fix: cron 观测缺口 — 费用记账 + 超预算不再静默成功 - #276
Conversation
cron_jobs 表和 CronJob 类型都带 maxBudgetUsd,但 scheduler.ts 调 executeTask 时从未传下去(只传 prompt/chatId/agentId 等),预算实际 一直由 agent 级配置(config/agents.json)决定。结果是通过 cron 设置 预算完全无效,属于「设了以为生效」的误导性字段。 老库遗留的 max_budget_usd 物理列保留不 DROP(避免对运行中库做不可逆 改动),列有 DEFAULT 5,INSERT 省略它不会触发约束错误。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
覆盖两点:读出的 job 不再带 maxBudgetUsd;老库仍有 max_budget_usd 列 (含最严格的 NOT NULL DEFAULT 形式)时 add/update 仍正常工作。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Claude finished @lishuceo's task in 2m 20s —— View job Claude Code Review
✅ Approved — no blocking issuesThe PR's core claim checks out. I verified at Cross-module verification:
🟡 Minor notes (non-blocking)1. Stale schema in That plan's DDL snippet still lists 2. Rollback direction is one-way (confidence: 80, low impact) Existing DBs keep the physical column, but DBs created after this change won't have it. If the service is ever rolled back to pre-PR code against such a DB, try {
this.db.exec(`ALTER TABLE cron_jobs ADD COLUMN max_budget_usd REAL DEFAULT 5`);
} catch { /* column already exists — ignore */ }Your call — the current "keep the comment, don't touch the column" approach is defensible and clearly documented at Notes on scopeThe PR's own "后续" section is accurate: TestingI could not execute Overall quality is good — tight scope, the reversible-migration choice is the right one for a running DB, and the regression tests target the two failure modes that actually matter. |
SDK 对 error_max_budget_usd 这类错误 result 不填顶层 usage(四个 token 计数全为 0),calculateCostFromUsage 照它算出来就是 0,而真实花费只存在于 total_cost_usd / modelUsage 里。结果是超预算——最该记账的场景——费用记成 0。 线上实测一次超预算的定时任务:usage 全 0,modelUsage 里却是 output 21 万 token、cacheRead 926 万 token,实际 $18.61。 新增 isUsageEmpty(),在顶层 usage 为空且 total_cost_usd > 0 时回退到 SDK 累计值,并在日志里用 costFallbackToSessionTotal 标明口径。usage 有值时行为 不变,仍按单次用量计算,避免 resume 首次 query 混入历史累计。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
三个用例:usage 全 0 时回退到会话累计(用线上真实的 $18.61 字段); usage 有值时不回退,仍按单次用量算;会话真的没花钱时保持 0。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
scheduler 只靠「executeTask 有没有抛异常」判断成败,而 executeDirectTask / executeClaudeTask 都返回 void。SDK 因会话累计花费超 maxBudgetUsd 而拒绝执行 时并不抛异常(实测 numTurns=1、零 token、18 秒返回,一个 turn 都没跑), 于是 run 被记成 status='ok' 且 cost_usd 为空 —— 任务连续多天空跑,而记录 显示一切正常。cron_runs.cost_usd 字段一直存在、updateRun 一直支持写入, 只是从来没人传值。 - 新增 TaskOutcome(success/costUsd/error/numTurns),从 ClaudeResult 提炼 - executeDirectTask / executeClaudeTask 覆盖全部出口返回 outcome (正常、workspace restart、resume 失败、catch) - CronTaskExecutor 返回 TaskOutcome | undefined | void;返回 undefined 时 保持旧行为,向后兼容 - executeJob 按 outcome.success 判定,失败走既有退避重试分支;成功与失败 两条路径都写入 cost_usd - 零轮执行在 lastError 里附上诊断提示,而不是只丢一个 subtype Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
三个用例:成功run 写入 cost_usd;失败 outcome 记 error 且照样记账(复现 08-02 那次 $18.61 超预算)并触发退避;executor 返回 void 时保持旧行为。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Claude finished @lishuceo's task in 5m 24s —— View job Claude Code Review
✅ Approved — no blocking issuesThe three fixes are independently correct and each is backed by a targeted regression test. 1. Dead-field removal (unchanged since my last pass) — verified again at 2. Cost fallback on error results (
The "错误型 result 下为会话累计口径" caveat is honest and documented in both the code comment and 3. Silent-success fix ( Caller-side type safety verified: 🟡 Minor notes (non-blocking)1. Two remaining silent-success holes in The new contract is "return
Both are reachable for a thread-mode cron job whose agent calls // 2964
return { success: false, costUsd: result.costUsd, error: '工作区准备失败,目录不存在', numTurns: result.numTurns };2. Restart branch reports only the second query's
3. Still outstanding from my previous pass — TestingI could not execute Overall: three tight, well-motivated fixes with genuinely good regression tests — the "executor returns nothing → legacy ok" test and the legacy-DB |
背景
定时任务「Watchlist 每日股票追踪」报
Reached maximum budget ($15)。排查出三个独立问题,本 PR 修其中两个观测缺口 + 清一个死字段。先说清根因:
$18.61不是跨天累计,noResume是生效的(日志resume: false,全新 sessionId)。就是 08-02 这一次 26 分钟真的花了 $18.61。反推 opus-5 单价(用 $18.6066 精确拟合)后的成本构成:降成本(换模型、收窄 pm 的 29 skills / 51 tools、拆任务)不在本 PR 范围。
修的问题
① 错误型 result 的费用记成 0
SDK 对
error_max_budget_usd不填顶层usage(四个 token 计数全 0),calculateCostFromUsage照它算就是 0。真实花费只在total_cost_usd/modelUsage里 —— 超预算恰恰是最该记账的场景。新增
isUsageEmpty(),顶层 usage 为空且total_cost_usd > 0时回退到 SDK 累计值,日志加costFallbackToSessionTotal标明口径。usage有值时行为不变,仍按单次用量算,避免 resume 首次 query 混入历史累计。② cron 把超预算的空跑记成成功
scheduler只靠「executeTask有没有抛异常」判断成败,而executeDirectTask/executeClaudeTask都返回void。SDK 拒绝执行时不抛异常,于是 run 记status='ok'且cost_usd为空。08-01 那次 run 18 秒就"完成"(零 turn,什么都没干)却记成 ok —— 任务连续多天空跑,记录显示一切正常。
cron_runs.cost_usd字段一直存在、updateRun一直支持写入,只是从来没人传值。TaskOutcome(success/costUsd/error/numTurns),从ClaudeResult提炼CronTaskExecutor返回TaskOutcome | undefined | void,返回 undefined 时保持旧行为executeJob按outcome.success判定,失败复用既有退避重试分支;两条路径都写cost_usdlastError附诊断提示,不只丢一个 subtype③ cron 的 maxBudgetUsd 死字段
cron_jobs表和CronJob类型都有maxBudgetUsd(出问题那个 job 存的是5.0),但scheduler.ts调executeTask时从未传下去。预算实际一直由 agent 级配置决定,通过 cron 层设预算完全无效。已从 types/store 移除。老库遗留物理列保留不 DROP(有
DEFAULT 5,省略插入不报错),避免对运行中库做不可逆改动。测试
新增 6 个回归测试:
maxBudgetUsd;老库带NOT NULL DEFAULT 5遗留列时 add/update 仍正常npx vitest run— 1784 passed / 1 failed。唯一失败是memory/quality.test.ts > 当前工作重点,已 stash 后在干净树上复现,属既有失败,与本 PR 无关。npm run typecheck干净;npm run lint0 errors。未处理
config/agents.json里"maxBudgetUsd": 15未改(config 不入 git,热重载生效)scheduler.ts同样没传model、timeoutSeconds、workingDir、repoUrl🤖 Generated with Claude Code