Commit 6a83257ecf73669013ff8b4a230fddbf1d95ac9f
1 parent
20fed0a5
fix(budget): 墙钟只算 run 真的在跑的时间,不算挂起等人的时间
## 病症:blocked 和墙钟预算互相拆台 墙钟按 `now - spent.started_at` 算,而 started_at 是「第一次花钱的绝对时刻」。 blocked 的定义恰恰是**挂起等人工** —— 预算耗尽、节点判罚都会把 run 打到 blocked。 于是:人隔夜来处置 → 等待那段被算成「已用」→ 一起跑就报 wallclock_ms 耗尽 → 再 blocked → 再等人。**一条过夜的 run 从此永远醒不过来。** 而且报出来的原因具有误导性:写着 wallclock_ms(跑太久),实际是等人等太久。 真事:run_9168bd24f46c(e2e-demo 做官网)08-18 15:48 起跑、16:19 判 blocked, 只花了 7/100 次调用、69,961/5,000,000 tokens —— **预算用掉不到 2%,两天没能重跑**。 它正是 27-p3c-batch3-handover §五 那条「还没解决的原始问题」。 ## 修法 spent 里改记 `active_ms`(累计活跃时长),started_at 退化成「当前这一段的起点」, 不在 running 时为 null。已用 = active_ms + 当前段。 起止结算挂在 graph-run-sse.afterRunTransition 这个**单一漏斗**上,不去枚举 「哪些地方会让 run 停下来」—— 枚举漏一条就是静默少算。 ⚠️ 钩子必须放在那个 early return **之前**:进 running 既不是收敛、也不空坑, 两个条件都不满足,放后面就变成「只结算停下来、从不开始计时」, 症状是 active_ms 恒 0、墙钟形同虚设,而且照样全绿。守卫里专门有一条钉这个位置 (反向验过:把钩子注释掉,那一条立刻红)。 spendBudget 不再兼职锚墙钟 —— 一个事实一处持有者。 ## 存量行 active_ms 缺失的行:旧锚点里混着等待时间,真实活跃时长复原不出来, 按 0 起算、一笔勾销。有意为之 —— 另外两维(调用次数 / tokens)是精确的且没受影响, 真正兜住花钱的是那两维,墙钟只是跑飞保护。所以那条卡住的 run 不需要动库, 重试进 running 时自动重新锚定。 ## ponytail 只认 running 一个态。manual 模式下两次点击之间 run 也停在 running, 那段人类思考时间仍会计入 —— 上限已知,比原来跨天累计小几个数量级。 要更准得按 agent 调用的起止配对计时,那是另一套账。 ## 顺带 goal-task-acceptance 两条用例原先 deepEqual 整个 budget 对象,现在剥掉 spent 只比申报的额度 —— 它守的是「用户填的额度没被静默换成默认值」, 跟运行期账本长什么样无关;账本另有断言(起跑那一刻 agent_calls/tokens 仍是 0)。 全量:137 文件 / 1284 测试全绿。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
4 changed files
with
216 additions
and
8 deletions
server/services/graph-engine.js
| @@ -2248,6 +2248,64 @@ function readBudget(run) { | @@ -2248,6 +2248,64 @@ function readBudget(run) { | ||
| 2248 | } | 2248 | } |
| 2249 | 2249 | ||
| 2250 | /** | 2250 | /** |
| 2251 | + * 墙钟已用时长 = **已结算的活跃时长** + 当前这一段还没结算的。 | ||
| 2252 | + * | ||
| 2253 | + * ── 为什么不是「now - 第一次花钱的时刻」 ────────────────────────────────── | ||
| 2254 | + * 原来就是那么算的,它和 blocked 的语义直接对撞:预算耗尽 / 节点判罚都会把 run 打到 | ||
| 2255 | + * `blocked`,而 blocked 的定义就是**挂起等人工**。人隔夜再来处置时,墙钟把这段等待 | ||
| 2256 | + * 一并算进了「已用」,于是一起跑就报 `wallclock_ms` 耗尽 → 再 blocked → 再等人。 | ||
| 2257 | + * **一条过夜的 run 从此永远醒不过来**,而报出来的原因是「跑太久」,实际是「等人等太久」。 | ||
| 2258 | + * | ||
| 2259 | + * 实例:`run_9168bd24f46c`(e2e-demo 做官网)2026-08-18 15:48 起跑,预算 2h, | ||
| 2260 | + * 只花了 7/100 次调用、69,961/5,000,000 tokens,08-18 16:19 判 blocked 之后就再没能起来。 | ||
| 2261 | + * | ||
| 2262 | + * ponytail: 只认 `running` 一个态。manual 模式下两次点击之间 run 也停在 running, | ||
| 2263 | + * 那段人类思考时间仍会计入 —— 上限已知,比原来跨天累计小几个数量级。 | ||
| 2264 | + * 要更准就得按 agent 调用的起止配对计时,那是另一套账。 | ||
| 2265 | + */ | ||
| 2266 | +function wallclockElapsedMs(spent = {}) { | ||
| 2267 | + const settled = spent.active_ms || 0; | ||
| 2268 | + if (!spent.started_at) return settled; | ||
| 2269 | + return settled + Math.max(0, Date.now() - Date.parse(spent.started_at)); | ||
| 2270 | +} | ||
| 2271 | + | ||
| 2272 | +/** | ||
| 2273 | + * 墙钟的起止结算。挂在 `graph-run-sse.afterRunTransition` 这个**单一漏斗**上, | ||
| 2274 | + * 而不是去枚举「哪些地方会让 run 停下来」—— 那种枚举漏一条就是静默少算。 | ||
| 2275 | + * | ||
| 2276 | + * 进 running:开一段新的计时。离开 running:把这一段并进 active_ms 并断开锚点。 | ||
| 2277 | + * | ||
| 2278 | + * **存量行**(`active_ms` 缺失):旧锚点里混着等待时间,真实活跃时长已经复原不出来了。 | ||
| 2279 | + * 这里把它按 0 起算,等于把过去那段一笔勾销 —— 有意为之:另外两维(调用次数 / tokens) | ||
| 2280 | + * 是精确的、且没受影响,真正兜住花钱的是那两维;墙钟只是跑飞保护。 | ||
| 2281 | + */ | ||
| 2282 | +function settleWallclock(runId, to) { | ||
| 2283 | + const run = getRun(runId); | ||
| 2284 | + if (!run || run.parent_run_id) return null; // 预算只挂在 root run 那一行 | ||
| 2285 | + const held = readBudget(run); | ||
| 2286 | + if (!held || held.budget.wallclock_ms == null) return null; | ||
| 2287 | + | ||
| 2288 | + const b = held.budget; | ||
| 2289 | + if (!b.spent) b.spent = {}; | ||
| 2290 | + const s = b.spent; | ||
| 2291 | + | ||
| 2292 | + if (to === 'running') { | ||
| 2293 | + s.active_ms = s.active_ms || 0; | ||
| 2294 | + s.started_at = now(); // 无条件重锚:进 running 就是新一段的开始 | ||
| 2295 | + } else { | ||
| 2296 | + if (s.started_at) { | ||
| 2297 | + s.active_ms = (s.active_ms || 0) + Math.max(0, Date.now() - Date.parse(s.started_at)); | ||
| 2298 | + } | ||
| 2299 | + s.active_ms = s.active_ms || 0; | ||
| 2300 | + s.started_at = null; | ||
| 2301 | + } | ||
| 2302 | + getDb() | ||
| 2303 | + .prepare(`UPDATE graph_runs SET budget_json = ?, updated_at = ? WHERE id = ?`) | ||
| 2304 | + .run(JSON.stringify(b), now(), held.rootRunId); | ||
| 2305 | + return s; | ||
| 2306 | +} | ||
| 2307 | + | ||
| 2308 | +/** | ||
| 2251 | * 余额检查。任何一层发起 agent 调用前都要过这一关(advanceOne 拾取节点之后立刻查)。 | 2309 | * 余额检查。任何一层发起 agent 调用前都要过这一关(advanceOne 拾取节点之后立刻查)。 |
| 2252 | * @returns {{ok: boolean, exhausted: string[], rootRunId?: string, budget?: object}} | 2310 | * @returns {{ok: boolean, exhausted: string[], rootRunId?: string, budget?: object}} |
| 2253 | */ | 2311 | */ |
| @@ -2262,10 +2320,7 @@ function checkBudget(run) { | @@ -2262,10 +2320,7 @@ function checkBudget(run) { | ||
| 2262 | exhausted.push('total_agent_calls'); | 2320 | exhausted.push('total_agent_calls'); |
| 2263 | } | 2321 | } |
| 2264 | if (b.total_tokens != null && (spent.tokens || 0) >= b.total_tokens) exhausted.push('total_tokens'); | 2322 | if (b.total_tokens != null && (spent.tokens || 0) >= b.total_tokens) exhausted.push('total_tokens'); |
| 2265 | - if ( | ||
| 2266 | - b.wallclock_ms != null && spent.started_at && | ||
| 2267 | - Date.now() - Date.parse(spent.started_at) >= b.wallclock_ms | ||
| 2268 | - ) { | 2323 | + if (b.wallclock_ms != null && wallclockElapsedMs(spent) >= b.wallclock_ms) { |
| 2269 | exhausted.push('wallclock_ms'); | 2324 | exhausted.push('wallclock_ms'); |
| 2270 | } | 2325 | } |
| 2271 | return { ok: exhausted.length === 0, exhausted, rootRunId: held.rootRunId, budget: b }; | 2326 | return { ok: exhausted.length === 0, exhausted, rootRunId: held.rootRunId, budget: b }; |
| @@ -2284,7 +2339,7 @@ function spendBudget(run, { agentCalls = 0, tokens = 0 } = {}) { | @@ -2284,7 +2339,7 @@ function spendBudget(run, { agentCalls = 0, tokens = 0 } = {}) { | ||
| 2284 | if (!b.spent) b.spent = {}; | 2339 | if (!b.spent) b.spent = {}; |
| 2285 | b.spent.agent_calls = (b.spent.agent_calls || 0) + agentCalls; | 2340 | b.spent.agent_calls = (b.spent.agent_calls || 0) + agentCalls; |
| 2286 | b.spent.tokens = (b.spent.tokens || 0) + tokens; | 2341 | b.spent.tokens = (b.spent.tokens || 0) + tokens; |
| 2287 | - if (!b.spent.started_at) b.spent.started_at = now(); // 墙钟从第一次真花钱开始算 | 2342 | + // 墙钟不在这里锚 —— 它按「run 处于 running 的时长」累计,起止都归 settleWallclock 管。 |
| 2288 | getDb() | 2343 | getDb() |
| 2289 | .prepare(`UPDATE graph_runs SET budget_json = ?, updated_at = ? WHERE id = ?`) | 2344 | .prepare(`UPDATE graph_runs SET budget_json = ?, updated_at = ? WHERE id = ?`) |
| 2290 | .run(JSON.stringify(b), now(), held.rootRunId); | 2345 | .run(JSON.stringify(b), now(), held.rootRunId); |
| @@ -3842,6 +3897,8 @@ module.exports = { | @@ -3842,6 +3897,8 @@ module.exports = { | ||
| 3842 | // 任务级总预算(§四.A.3) | 3897 | // 任务级总预算(§四.A.3) |
| 3843 | checkBudget, | 3898 | checkBudget, |
| 3844 | spendBudget, | 3899 | spendBudget, |
| 3900 | + settleWallclock, | ||
| 3901 | + wallclockElapsedMs, | ||
| 3845 | suspendRunTree, | 3902 | suspendRunTree, |
| 3846 | 3903 | ||
| 3847 | // 重试级联判据(§四.C 末段) | 3904 | // 重试级联判据(§四.C 末段) |
server/services/graph-run-sse.js
| @@ -343,6 +343,15 @@ const { ACTIVE_ROOT_RUN_STATUS } = workGraphDb; | @@ -343,6 +343,15 @@ const { ACTIVE_ROOT_RUN_STATUS } = workGraphDb; | ||
| 343 | * 两件事各自 try/catch:settle 炸了不能连累调度,否则一次回写异常就把整个队列冻住。 | 343 | * 两件事各自 try/catch:settle 炸了不能连累调度,否则一次回写异常就把整个队列冻住。 |
| 344 | */ | 344 | */ |
| 345 | function afterRunTransition(runId, to) { | 345 | function afterRunTransition(runId, to) { |
| 346 | + // 墙钟起止结算 —— 必须在下面那个 early return **之前**:进 `running` 既不是收敛、 | ||
| 347 | + // 也不空坑,两个条件都不满足,放在后面等于「只结算停下来、从不开始计时」。 | ||
| 348 | + try { | ||
| 349 | + // 惰性 require:graph-engine → 本模块,顶层 require 必成环(与下面两处同理) | ||
| 350 | + require('./graph-engine').settleWallclock(runId, to); | ||
| 351 | + } catch (err) { | ||
| 352 | + console.error(`[graphRunSse] 墙钟结算异常 run=${runId} → ${to}: ${err.message}`); | ||
| 353 | + } | ||
| 354 | + | ||
| 346 | const settled = ROOT_RUN_SETTLED_STATUS.has(to); | 355 | const settled = ROOT_RUN_SETTLED_STATUS.has(to); |
| 347 | const freedSlot = !ACTIVE_ROOT_RUN_STATUS.includes(to); | 356 | const freedSlot = !ACTIVE_ROOT_RUN_STATUS.includes(to); |
| 348 | if (!settled && !freedSlot) return; | 357 | if (!settled && !freedSlot) return; |
server/tests/unit/budget-wallclock-active.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * Unit Tests — 墙钟预算只算「真的在跑」的时间,不算挂起等人的时间 | ||
| 3 | + * | ||
| 4 | + * ── 挡的是什么 ───────────────────────────────────────────────────────── | ||
| 5 | + * 原实现按 `now - spent.started_at` 算,而 started_at 是「第一次花钱的绝对时刻」。 | ||
| 6 | + * 这与 blocked 的语义直接对撞:预算耗尽、节点判罚都会把 run 打到 blocked, | ||
| 7 | + * 而 blocked 的定义就是**挂起等人工**。人隔夜再来处置 →「等待」被算成「已用」→ | ||
| 8 | + * 一起跑就报 wallclock_ms 耗尽 → 再 blocked → 再等人。**过夜的 run 永远醒不过来。** | ||
| 9 | + * | ||
| 10 | + * 而且报出来的原因具有误导性:写着 `wallclock_ms`(跑太久),实际是等人等太久。 | ||
| 11 | + * | ||
| 12 | + * 真事:run_9168bd24f46c(e2e-demo 做官网)2026-08-18 15:48 起跑、16:19 判 blocked, | ||
| 13 | + * 只花了 7/100 次调用、69,961/5,000,000 tokens,两天后仍卡死 —— 用掉的预算不到 2%。 | ||
| 14 | + */ | ||
| 15 | +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; | ||
| 16 | +import fs from 'fs'; | ||
| 17 | +import os from 'os'; | ||
| 18 | +import path from 'path'; | ||
| 19 | + | ||
| 20 | +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-wallclock-')); | ||
| 21 | +process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite'); | ||
| 22 | + | ||
| 23 | +const engine = require('../../services/graph-engine'); | ||
| 24 | +const wdb = require('../../services/work-graph-db'); | ||
| 25 | + | ||
| 26 | +const HOUR = 3600_000; | ||
| 27 | +const budget = (spent) => JSON.stringify({ wallclock_ms: 2 * HOUR, total_agent_calls: 100, spent }); | ||
| 28 | + | ||
| 29 | +/** 直接落一行 root run,只为拿一个带 budget_json 的载体(不走图定义那一整套) */ | ||
| 30 | +function seedRun(id, spentObj, status = 'running') { | ||
| 31 | + const db = wdb.getDb(); | ||
| 32 | + const t = '2026-08-18T15:48:00.000Z'; | ||
| 33 | + db.prepare(`INSERT INTO graph_runs | ||
| 34 | + (id, graph_kind, graph_id, company_id, root_run_id, execution_mode, run_status, budget_json, created_at, updated_at) | ||
| 35 | + VALUES (?, 'org', 'g1', 'co1', ?, 'manual', ?, ?, ?, ?)`) | ||
| 36 | + .run(id, id, status, budget(spentObj), t, t); | ||
| 37 | + return wdb.getRun(id); | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +describe('预算 · 墙钟只算活跃时长', () => { | ||
| 41 | + afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); | ||
| 42 | + beforeEach(() => wdb.getDb().prepare('DELETE FROM graph_runs').run()); | ||
| 43 | + | ||
| 44 | + it('挂起期间墙钟不走:两天前 blocked 的 run,余额照旧', () => { | ||
| 45 | + // 这一条就是 run_9168bd24f46c 的形态:active_ms 只有 31 分钟,started_at 已断开 | ||
| 46 | + const run = seedRun('r_old', { agent_calls: 7, tokens: 69961, active_ms: 31 * 60_000, started_at: null }, 'blocked'); | ||
| 47 | + const gate = engine.checkBudget(run); | ||
| 48 | + expect(gate.exhausted).toEqual([]); | ||
| 49 | + expect(gate.ok).toBe(true); | ||
| 50 | + }); | ||
| 51 | + | ||
| 52 | + it('旧算法下这条 run 必然耗尽 —— 证明这个测试真的在量东西', () => { | ||
| 53 | + // 反例对照:起点是两天前的绝对时刻、没有 active_ms(存量行的原始形态) | ||
| 54 | + const spent = { agent_calls: 7, tokens: 69961, started_at: '2026-08-18T15:48:17.630Z' }; | ||
| 55 | + const legacyElapsed = Date.now() - Date.parse(spent.started_at); | ||
| 56 | + expect(legacyElapsed).toBeGreaterThan(2 * HOUR); // 旧判据:早就超了 | ||
| 57 | + // 新判据看的是 active_ms,started_at 断开后就不再累加 | ||
| 58 | + expect(engine.wallclockElapsedMs({ ...spent, started_at: null, active_ms: 31 * 60_000 })).toBeLessThan(2 * HOUR); | ||
| 59 | + }); | ||
| 60 | + | ||
| 61 | + it('活跃段分次累加:三段各 40 分钟 → 第三段跑到一半就该耗尽', () => { | ||
| 62 | + expect(engine.wallclockElapsedMs({ active_ms: 80 * 60_000, started_at: null })).toBe(80 * 60_000); | ||
| 63 | + const run = seedRun('r_acc', { active_ms: 119 * 60_000, started_at: null }, 'blocked'); | ||
| 64 | + expect(engine.checkBudget(run).ok).toBe(true); | ||
| 65 | + wdb.getDb().prepare('UPDATE graph_runs SET budget_json = ? WHERE id = ?') | ||
| 66 | + .run(budget({ active_ms: 121 * 60_000, started_at: null }), 'r_acc'); | ||
| 67 | + expect(engine.checkBudget(wdb.getRun('r_acc')).exhausted).toEqual(['wallclock_ms']); | ||
| 68 | + }); | ||
| 69 | + | ||
| 70 | + it('settleWallclock:进 running 开始计时,离开 running 把这一段并进 active_ms', () => { | ||
| 71 | + seedRun('r_settle', { active_ms: 5 * 60_000, started_at: null }, 'running'); | ||
| 72 | + const started = engine.settleWallclock('r_settle', 'running'); | ||
| 73 | + expect(started.started_at).toBeTruthy(); | ||
| 74 | + expect(started.active_ms).toBe(5 * 60_000); | ||
| 75 | + | ||
| 76 | + const stopped = engine.settleWallclock('r_settle', 'blocked'); | ||
| 77 | + expect(stopped.started_at).toBeNull(); | ||
| 78 | + expect(stopped.active_ms).toBeGreaterThanOrEqual(5 * 60_000); // 只增不减 | ||
| 79 | + expect(stopped.active_ms).toBeLessThan(6 * 60_000); // 这一段是毫秒级,不该凭空多出一分钟 | ||
| 80 | + }); | ||
| 81 | + | ||
| 82 | + it('存量行(没有 active_ms)进 running 时按 0 起算,不把旧锚点当活跃时长', () => { | ||
| 83 | + // 旧锚点里混着等待时间、复原不出来 —— 一笔勾销是有意的:真正兜住花钱的是调用次数与 tokens | ||
| 84 | + seedRun('r_legacy', { agent_calls: 7, started_at: '2026-08-18T15:48:17.630Z' }, 'running'); | ||
| 85 | + const s = engine.settleWallclock('r_legacy', 'running'); | ||
| 86 | + expect(s.active_ms).toBe(0); | ||
| 87 | + expect(Date.parse(s.started_at)).toBeGreaterThan(Date.parse('2026-08-19T00:00:00Z')); | ||
| 88 | + expect(engine.checkBudget(wdb.getRun('r_legacy')).ok).toBe(true); | ||
| 89 | + }); | ||
| 90 | + | ||
| 91 | + it('子 run 不动预算:结算只写 root 那一行', () => { | ||
| 92 | + seedRun('r_root', { active_ms: 0, started_at: null }, 'running'); | ||
| 93 | + const db = wdb.getDb(); | ||
| 94 | + db.prepare(`INSERT INTO graph_runs | ||
| 95 | + (id, graph_kind, graph_id, company_id, root_run_id, parent_run_id, execution_mode, run_status, budget_json, created_at, updated_at) | ||
| 96 | + VALUES ('r_child','work','wg1','co1','r_root','r_root','manual','running', NULL, ?, ?)`) | ||
| 97 | + .run('2026-08-18T15:49:00.000Z', '2026-08-18T15:49:00.000Z'); | ||
| 98 | + expect(engine.settleWallclock('r_child', 'blocked')).toBeNull(); | ||
| 99 | + const root = JSON.parse(wdb.getRun('r_root').budget_json); | ||
| 100 | + expect(root.spent.started_at).toBeNull(); // 没被子 run 的转换改动 | ||
| 101 | + }); | ||
| 102 | + | ||
| 103 | + it('挂在漏斗上:走 sse.transitionRun 就会结算,不需要调用方记得调', () => { | ||
| 104 | + // 这一条守的是钩子的**位置**:进 running 既不是收敛、也不空坑, | ||
| 105 | + // 放在 afterRunTransition 那个 early return 后面就变成「只结算停下来、从不开始计时」—— | ||
| 106 | + // 症状是 active_ms 永远 0,墙钟形同虚设,而且照样全绿。 | ||
| 107 | + const sse = require('../../services/graph-run-sse'); | ||
| 108 | + seedRun('r_funnel', { active_ms: 0, started_at: null }, 'ready'); | ||
| 109 | + | ||
| 110 | + expect(sse.transitionRun('r_funnel', 'ready', 'running')).toBeTruthy(); | ||
| 111 | + expect(JSON.parse(wdb.getRun('r_funnel').budget_json).spent.started_at).toBeTruthy(); | ||
| 112 | + | ||
| 113 | + expect(sse.transitionRun('r_funnel', 'running', 'blocked')).toBeTruthy(); | ||
| 114 | + const spent = JSON.parse(wdb.getRun('r_funnel').budget_json).spent; | ||
| 115 | + expect(spent.started_at).toBeNull(); | ||
| 116 | + expect(spent.active_ms).toBeGreaterThanOrEqual(0); | ||
| 117 | + }); | ||
| 118 | + | ||
| 119 | + it('没配 wallclock_ms 的 run:结算是 no-op,不许凭空造出 active_ms 字段', () => { | ||
| 120 | + const db = wdb.getDb(); | ||
| 121 | + db.prepare(`INSERT INTO graph_runs | ||
| 122 | + (id, graph_kind, graph_id, company_id, root_run_id, execution_mode, run_status, budget_json, created_at, updated_at) | ||
| 123 | + VALUES ('r_nowc','org','g1','co1','r_nowc','manual','running', ?, ?, ?)`) | ||
| 124 | + .run(JSON.stringify({ total_agent_calls: 10, spent: { agent_calls: 1 } }), '2026-08-18T15:48:00.000Z', '2026-08-18T15:48:00.000Z'); | ||
| 125 | + expect(engine.settleWallclock('r_nowc', 'blocked')).toBeNull(); | ||
| 126 | + expect(JSON.parse(wdb.getRun('r_nowc').budget_json).spent).toEqual({ agent_calls: 1 }); | ||
| 127 | + }); | ||
| 128 | +}); |
server/tests/unit/goal-task-acceptance.test.js
| @@ -1251,6 +1251,17 @@ describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { | @@ -1251,6 +1251,17 @@ describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { | ||
| 1251 | // 缺陷 F3 / F6 · dispatch 那条路上被丢掉和被违反的两个约定 | 1251 | // 缺陷 F3 / F6 · dispatch 那条路上被丢掉和被违反的两个约定 |
| 1252 | // ────────────────────────────────────────────────────────────── | 1252 | // ────────────────────────────────────────────────────────────── |
| 1253 | 1253 | ||
| 1254 | + /** | ||
| 1255 | + * 只比「申报的额度」,剥掉 spent。 | ||
| 1256 | + * | ||
| 1257 | + * spent 是**运行期账本**:run 一进 running,墙钟就往里写 active_ms / started_at | ||
| 1258 | + * (见 budget-wallclock-active.test.js)。整个对象 deepEqual 会让这条用例在账本 | ||
| 1259 | + * 新增字段时红,而它守的是「用户填的额度没被静默换成默认值」—— 跟账本长什么样无关。 | ||
| 1260 | + * 账本本身另有断言:起跑那一刻还没花钱,agent_calls / tokens 必须仍是 0。 | ||
| 1261 | + */ | ||
| 1262 | + const limitsOf = (o) => { const { spent, ...limits } = typeof o === 'string' ? JSON.parse(o) : o; return limits; }; | ||
| 1263 | + const spentOf = (o) => (typeof o === 'string' ? JSON.parse(o) : o).spent || {}; | ||
| 1264 | + | ||
| 1254 | it('草稿上申报的 budget 能活到 dispatch,run 拿到的不是默认额度(缺陷 F3)', async () => { | 1265 | it('草稿上申报的 budget 能活到 dispatch,run 拿到的不是默认额度(缺陷 F3)', async () => { |
| 1255 | const cid = newCompany(); | 1266 | const cid = newCompany(); |
| 1256 | const budget = { total_agent_calls: 3, total_tokens: 5000000, wallclock_ms: 60000, spent: { agent_calls: 0, tokens: 0 } }; | 1267 | const budget = { total_agent_calls: 3, total_tokens: 5000000, wallclock_ms: 60000, spent: { agent_calls: 0, tokens: 0 } }; |
| @@ -1265,7 +1276,9 @@ describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { | @@ -1265,7 +1276,9 @@ describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { | ||
| 1265 | expect(s2).toBe(201); | 1276 | expect(s2).toBe(201); |
| 1266 | // 修之前这里是 DEFAULT_TASK_BUDGET(100 次调用):用户填的 3 次被静默丢弃, | 1277 | // 修之前这里是 DEFAULT_TASK_BUDGET(100 次调用):用户填的 3 次被静默丢弃, |
| 1267 | // 他只会看到任务跑到一半 blocked,而没有任何地方说过预算被换掉了 | 1278 | // 他只会看到任务跑到一半 blocked,而没有任何地方说过预算被换掉了 |
| 1268 | - expect(JSON.parse(wdb.getRun(dispatched.started.runId).budget_json)).toEqual(budget); | 1279 | + const dispatchedBudget = wdb.getRun(dispatched.started.runId).budget_json; |
| 1280 | + expect(limitsOf(dispatchedBudget)).toEqual(limitsOf(budget)); | ||
| 1281 | + expect(spentOf(dispatchedBudget)).toMatchObject({ agent_calls: 0, tokens: 0 }); // 起跑那一刻还没花钱 | ||
| 1269 | 1282 | ||
| 1270 | // 本次 dispatch 的 body 仍然覆盖草稿上的申报值(优先级:body > 草稿 > 默认) | 1283 | // 本次 dispatch 的 body 仍然覆盖草稿上的申报值(优先级:body > 草稿 > 默认) |
| 1271 | const cid2 = newCompany(); | 1284 | const cid2 = newCompany(); |
| @@ -1273,7 +1286,7 @@ describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { | @@ -1273,7 +1286,7 @@ describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { | ||
| 1273 | const override = { total_agent_calls: 9, spent: { agent_calls: 0, tokens: 0 } }; | 1286 | const override = { total_agent_calls: 9, spent: { agent_calls: 0, tokens: 0 } }; |
| 1274 | const [s3, d2] = await call('POST', `/api/company/${cid2}/goals/${goal2.goalId}/dispatch`, { budget: override }); | 1287 | const [s3, d2] = await call('POST', `/api/company/${cid2}/goals/${goal2.goalId}/dispatch`, { budget: override }); |
| 1275 | expect(s3).toBe(201); | 1288 | expect(s3).toBe(201); |
| 1276 | - expect(JSON.parse(wdb.getRun(d2.started.runId).budget_json)).toEqual(override); | 1289 | + expect(limitsOf(wdb.getRun(d2.started.runId).budget_json)).toEqual(limitsOf(override)); |
| 1277 | }); | 1290 | }); |
| 1278 | 1291 | ||
| 1279 | it('坑被占着时 POST /tasks 不再 409 而是入队,申报的 budget 一路带到出队(批 3 · 缺陷 F3 的现场)', async () => { | 1292 | it('坑被占着时 POST /tasks 不再 409 而是入队,申报的 budget 一路带到出队(批 3 · 缺陷 F3 的现场)', async () => { |
| @@ -1311,7 +1324,8 @@ describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { | @@ -1311,7 +1324,8 @@ describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { | ||
| 1311 | expect(ok).toBe(201); | 1324 | expect(ok).toBe(201); |
| 1312 | expect(started.started.runId).toBe(body.runId); // 不是新起一条,是把排着的那条拉起来 | 1325 | expect(started.started.runId).toBe(body.runId); // 不是新起一条,是把排着的那条拉起来 |
| 1313 | const runBudget = JSON.parse(wdb.getRun(body.runId).budget_json); | 1326 | const runBudget = JSON.parse(wdb.getRun(body.runId).budget_json); |
| 1314 | - expect(runBudget).toEqual(budget); | 1327 | + expect(limitsOf(runBudget)).toEqual(limitsOf(budget)); |
| 1328 | + expect(spentOf(runBudget)).toMatchObject({ agent_calls: 0, tokens: 0 }); | ||
| 1315 | expect(runBudget.total_agent_calls).not.toBe(100); // DEFAULT_TASK_BUDGET 的值,钉住「不是默认额度」 | 1329 | expect(runBudget.total_agent_calls).not.toBe(100); // DEFAULT_TASK_BUDGET 的值,钉住「不是默认额度」 |
| 1316 | }); | 1330 | }); |
| 1317 | 1331 |