Commit 9891c3b71e3c6ebe87e20bbd546262e0fc6f57a7
1 parent
6a83257e
fix(budget): 墙钟要跟着整棵 run 树,不只是 root —— 真跑撞出来的
## 上一版漏了什么 预算行挂在 root run,我就只在 root 的转换上结算。而**活动发生在树里任何一层**: 人工重试一个 work 层节点动的是子 run(blocked → running),root 全程 blocked。 后果:子 run 跑整场、墙钟一次都没开始计,旧锚点还留着 —— 第一次 checkBudget 立刻判耗尽。单测当时全绿,因为它们只喂了 root。 **是真起服务、真点重试才撞出来的**:那条卡住的 run 重试后子 run 起来了, root 的 started_at 仍是 08-18。 ## 改 · 任何一层进 running 都开始计时(预算行仍写 root,readBudget 自动上溯)。 · 离开 running 时先查树里还有没有 running 的 run —— 有就不结算, 否则一层停了会把整段计时提前掐断。 · 树里已经有 run 在跑时,另一条进 running 不重锚,否则前一段被抹掉。 ## 存量重锚必须在开库时做 钩子只在「进 / 出 running」时触发,而升级那一刻正停在 running 的 run 不会再进一次 running —— 旧锚点一直挂着,下一次 checkBudget 直接判耗尽。 ⚠️ 第一版把这段迁移写进了 graph-db.js 的 migrate(),而 graph_runs 是 work-graph-db 建的表、graph-db 的迁移块跑在它之前 —— 新库上直接 `no such table: graph_runs`,getDb() 起不来。已挪到建这张表的模块里。 和 §16.2 那次「加索引写进建表块」是同一类顺序问题,这是第二次。 守卫 budget-wallclock-migration.test.js 走的是**存量库重启**那条路 (照抄 tasks-executor-migration 的 freshDb 写法):老锚点归零、running 的续上、 其余断开、没配 wallclock_ms 的不许被塞字段、坏 json 不许把开库卡死、幂等。 ## 真跑验证 重启服务 → 迁移在真库上跑到(run_9168bd24f46c 的 started_at 归 null、active_ms=0) → 重试 org-planner-sdd → POST /next → **节点 success,attempt 2**, 预算 8/100 次调用、78,858/5,000,000 tokens,org-planner-benchmark 已转 ready。 这条 run 从 2026-08-18 16:19 卡到现在,是 27-p3c-batch3-handover §五 「还没解决的原始问题」。 全量:138 文件 / 1289 测试全绿。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
4 changed files
with
203 additions
and
12 deletions
server/services/graph-engine.js
| ... | ... | @@ -2281,18 +2281,33 @@ function wallclockElapsedMs(spent = {}) { |
| 2281 | 2281 | */ |
| 2282 | 2282 | function settleWallclock(runId, to) { |
| 2283 | 2283 | const run = getRun(runId); |
| 2284 | - if (!run || run.parent_run_id) return null; // 预算只挂在 root run 那一行 | |
| 2284 | + if (!run) return null; | |
| 2285 | + // 预算行永远在 root(readBudget 自动上溯),但**活动发生在树里任何一层**: | |
| 2286 | + // 人工重试一个 work 层节点时,动的是子 run(blocked → running),root 一直是 blocked。 | |
| 2287 | + // 只认 root 的转换 = 子 run 跑整场、墙钟一次都没开始计,而且旧锚点还留着 —— | |
| 2288 | + // 实测就是这么漏的:那条卡了两天的 run 重试后子 run 起来了,root 的 started_at | |
| 2289 | + // 仍是 08-18,第一次 checkBudget 立刻判耗尽。 | |
| 2285 | 2290 | const held = readBudget(run); |
| 2286 | 2291 | if (!held || held.budget.wallclock_ms == null) return null; |
| 2287 | 2292 | |
| 2288 | 2293 | const b = held.budget; |
| 2289 | 2294 | if (!b.spent) b.spent = {}; |
| 2290 | 2295 | const s = b.spent; |
| 2296 | + const legacy = s.active_ms == null; // 存量行:旧锚点混着等待时间,复原不出来 | |
| 2291 | 2297 | |
| 2292 | 2298 | if (to === 'running') { |
| 2293 | - s.active_ms = s.active_ms || 0; | |
| 2294 | - s.started_at = now(); // 无条件重锚:进 running 就是新一段的开始 | |
| 2299 | + if (legacy) { | |
| 2300 | + s.active_ms = 0; | |
| 2301 | + s.started_at = now(); // 旧锚点一笔勾销,从这次起重新计 | |
| 2302 | + } else if (!s.started_at) { | |
| 2303 | + s.started_at = now(); | |
| 2304 | + } else { | |
| 2305 | + return null; // 树里已经有别的 run 在跑,这一段的计时早开着了,别重锚 | |
| 2306 | + } | |
| 2295 | 2307 | } else { |
| 2308 | + // 这一层停了不等于树停了 —— 还有兄弟 / 父 run 在跑就不结算,否则计时会被提前掐断。 | |
| 2309 | + // 转换此刻已落库,所以直接查库就能拿到最新状态,不用把 runId 排除在外。 | |
| 2310 | + if (treeHasRunningRun(held.rootRunId)) return null; | |
| 2296 | 2311 | if (s.started_at) { |
| 2297 | 2312 | s.active_ms = (s.active_ms || 0) + Math.max(0, Date.now() - Date.parse(s.started_at)); |
| 2298 | 2313 | } |
| ... | ... | @@ -2305,6 +2320,13 @@ function settleWallclock(runId, to) { |
| 2305 | 2320 | return s; |
| 2306 | 2321 | } |
| 2307 | 2322 | |
| 2323 | +/** 这棵 run 树里还有没有 running 的 run(墙钟该不该继续走) */ | |
| 2324 | +function treeHasRunningRun(rootRunId) { | |
| 2325 | + return !!getDb() | |
| 2326 | + .prepare(`SELECT 1 FROM graph_runs WHERE root_run_id = ? AND run_status = 'running' LIMIT 1`) | |
| 2327 | + .get(rootRunId); | |
| 2328 | +} | |
| 2329 | + | |
| 2308 | 2330 | /** |
| 2309 | 2331 | * 余额检查。任何一层发起 agent 调用前都要过这一关(advanceOne 拾取节点之后立刻查)。 |
| 2310 | 2332 | * @returns {{ok: boolean, exhausted: string[], rootRunId?: string, budget?: object}} | ... | ... |
server/services/work-graph-db.js
| ... | ... | @@ -623,10 +623,48 @@ function getDb() { |
| 623 | 623 | `); |
| 624 | 624 | } |
| 625 | 625 | |
| 626 | + // 墙钟预算改成「只算活跃时长」之后的存量重锚(graph-engine.wallclockElapsedMs)。 | |
| 627 | + // | |
| 628 | + // 老形态:spent.started_at = 第一次花钱的绝对时刻,没有 active_ms。那个锚点里混着 | |
| 629 | + // **挂起等人**的时间,真实活跃时长复原不出来 —— 按 0 起算、一笔勾销。另外两维 | |
| 630 | + // (agent_calls / tokens)是精确的且没受影响,真正兜住花钱的是那两维。 | |
| 631 | + // | |
| 632 | + // **为什么必须在开库时做,而不是交给转换钩子**:钩子只在「进 / 出 running」时触发, | |
| 633 | + // 而升级那一刻正停在 running 的 run 不会再进一次 running —— 旧锚点一直挂着, | |
| 634 | + // 下一次 checkBudget 直接判耗尽。 | |
| 635 | + // | |
| 636 | + // 放在**这里**而不是 graph-db.js 的 migrate():graph_runs 是本文件建的表, | |
| 637 | + // graph-db 的迁移块跑在它之前,写在那边会在新库上直接 `no such table: graph_runs` | |
| 638 | + // (踩过了 —— 和 §16.2 那次「加索引写进建表块」是同一类顺序问题)。 | |
| 639 | + backfillWallclockAnchor(db); | |
| 640 | + | |
| 626 | 641 | schemaEnsured = true; |
| 627 | 642 | return db; |
| 628 | 643 | } |
| 629 | 644 | |
| 645 | +/** | |
| 646 | + * 存量墙钟锚点重置:active_ms 补 0;running 的重新锚到现在,其余断开锚点。 | |
| 647 | + * 只碰配了 wallclock_ms 的行 —— 没配的本来就不限墙钟,凭空加字段只会让人以为限了。 | |
| 648 | + * 判据是 active_ms 缺席,所以第二遍是零行更新(幂等)。 | |
| 649 | + */ | |
| 650 | +function backfillWallclockAnchor(db) { | |
| 651 | + const rows = db | |
| 652 | + .prepare(`SELECT id, run_status, budget_json FROM graph_runs WHERE budget_json IS NOT NULL`) | |
| 653 | + .all(); | |
| 654 | + const upd = db.prepare(`UPDATE graph_runs SET budget_json = ? WHERE id = ?`); | |
| 655 | + const nowIso = new Date().toISOString(); | |
| 656 | + for (const r of rows) { | |
| 657 | + let b; | |
| 658 | + try { b = JSON.parse(r.budget_json); } catch { continue; } // 坏字段不该把开库卡死 | |
| 659 | + if (!b || b.wallclock_ms == null) continue; | |
| 660 | + if (!b.spent) b.spent = {}; | |
| 661 | + if (b.spent.active_ms != null) continue; // 已经是新形态 | |
| 662 | + b.spent.active_ms = 0; | |
| 663 | + b.spent.started_at = r.run_status === 'running' ? nowIso : null; | |
| 664 | + upd.run(JSON.stringify(b), r.id); | |
| 665 | + } | |
| 666 | +} | |
| 667 | + | |
| 630 | 668 | // ──────────────────────────────────────────────────────────────── |
| 631 | 669 | // §四.A.4 状态机(模式照抄 devloop/unit-registry.js:显式转换表 + 非法转换抛错) |
| 632 | 670 | // ──────────────────────────────────────────────────────────────── | ... | ... |
server/tests/unit/budget-wallclock-active.test.js
| ... | ... | @@ -73,6 +73,9 @@ describe('预算 · 墙钟只算活跃时长', () => { |
| 73 | 73 | expect(started.started_at).toBeTruthy(); |
| 74 | 74 | expect(started.active_ms).toBe(5 * 60_000); |
| 75 | 75 | |
| 76 | + // 生产里转换先落库、钩子后跑,所以这里也得先把状态改掉 —— 不改的话 | |
| 77 | + // treeHasRunningRun 仍看到 running,结算被正确地跳过(那是另一条用例在验的行为) | |
| 78 | + wdb.getDb().prepare(`UPDATE graph_runs SET run_status='blocked' WHERE id='r_settle'`).run(); | |
| 76 | 79 | const stopped = engine.settleWallclock('r_settle', 'blocked'); |
| 77 | 80 | expect(stopped.started_at).toBeNull(); |
| 78 | 81 | expect(stopped.active_ms).toBeGreaterThanOrEqual(5 * 60_000); // 只增不减 |
| ... | ... | @@ -88,16 +91,56 @@ describe('预算 · 墙钟只算活跃时长', () => { |
| 88 | 91 | expect(engine.checkBudget(wdb.getRun('r_legacy')).ok).toBe(true); |
| 89 | 92 | }); |
| 90 | 93 | |
| 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 | |
| 94 | + /** | |
| 95 | + * 这一组是**真跑撞出来的**:第一版只认 root run 的转换。 | |
| 96 | + * 而人工重试一个 work 层节点动的是**子 run**(blocked → running),root 全程 blocked —— | |
| 97 | + * 于是子 run 跑整场、墙钟一次都没开始计,旧锚点还留着,第一次 checkBudget 立刻判耗尽。 | |
| 98 | + * 单测当时全绿:它们只喂了 root。 | |
| 99 | + */ | |
| 100 | + const seedChild = (id, rootId, status) => { | |
| 101 | + wdb.getDb().prepare(`INSERT INTO graph_runs | |
| 95 | 102 | (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 的转换改动 | |
| 103 | + VALUES (?,'work','wg1','co1',?,?,'manual',?, NULL, ?, ?)`) | |
| 104 | + .run(id, rootId, rootId, status, '2026-08-18T15:49:00.000Z', '2026-08-18T15:49:00.000Z'); | |
| 105 | + }; | |
| 106 | + | |
| 107 | + it('子 run 进 running 也开始计时:预算行仍写 root,但触发的是子 run 的转换', () => { | |
| 108 | + seedRun('r_root', { active_ms: 0, started_at: null }, 'blocked'); // root 一直 blocked | |
| 109 | + seedChild('r_child', 'r_root', 'running'); | |
| 110 | + const s = engine.settleWallclock('r_child', 'running'); | |
| 111 | + expect(s).not.toBeNull(); | |
| 112 | + expect(s.started_at).toBeTruthy(); | |
| 113 | + expect(JSON.parse(wdb.getRun('r_root').budget_json).spent.started_at).toBeTruthy(); | |
| 114 | + }); | |
| 115 | + | |
| 116 | + it('存量行由子 run 的转换重锚 —— 那条卡了两天的 run 就是这个形态', () => { | |
| 117 | + seedRun('r_root2', { agent_calls: 7, tokens: 69961, started_at: '2026-08-18T15:48:17.630Z' }, 'blocked'); | |
| 118 | + seedChild('r_child2', 'r_root2', 'running'); | |
| 119 | + engine.settleWallclock('r_child2', 'running'); | |
| 120 | + const spent = JSON.parse(wdb.getRun('r_root2').budget_json).spent; | |
| 121 | + expect(spent.active_ms).toBe(0); | |
| 122 | + expect(Date.parse(spent.started_at)).toBeGreaterThan(Date.parse('2026-08-19T00:00:00Z')); | |
| 123 | + expect(engine.checkBudget(wdb.getRun('r_child2')).ok).toBe(true); // 子 run 查的也是 root 的池子 | |
| 124 | + }); | |
| 125 | + | |
| 126 | + it('一层停了不结算 —— 树里还有别的 run 在跑,计时不能被提前掐断', () => { | |
| 127 | + seedRun('r_root3', { active_ms: 60_000, started_at: '2026-08-20T00:00:00.000Z' }, 'running'); | |
| 128 | + seedChild('r_child3', 'r_root3', 'blocked'); | |
| 129 | + expect(engine.settleWallclock('r_child3', 'blocked')).toBeNull(); | |
| 130 | + expect(JSON.parse(wdb.getRun('r_root3').budget_json).spent.started_at).toBe('2026-08-20T00:00:00.000Z'); | |
| 131 | + | |
| 132 | + // root 也停下来,树里再没有 running → 这才结算 | |
| 133 | + wdb.getDb().prepare(`UPDATE graph_runs SET run_status='blocked' WHERE id='r_root3'`).run(); | |
| 134 | + const s = engine.settleWallclock('r_root3', 'blocked'); | |
| 135 | + expect(s.started_at).toBeNull(); | |
| 136 | + expect(s.active_ms).toBeGreaterThan(60_000); | |
| 137 | + }); | |
| 138 | + | |
| 139 | + it('树里已经有 run 在跑时,另一条进 running 不重锚(否则前一段被抹掉)', () => { | |
| 140 | + seedRun('r_root4', { active_ms: 0, started_at: '2026-08-20T00:00:00.000Z' }, 'running'); | |
| 141 | + seedChild('r_child4', 'r_root4', 'running'); | |
| 142 | + expect(engine.settleWallclock('r_child4', 'running')).toBeNull(); | |
| 143 | + expect(JSON.parse(wdb.getRun('r_root4').budget_json).spent.started_at).toBe('2026-08-20T00:00:00.000Z'); | |
| 101 | 144 | }); |
| 102 | 145 | |
| 103 | 146 | it('挂在漏斗上:走 sse.transitionRun 就会结算,不需要调用方记得调', () => { | ... | ... |
server/tests/unit/budget-wallclock-migration.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * 墙钟改成「只算活跃时长」之后的**存量库迁移路径**。 | |
| 3 | + * | |
| 4 | + * 单独钉这一条的理由和 tasks-executor-migration 一样:新建库那条路由单测天然覆盖, | |
| 5 | + * 「存量数据 + 重启」这条必须另外安排人走一遍。这里要守的是两点: | |
| 6 | + * | |
| 7 | + * ① **必须在开库时重锚**,不能只靠转换钩子。钩子只在「进 / 出 running」时触发, | |
| 8 | + * 而升级那一刻正停在 running 的 run 不会再进一次 running —— 旧锚点一直挂着, | |
| 9 | + * 下一次 checkBudget 直接判耗尽(= 那条 run 永远醒不过来)。 | |
| 10 | + * ② **迁移代码必须放在建 graph_runs 的那个模块里**。第一版写进了 graph-db.js 的 | |
| 11 | + * migrate(),而 graph_runs 是 work-graph-db 建的表、迁移块跑在它之前 —— | |
| 12 | + * 新库上直接 `no such table: graph_runs`,getDb() 起不来。 | |
| 13 | + */ | |
| 14 | +import { describe, it, expect } from 'vitest'; | |
| 15 | +import { createRequire } from 'module'; | |
| 16 | +import fs from 'fs'; | |
| 17 | +import os from 'os'; | |
| 18 | +import path from 'path'; | |
| 19 | +const require_ = createRequire(import.meta.url); | |
| 20 | + | |
| 21 | +/** 用独立 DB 文件跑一遍 getDb()(写法照抄 tasks-executor-migration.test.js) */ | |
| 22 | +function freshDb(dbPath) { | |
| 23 | + process.env.GRAPH_DB_PATH = dbPath; | |
| 24 | + for (const m of ['../../services/work-graph-db', '../../services/graph-db']) { | |
| 25 | + delete require_.cache[require_.resolve(m)]; | |
| 26 | + } | |
| 27 | + const wdb = require_('../../services/work-graph-db'); | |
| 28 | + wdb.getDb(); | |
| 29 | + return wdb; | |
| 30 | +} | |
| 31 | + | |
| 32 | +const OLD_ANCHOR = '2026-08-18T15:48:17.630Z'; | |
| 33 | +const legacyBudget = () => JSON.stringify({ | |
| 34 | + total_agent_calls: 100, total_tokens: 5000000, wallclock_ms: 7200000, | |
| 35 | + spent: { agent_calls: 7, tokens: 69961, started_at: OLD_ANCHOR }, // 没有 active_ms = 老形态 | |
| 36 | +}); | |
| 37 | + | |
| 38 | +function seed(db, id, status, budgetJson) { | |
| 39 | + db.prepare(`INSERT INTO graph_runs | |
| 40 | + (id, graph_kind, graph_id, company_id, root_run_id, execution_mode, run_status, budget_json, created_at, updated_at) | |
| 41 | + VALUES (?, 'org', 'g1', 'co1', ?, 'manual', ?, ?, ?, ?)`) | |
| 42 | + .run(id, id, status, budgetJson, OLD_ANCHOR, OLD_ANCHOR); | |
| 43 | +} | |
| 44 | + | |
| 45 | +describe('墙钟锚点的存量迁移', () => { | |
| 46 | + it('存量库重启:老锚点归零重锚,running 的续上、其余断开,且不炸', () => { | |
| 47 | + const dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'wc-mig-')), 'graph.sqlite'); | |
| 48 | + const wdb = freshDb(dbPath); | |
| 49 | + const db = wdb.getDb(); | |
| 50 | + | |
| 51 | + seed(db, 'r_running', 'running', legacyBudget()); | |
| 52 | + seed(db, 'r_blocked', 'blocked', legacyBudget()); | |
| 53 | + seed(db, 'r_nowc', 'running', JSON.stringify({ total_agent_calls: 10, spent: { agent_calls: 1 } })); | |
| 54 | + seed(db, 'r_broken', 'running', '{ 这不是 json'); | |
| 55 | + db.close(); | |
| 56 | + | |
| 57 | + // 重开进程 = 服务重启 | |
| 58 | + const wdb2 = freshDb(dbPath); | |
| 59 | + const get = (id) => wdb2.getRun(id).budget_json; | |
| 60 | + | |
| 61 | + const running = JSON.parse(get('r_running')).spent; | |
| 62 | + expect(running.active_ms).toBe(0); // 旧锚点一笔勾销 | |
| 63 | + expect(Date.parse(running.started_at)).toBeGreaterThan(Date.parse('2026-08-19T00:00:00Z')); | |
| 64 | + expect(running.agent_calls).toBe(7); // 精确的那两维一个字不动 | |
| 65 | + expect(running.tokens).toBe(69961); | |
| 66 | + | |
| 67 | + const blocked = JSON.parse(get('r_blocked')).spent; | |
| 68 | + expect(blocked.active_ms).toBe(0); | |
| 69 | + expect(blocked.started_at).toBeNull(); // 没在跑就不该有锚点 | |
| 70 | + | |
| 71 | + // 没配 wallclock_ms 的行不许被塞字段 —— 塞了会让人以为它限了墙钟 | |
| 72 | + expect(JSON.parse(get('r_nowc')).spent).toEqual({ agent_calls: 1 }); | |
| 73 | + // 坏字段跳过即可,不能把开库卡死(上面 getDb() 没抛就是证据) | |
| 74 | + expect(get('r_broken')).toBe('{ 这不是 json'); | |
| 75 | + }); | |
| 76 | + | |
| 77 | + it('幂等:再重启一次,已经重锚过的行一个字不动', () => { | |
| 78 | + const dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'wc-mig2-')), 'graph.sqlite'); | |
| 79 | + const wdb = freshDb(dbPath); | |
| 80 | + seed(wdb.getDb(), 'r_x', 'blocked', legacyBudget()); | |
| 81 | + wdb.getDb().close(); | |
| 82 | + | |
| 83 | + const first = freshDb(dbPath).getRun('r_x').budget_json; | |
| 84 | + const second = freshDb(dbPath).getRun('r_x').budget_json; | |
| 85 | + expect(second).toBe(first); | |
| 86 | + expect(JSON.parse(first).spent.active_ms).toBe(0); | |
| 87 | + }); | |
| 88 | +}); | ... | ... |