Commit 9891c3b71e3c6ebe87e20bbd546262e0fc6f57a7

Authored by yibo
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>
server/services/graph-engine.js
@@ -2281,18 +2281,33 @@ function wallclockElapsedMs(spent = {}) { @@ -2281,18 +2281,33 @@ function wallclockElapsedMs(spent = {}) {
2281 */ 2281 */
2282 function settleWallclock(runId, to) { 2282 function settleWallclock(runId, to) {
2283 const run = getRun(runId); 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 const held = readBudget(run); 2290 const held = readBudget(run);
2286 if (!held || held.budget.wallclock_ms == null) return null; 2291 if (!held || held.budget.wallclock_ms == null) return null;
2287 2292
2288 const b = held.budget; 2293 const b = held.budget;
2289 if (!b.spent) b.spent = {}; 2294 if (!b.spent) b.spent = {};
2290 const s = b.spent; 2295 const s = b.spent;
  2296 + const legacy = s.active_ms == null; // 存量行:旧锚点混着等待时间,复原不出来
2291 2297
2292 if (to === 'running') { 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 } else { 2307 } else {
  2308 + // 这一层停了不等于树停了 —— 还有兄弟 / 父 run 在跑就不结算,否则计时会被提前掐断。
  2309 + // 转换此刻已落库,所以直接查库就能拿到最新状态,不用把 runId 排除在外。
  2310 + if (treeHasRunningRun(held.rootRunId)) return null;
2296 if (s.started_at) { 2311 if (s.started_at) {
2297 s.active_ms = (s.active_ms || 0) + Math.max(0, Date.now() - Date.parse(s.started_at)); 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,6 +2320,13 @@ function settleWallclock(runId, to) {
2305 return s; 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 * 余额检查。任何一层发起 agent 调用前都要过这一关(advanceOne 拾取节点之后立刻查)。 2331 * 余额检查。任何一层发起 agent 调用前都要过这一关(advanceOne 拾取节点之后立刻查)。
2310 * @returns {{ok: boolean, exhausted: string[], rootRunId?: string, budget?: object}} 2332 * @returns {{ok: boolean, exhausted: string[], rootRunId?: string, budget?: object}}
server/services/work-graph-db.js
@@ -623,10 +623,48 @@ function getDb() { @@ -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 schemaEnsured = true; 641 schemaEnsured = true;
627 return db; 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 // §四.A.4 状态机(模式照抄 devloop/unit-registry.js:显式转换表 + 非法转换抛错) 669 // §四.A.4 状态机(模式照抄 devloop/unit-registry.js:显式转换表 + 非法转换抛错)
632 // ──────────────────────────────────────────────────────────────── 670 // ────────────────────────────────────────────────────────────────
server/tests/unit/budget-wallclock-active.test.js
@@ -73,6 +73,9 @@ describe(&#39;预算 · 墙钟只算活跃时长&#39;, () =&gt; { @@ -73,6 +73,9 @@ describe(&#39;预算 · 墙钟只算活跃时长&#39;, () =&gt; {
73 expect(started.started_at).toBeTruthy(); 73 expect(started.started_at).toBeTruthy();
74 expect(started.active_ms).toBe(5 * 60_000); 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 const stopped = engine.settleWallclock('r_settle', 'blocked'); 79 const stopped = engine.settleWallclock('r_settle', 'blocked');
77 expect(stopped.started_at).toBeNull(); 80 expect(stopped.started_at).toBeNull();
78 expect(stopped.active_ms).toBeGreaterThanOrEqual(5 * 60_000); // 只增不减 81 expect(stopped.active_ms).toBeGreaterThanOrEqual(5 * 60_000); // 只增不减
@@ -88,16 +91,56 @@ describe(&#39;预算 · 墙钟只算活跃时长&#39;, () =&gt; { @@ -88,16 +91,56 @@ describe(&#39;预算 · 墙钟只算活跃时长&#39;, () =&gt; {
88 expect(engine.checkBudget(wdb.getRun('r_legacy')).ok).toBe(true); 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 (id, graph_kind, graph_id, company_id, root_run_id, parent_run_id, execution_mode, run_status, budget_json, created_at, updated_at) 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 it('挂在漏斗上:走 sse.transitionRun 就会结算,不需要调用方记得调', () => { 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 +});