feat: 项目级成本预算系统(按模型×token 精确计费 + 超额自动暂停)
精确到项目级的成本/预算护栏,成本按「模型 × token 用量」折算(⑧ ★ 新特性)。
定价(单源):
- src/model/pricing.ts:MODEL_PRICES(opus/sonnet/haiku,USD/1M token,分
input/output/cacheRead/cacheWrite)+ computeCost/addUsage,前缀容错、未知模型记 0
token 捕获链(worker → daemon):
- cc.ts:从 SDK result 消息抓 usage,跨 resume/回退累计;CCResult.usage
- runner/reviewer:结果对象带 usage + modelUsed
- protocol.ts:新增 'usage' outbox 记录;pipeline 每次 CC(executor/复审/planner/
conflict)后 emit usage(worker 侧写文件,不碰 DB)
- ingest.ts:'usage' → store.setRunUsage(累加、按模型折算)→ enforceBudget
存储 + 护栏:
- schema/db.ts:runs.usage/cost_usd、projects.budget_usd/budget_period(幂等迁移)
- store.ts:setRunUsage(累加)、projectSpend、costSummary(按项目/模型/总计)、
enforceBudget(超额 → 置 paused + 广播 budget.exceeded)
- 编排器天然停领:orchestrator 既有「跳过 paused 项目」即生效,无需改
API:
- GET /api/usage:并入成本明细 {session,weekly,cost:{period,total,byProject,byModel}},?period/?project
- GET /api/health:{ok,db,inflight,at}
- PATCH /api/projects:支持 budgetUsd/budgetPeriod
前端:
- adapt.js:agentSummary 用真实成本/token;adaptProject 透传预算字段
- app.jsx:budget.exceeded → 告警 toast
- ConfigPanel:预算 $ + 周期(day|month)受控输入
验证:typecheck 干净;206 测试通过(含新增 budget.test.ts 4 例:定价折算/累加/
costSummary/enforceBudget);前端 build + 截图确认预算输入渲染、0 错误。
注:生效需合并后重启 daemon(迁移幂等加列、向后兼容旧库)。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+22
-2
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { isModelError, pickFallbackModel } from './models.js';
|
||||
import { type UsageTokens, EMPTY_USAGE, addUsage } from '../model/pricing.js';
|
||||
|
||||
/** 转录目录:<MAESTRO_DATA_DIR 或 ~/.maestro>/transcripts */
|
||||
export function transcriptDir(): string {
|
||||
@@ -27,6 +28,7 @@ export interface CCResult {
|
||||
transcriptRef: string;
|
||||
modelUsed: string; // 实际使用的模型(发生回退时为回退模型)
|
||||
fellBack: boolean;
|
||||
usage: UsageTokens; // 本 run 的 token 用量(含 resume/回退累计),供预算成本折算
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -34,6 +36,7 @@ interface AttemptResult {
|
||||
ok: boolean;
|
||||
finalText: string;
|
||||
sessionId: string | null;
|
||||
usage: UsageTokens; // 本次会话的 token 用量(result 消息提供;失败/无 result 时为空)
|
||||
error?: string;
|
||||
/**
|
||||
* 失败是否属于「会话异常中断」——即流式断了 / 没收到 result 就结束(SDK 偶发抖动)。
|
||||
@@ -65,6 +68,7 @@ async function attempt(
|
||||
let sawResult = false;
|
||||
let resumable = false;
|
||||
let timedOut = false;
|
||||
let usage: UsageTokens = { ...EMPTY_USAGE };
|
||||
|
||||
const abort = new AbortController();
|
||||
const timeoutMs = extra?.timeoutMs ?? opts.timeoutMs;
|
||||
@@ -91,6 +95,18 @@ async function attempt(
|
||||
if (typeof sid === 'string' && sid) sessionId = sid;
|
||||
if (message.type === 'result') {
|
||||
sawResult = true;
|
||||
const u = (message as { usage?: unknown }).usage as {
|
||||
input_tokens?: number; output_tokens?: number;
|
||||
cache_read_input_tokens?: number; cache_creation_input_tokens?: number;
|
||||
} | undefined;
|
||||
if (u) {
|
||||
usage = {
|
||||
input: u.input_tokens ?? 0,
|
||||
output: u.output_tokens ?? 0,
|
||||
cacheRead: u.cache_read_input_tokens ?? 0,
|
||||
cacheWrite: u.cache_creation_input_tokens ?? 0,
|
||||
};
|
||||
}
|
||||
if (message.subtype === 'success' && !message.is_error) {
|
||||
resultOk = true;
|
||||
const txt = (message as { result?: unknown }).result;
|
||||
@@ -116,8 +132,8 @@ async function attempt(
|
||||
clearTimeout(killer);
|
||||
}
|
||||
|
||||
if (!resultOk) return { ok: false, finalText: '', sessionId, error: resultError ?? '未知错误', resumable };
|
||||
return { ok: true, finalText, sessionId };
|
||||
if (!resultOk) return { ok: false, finalText: '', sessionId, usage, error: resultError ?? '未知错误', resumable };
|
||||
return { ok: true, finalText, sessionId, usage };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,12 +159,14 @@ export async function runClaude(opts: CCOptions, queryImpl: CCQuery = query): Pr
|
||||
let r = await attempt(opts, opts.model, out, queryImpl);
|
||||
let modelUsed = opts.model;
|
||||
let fellBack = false;
|
||||
let usage = r.usage; // 累计各次尝试(resume/回退)的 token 用量
|
||||
|
||||
// 1. SDK 抖动续跑:会话异常中断 + 有 sessionId + 非模型错误(那归回退处理) + 未关闭
|
||||
if (!r.ok && r.resumable && r.sessionId && !isModelError(r.error ?? '') && process.env.MAESTRO_SDK_RESUME !== '0') {
|
||||
out.write(`${JSON.stringify({ type: 'maestro.session_resume', sessionId: r.sessionId, reason: r.error })}\n`);
|
||||
const remaining = Math.max(opts.timeoutMs - (Date.now() - t0), RESUME_MIN_TIMEOUT_MS);
|
||||
r = await attempt(opts, opts.model, out, queryImpl, { resume: r.sessionId, timeoutMs: remaining });
|
||||
usage = addUsage(usage, r.usage);
|
||||
}
|
||||
|
||||
if (!r.ok && r.error && isModelError(r.error)) {
|
||||
@@ -156,6 +174,7 @@ export async function runClaude(opts: CCOptions, queryImpl: CCQuery = query): Pr
|
||||
if (fb) {
|
||||
out.write(`${JSON.stringify({ type: 'maestro.model_fallback', from: opts.model, to: fb, reason: r.error })}\n`);
|
||||
r = await attempt(opts, fb, out, queryImpl);
|
||||
usage = addUsage(usage, r.usage);
|
||||
modelUsed = fb;
|
||||
fellBack = true;
|
||||
}
|
||||
@@ -169,6 +188,7 @@ export async function runClaude(opts: CCOptions, queryImpl: CCQuery = query): Pr
|
||||
transcriptRef,
|
||||
modelUsed,
|
||||
fellBack,
|
||||
usage,
|
||||
...(r.error ? { error: fellBack ? `${r.error}(已回退至 ${modelUsed} 重试)` : r.error } : {}),
|
||||
};
|
||||
} finally {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// (worker 退出) → emit done
|
||||
|
||||
import type { JobSpec, OutboxPayload, ReviewReport, DecomposeResult } from './protocol.js';
|
||||
import type { UsageTokens } from '../model/types.js';
|
||||
import { createWorktree, worktreeDiff, git, type WorktreeDiff, type WorktreeInfo } from './worktree.js';
|
||||
import { runTask, runPlanner, runConflict, type RunnerFn, type PlannerFn } from './runner.js';
|
||||
import { runVerify, type VerifyFn } from './verify.js';
|
||||
@@ -78,15 +79,25 @@ async function runOneReview(
|
||||
wt: WorktreeInfo,
|
||||
reviewRunId: string,
|
||||
executorReport: string,
|
||||
emit: Emit,
|
||||
): Promise<ReviewReport> {
|
||||
try {
|
||||
const rv = await fn(job.task, job.project, wt, reviewRunId, executorReport);
|
||||
emitUsage(emit, rv); // 复审也耗 token,计入本 run 成本
|
||||
return { summary: rv.summary, verdict: rv.verdict, transcriptRef: rv.transcriptRef };
|
||||
} catch (e) {
|
||||
return { summary: `自动复审失败:${(e as Error).message}`, verdict: null, transcriptRef: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** 把一次 CC 的 token 用量发成 usage outbox 记录(有用量才发;daemon ingest → 累加成本)。 */
|
||||
function emitUsage(emit: Emit, r: { usage?: UsageTokens; modelUsed?: string }): void {
|
||||
const u = r.usage;
|
||||
if (!u || !r.modelUsed) return;
|
||||
if (u.input + u.output + u.cacheRead + u.cacheWrite <= 0) return;
|
||||
emit({ type: 'usage', usage: u, model: r.modelUsed });
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯执行管线。复刻旧 in-process 流程的【执行与产出】,但所有 store.* 改成 emit(outbox):
|
||||
* 1. createWorktree
|
||||
@@ -121,6 +132,7 @@ export async function runPipeline(job: JobSpec, deps: PipelineDeps = realDeps, e
|
||||
// 2. 执行
|
||||
emit({ type: 'phase', phase: 'executing' });
|
||||
const rr = await deps.runTask(job.task, job.project, wt, job.runId);
|
||||
emitUsage(emit, rr);
|
||||
if (!rr.ok) {
|
||||
emit({ type: 'failed', error: rr.error ?? '执行失败', transcriptRef: rr.transcriptRef, sessionId: rr.sessionId });
|
||||
emit({ type: 'done' });
|
||||
@@ -144,8 +156,8 @@ export async function runPipeline(job: JobSpec, deps: PipelineDeps = realDeps, e
|
||||
emit({ type: 'phase', phase: 'reviewing' });
|
||||
const report = rr.finalText ?? '';
|
||||
const [code, security] = await Promise.all([
|
||||
runOneReview(deps.reviewCode, job, wt, `${job.runId}.review`, report),
|
||||
runOneReview(deps.reviewSecurity, job, wt, `${job.runId}.security`, report),
|
||||
runOneReview(deps.reviewCode, job, wt, `${job.runId}.review`, report, emit),
|
||||
runOneReview(deps.reviewSecurity, job, wt, `${job.runId}.security`, report, emit),
|
||||
]);
|
||||
|
||||
// 6. 成功终态
|
||||
@@ -172,6 +184,7 @@ async function runPlannerPipeline(job: JobSpec, deps: PipelineDeps, emit: Emit):
|
||||
const kind = job.runKind === 'planner-spec' ? 'spec' : 'decompose';
|
||||
emit({ type: 'phase', phase: kind === 'spec' ? 'speccing' : 'decomposing' });
|
||||
const r = await deps.runPlanner(job.task, job.project, kind, job.runId);
|
||||
emitUsage(emit, r);
|
||||
if (!r.ok || !r.finalText) {
|
||||
emit({ type: 'failed', error: r.error ?? 'planner 无输出', transcriptRef: r.transcriptRef, sessionId: r.sessionId });
|
||||
emit({ type: 'done' });
|
||||
@@ -234,6 +247,7 @@ async function runConflictPipeline(job: JobSpec, deps: PipelineDeps, emit: Emit)
|
||||
// 4. CC 解冲突
|
||||
emit({ type: 'phase', phase: 'resolving' });
|
||||
const rr = await deps.runConflict(job.task, job.project, wt, job.runId, conflictFiles);
|
||||
emitUsage(emit, rr);
|
||||
if (!rr.ok) {
|
||||
emit({ type: 'failed', error: rr.error ?? '解冲突失败', transcriptRef: rr.transcriptRef, sessionId: rr.sessionId });
|
||||
emit({ type: 'done' });
|
||||
@@ -247,8 +261,8 @@ async function runConflictPipeline(job: JobSpec, deps: PipelineDeps, emit: Emit)
|
||||
emit({ type: 'phase', phase: 'reviewing' });
|
||||
const report = rr.finalText ?? '';
|
||||
const [code, security] = await Promise.all([
|
||||
runOneReview(deps.reviewCode, job, wt, `${job.runId}.review`, report),
|
||||
runOneReview(deps.reviewSecurity, job, wt, `${job.runId}.security`, report),
|
||||
runOneReview(deps.reviewCode, job, wt, `${job.runId}.review`, report, emit),
|
||||
runOneReview(deps.reviewSecurity, job, wt, `${job.runId}.security`, report, emit),
|
||||
]);
|
||||
|
||||
// 7. 成功终态
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { Project, Task, ReviewVerdict } from '../model/types.js';
|
||||
import type { Project, Task, ReviewVerdict, UsageTokens } from '../model/types.js';
|
||||
|
||||
/** 数据根目录(与 worktree.ts 同约定):<MAESTRO_DATA_DIR 或 ~/.maestro> */
|
||||
function dataDir(): string {
|
||||
@@ -128,6 +128,8 @@ export type OutboxRecord =
|
||||
}>;
|
||||
transcriptRef: string | null; sessionId: string | null;
|
||||
}
|
||||
// token 用量(任意 CC run 结束后发;daemon ingest → setRunUsage 折算成本,再判预算)
|
||||
| { seq: number; at: string; type: 'usage'; usage: UsageTokens; model: string }
|
||||
| { seq: number; at: string; type: 'done' };
|
||||
|
||||
/** OutboxRecord 去掉 seq/at(由 appendOutbox 填) */
|
||||
@@ -138,6 +140,7 @@ export type OutboxPayload =
|
||||
| Omit<Extract<OutboxRecord, { type: 'result' }>, 'seq' | 'at'>
|
||||
| Omit<Extract<OutboxRecord, { type: 'spec-result' }>, 'seq' | 'at'>
|
||||
| Omit<Extract<OutboxRecord, { type: 'decompose-result' }>, 'seq' | 'at'>
|
||||
| Omit<Extract<OutboxRecord, { type: 'usage' }>, 'seq' | 'at'>
|
||||
| Omit<Extract<OutboxRecord, { type: 'done' }>, 'seq' | 'at'>;
|
||||
|
||||
/** 追加一条 outbox 记录(worker 侧调用)。seq = 现有行数+1(worker 单线程,无并发写)。返回写入的完整记录。 */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Project, Task, ReviewVerdict } from '../model/types.js';
|
||||
import type { Project, Task, ReviewVerdict, UsageTokens } from '../model/types.js';
|
||||
import type { WorktreeInfo } from './worktree.js';
|
||||
import { runClaude } from './cc.js';
|
||||
import { pickModel } from './models.js';
|
||||
@@ -11,6 +11,8 @@ export interface ReviewResult {
|
||||
verdict: ReviewVerdict | null; // 解析不到 = null
|
||||
transcriptRef: string | null;
|
||||
sessionId: string | null;
|
||||
modelUsed?: string;
|
||||
usage?: UsageTokens;
|
||||
}
|
||||
|
||||
/** 复审函数签名(orchestrator 依赖注入点;测试传 mock,生产传 reviewCode/reviewSecurity)。失败时抛错,由 orchestrator 兜底。 */
|
||||
@@ -144,7 +146,7 @@ export async function runReview(
|
||||
if (!cc.finalText.trim()) throw new Error(`${label}未产出最终文本`);
|
||||
|
||||
const { summary, verdict } = parseVerdict(cc.finalText);
|
||||
return { summary, verdict, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId };
|
||||
return { summary, verdict, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, modelUsed: cc.modelUsed, usage: cc.usage };
|
||||
}
|
||||
|
||||
/** code review 入口(kind=reviewer 的 run) */
|
||||
|
||||
+12
-8
@@ -1,4 +1,4 @@
|
||||
import type { Project, Task } from '../model/types.js';
|
||||
import type { Project, Task, UsageTokens } from '../model/types.js';
|
||||
import { git, type WorktreeInfo } from './worktree.js';
|
||||
import { runClaude } from './cc.js';
|
||||
import { pickModel } from './models.js';
|
||||
@@ -13,6 +13,8 @@ export interface RunnerResult {
|
||||
finalText?: string | null;
|
||||
/** 实际使用的模型(发生回退时为回退模型) */
|
||||
modelUsed?: string;
|
||||
/** 本次 CC 的 token 用量(供预算成本折算) */
|
||||
usage?: UsageTokens;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -83,16 +85,16 @@ export async function runTask(task: Task, project: Project, worktree: WorktreeIn
|
||||
});
|
||||
|
||||
if (!cc.ok) {
|
||||
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: null, modelUsed: cc.modelUsed, error: cc.error ?? '未知错误' };
|
||||
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: null, modelUsed: cc.modelUsed, usage: cc.usage, error: cc.error ?? '未知错误' };
|
||||
}
|
||||
|
||||
// 兜底:CC 没 commit 时由 runner 代为提交
|
||||
try {
|
||||
await ensureCommitted(worktree.dir, task);
|
||||
} catch (e) {
|
||||
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, error: `兜底提交失败:${(e as Error).message}` };
|
||||
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, usage: cc.usage, error: `兜底提交失败:${(e as Error).message}` };
|
||||
}
|
||||
return { ok: true, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed };
|
||||
return { ok: true, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, usage: cc.usage };
|
||||
}
|
||||
|
||||
// ───────────────────────── planner(拆解 Hard / 写方案 Medium)─────────────────────────
|
||||
@@ -104,6 +106,8 @@ export interface PlannerResult {
|
||||
transcriptRef: string | null;
|
||||
sessionId: string | null;
|
||||
finalText: string | null; // CC 的最终文本:spec=方案正文;decompose=分析 + 末尾 fenced JSON
|
||||
modelUsed?: string;
|
||||
usage?: UsageTokens;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -170,7 +174,7 @@ export async function runPlanner(task: Task, project: Project, kind: PlanKind, r
|
||||
permissionMode: 'acceptEdits', // planner 只读,不会触发编辑;保持工具流不被 prompt 卡住
|
||||
allowedTools: ['Read', 'Glob', 'Grep', 'Bash(git log:*)', 'Bash(git diff:*)', 'Bash(git show:*)'], // 纯只读:无 Edit/Write,零副作用
|
||||
});
|
||||
return { ok: cc.ok, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, error: cc.error };
|
||||
return { ok: cc.ok, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, usage: cc.usage, error: cc.error };
|
||||
}
|
||||
|
||||
/** 解冲突任务的提示词:worker 已在 worktree 内触发 git merge 制造冲突态,CC 负责逐个解决。 */
|
||||
@@ -230,13 +234,13 @@ export async function runConflict(
|
||||
});
|
||||
|
||||
if (!cc.ok) {
|
||||
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: null, modelUsed: cc.modelUsed, error: cc.error ?? '解冲突失败' };
|
||||
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: null, modelUsed: cc.modelUsed, usage: cc.usage, error: cc.error ?? '解冲突失败' };
|
||||
}
|
||||
// 兜底提交(CC 应已 commit,但防万一)
|
||||
try {
|
||||
await ensureCommitted(worktree.dir, task);
|
||||
} catch (e) {
|
||||
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, error: `兜底提交失败:${(e as Error).message}` };
|
||||
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, usage: cc.usage, error: `兜底提交失败:${(e as Error).message}` };
|
||||
}
|
||||
return { ok: true, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed };
|
||||
return { ok: true, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, usage: cc.usage };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user