Commit e95e77f0a3a0749984098876cec121610d00d2f6

Authored by yibo
1 parent 8a6dbc8c

feat(graph-engine): T5 欠费门可插拔桩子 + 自动恢复(27-P4)

先搭桩子,不接真实计费接口(用户明确选择:真实欠费判据依赖计费系统那边确认,
现在只把可插拔的挂载点和恢复机制先落地,接口确认后再换真实实现)。

新增:
- setBillingGate(fn):与 setAgentExecutor/setPlanWorkGraph 同款注入模式,未注入
  时 checkBilling 恒返回「未欠费」,对现有行为零影响。
- checkBilling 挂进 advanceOne 与 advanceBatch,紧跟 checkBudget 之后——两者是
  不同维度的门(任务级预算池 vs 账户级欠费),不合并成一个判据。
- 欠费挂起复用现成的 suspendRunTree(预算耗尽走的同一条路),只是 reason 换成
  'billing-arrears'。suspendRunTree 加 reason/summary 参数,并把 reason 写进新增的
  graph_runs.block_reason 列(欠费清零后的自动恢复扫描要按它过滤,不用解析 timeline
  文本);startRunSync「启动」时清空这一列。
- resumeBillingClearedRuns():欠费清零后的自动恢复触发器——今天从 blocked 恢复靠
  人手点「启动」,欠费清零不会有人去点,这个函数补上「扫一遍 block_reason=
  billing-arrears 的 blocked root run,欠费真清了就代它点启动」。调度触发源
  (webhook/轮询)留给计费接口接通之后再定。

顺手修了一个 advanceBatch 的排序 bug:budget/billing 门必须在「确认真有 ready
候选」之后才查,跟 advanceOne 的既有顺序一致——反过来会把一个已经收敛、只是
恰好预算也花完了的 run 误判成「挂起」而不是正确收敛到 completed。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
server/services/graph-engine.js
... ... @@ -1370,6 +1370,14 @@ async function advanceOne(runId, opts = {}) {
1370 1370 return { ...result(runId, (getRun(runId) || {}).run_status, 'budget-exhausted'), budget: gate };
1371 1371 }
1372 1372  
  1373 + // 27-P4 T5:账户欠费门,与 advanceBatch 共用同一个 checkBilling/suspendRunTree
  1374 + // (advanceBatch 的 maxConcurrency<=1 分支整个委托给这个函数,这道门只写一份)。
  1375 + const billing = await checkBilling(run);
  1376 + if (!billing.ok) {
  1377 + suspendRunTree(run, { reason: 'billing-arrears' });
  1378 + return { ...result(runId, (getRun(runId) || {}).run_status, 'billing-arrears'), billing };
  1379 + }
  1380 +
1373 1381 const def = defIndex(run);
1374 1382 const defNode = def.nodes.get(picked.def_node_id) || {};
1375 1383 const nodeName = defNode.name || picked.def_node_id;
... ... @@ -1585,15 +1593,11 @@ async function advanceBatch(runId, opts = {}) {
1585 1593 return { ok: false, runId, runStatus: run.run_status, reason: 'run-not-running', results: [], activated: [] };
1586 1594 }
1587 1595  
1588   - const gate = checkBudget(run);
1589   - if (!gate.ok) {
1590   - suspendRunTree(run);
1591   - return {
1592   - ok: false, runId, runStatus: (getRun(runId) || {}).run_status, reason: 'budget-exhausted',
1593   - results: [], activated: [], budget: gate,
1594   - };
1595   - }
1596   -
  1596 + // 候选先查、budget/billing 后查——次序必须跟 advanceOne 一致(其注释原话:「拾到了 ready
  1597 + // 节点(=真要花钱了)才查,避免空转时把已跑完的 run 误挂起」)。审 T5 时发现这里以前是反的
  1598 + // (先查 budget 再查候选):一个已经没有 ready 节点、只是恰好也把预算花完了的收敛 run,
  1599 + // 会被误判成「预算耗尽而挂起」而不是正确收敛到 completed。顺手连着billing gate一起接进来,
  1600 + // 两处判定共用同一个「先确认真有工作要做」的前提。
1597 1601 const def = defIndex(run);
1598 1602 const candidates = getDb()
1599 1603 .prepare(`SELECT * FROM graph_run_nodes WHERE run_id = ? AND status = 'ready' ORDER BY created_at, id LIMIT ?`)
... ... @@ -1606,6 +1610,26 @@ async function advanceBatch(runId, opts = {}) {
1606 1610 return { ok: false, runId, runStatus: settleRun(runId), reason: 'no-ready-node', results: [], activated: [] };
1607 1611 }
1608 1612  
  1613 + const gate = checkBudget(run);
  1614 + if (!gate.ok) {
  1615 + suspendRunTree(run);
  1616 + return {
  1617 + ok: false, runId, runStatus: (getRun(runId) || {}).run_status, reason: 'budget-exhausted',
  1618 + results: [], activated: [], budget: gate,
  1619 + };
  1620 + }
  1621 +
  1622 + // 27-P4 T5:账户欠费门。billingGateFn 未注入时 checkBilling 恒返回 ok:true(桩子默认「未欠费」,
  1623 + // 真实计费接口接通之前不影响任何行为)。放在 checkBudget 之后同一个理由:真要花钱才查。
  1624 + const billing = await checkBilling(run);
  1625 + if (!billing.ok) {
  1626 + suspendRunTree(run, { reason: 'billing-arrears' });
  1627 + return {
  1628 + ok: false, runId, runStatus: (getRun(runId) || {}).run_status, reason: 'billing-arrears',
  1629 + results: [], activated: [], billing,
  1630 + };
  1631 + }
  1632 +
1609 1633 const claimed = [];
1610 1634 for (const picked of candidates) {
1611 1635 const defNode = def.nodes.get(picked.def_node_id) || {};
... ... @@ -2541,6 +2565,82 @@ function checkBudget(run) {
2541 2565 }
2542 2566  
2543 2567 /**
  2568 + * 27-P4 T5:账户欠费门的可插拔挂载点。
  2569 + *
  2570 + * 与 `checkBudget` 不是一回事——`checkBudget` 判的是**任务级**的 agent-call/token 预算池
  2571 + * (R-15 那套「只增不减的共池」),这里判的是**账户级**的欠费状态,两个维度互不代替
  2572 + * (27-P4 §4.4 决策记录了这条区分,别合并成一个判据)。
  2573 + *
  2574 + * 真实的计费接口还没确认(依赖计费系统那边给出可查的欠费状态),先按 `setAgentExecutor` /
  2575 + * `setPlanWorkGraph` 同款的可注入模式搭桩子:不注入时恒返回「未欠费」,`checkBudget`/
  2576 + * `checkBilling` 两道门因此在真实接口接通前对现有行为零影响。
  2577 + */
  2578 +let billingGateFn = null;
  2579 +
  2580 +/**
  2581 + * @param {null | (companyId: string) => boolean | Promise<boolean> |
  2582 + * {inArrears: boolean} | Promise<{inArrears: boolean}>} fn
  2583 + * 传 null 恢复成「恒未欠费」的默认桩子(测试 afterEach 用)。
  2584 + */
  2585 +function setBillingGate(fn) {
  2586 + billingGateFn = fn;
  2587 +}
  2588 +
  2589 +/**
  2590 + * @param {object} run
  2591 + * @returns {Promise<{ok: boolean, inArrears: boolean}>}
  2592 + */
  2593 +async function checkBilling(run) {
  2594 + if (!billingGateFn) return { ok: true, inArrears: false };
  2595 + const raw = await billingGateFn(run.company_id);
  2596 + const inArrears = typeof raw === 'boolean' ? raw : !!(raw && raw.inArrears);
  2597 + return { ok: !inArrears, inArrears };
  2598 +}
  2599 +
  2600 +/**
  2601 + * 27-P4 T5:欠费清零后的自动恢复。
  2602 + *
  2603 + * 今天从 `blocked` 恢复靠人手点「启动」(`startRunSync` 里 `blocked → running` 那跳)——
  2604 + * 欠费清零不会有人去点,这正是「7×24 无人值守缺看门狗」那个更大缺口的一个具体实例
  2605 + * (27-P4 §4.4 决策块)。这个函数补的就是那个自动触发器:扫一遍所有因为欠费被挂起的
  2606 + * root run,对每一条重新查一次欠费状态,清了就代它点「启动」。
  2607 + *
  2608 + * **谁来调它、多久调一次,不是这个函数的职责**——那是计费系统那边事件/轮询接进来之后
  2609 + * 才能定的调度问题(T5 设计决策里写过),这里只保证「调了就正确」。桩子还没换成真实
  2610 + * 计费接口时,`checkBilling` 恒 ok,这个函数扫到的挂起 run 集合恒为空,调用零风险。
  2611 + *
  2612 + * @param {object} [opts]
  2613 + * @param {string} [opts.companyId] 只扫这一家公司;不传扫全部
  2614 + * @returns {Promise<{scanned: number, resumed: string[], stillBlocked: string[]}>}
  2615 + */
  2616 +async function resumeBillingClearedRuns(opts = {}) {
  2617 + const rows = getDb()
  2618 + .prepare(
  2619 + opts.companyId
  2620 + ? `SELECT * FROM graph_runs WHERE run_status = 'blocked' AND block_reason = 'billing-arrears'
  2621 + AND parent_run_id IS NULL AND company_id = ?`
  2622 + : `SELECT * FROM graph_runs WHERE run_status = 'blocked' AND block_reason = 'billing-arrears'
  2623 + AND parent_run_id IS NULL`
  2624 + )
  2625 + .all(...(opts.companyId ? [opts.companyId] : []));
  2626 +
  2627 + const resumed = [];
  2628 + const stillBlocked = [];
  2629 + for (const row of rows) {
  2630 + const billing = await checkBilling(row);
  2631 + if (!billing.ok) {
  2632 + stillBlocked.push(row.id);
  2633 + continue;
  2634 + }
  2635 + // startRun 的「启动」路径本身就是 blocked→running 的准入 + 三层级联恢复(§4.4 决策
  2636 + // 复用的正是这条路),不用在这里另写一套恢复逻辑。
  2637 + startRun(row.id, { mode: row.execution_mode });
  2638 + resumed.push(row.id);
  2639 + }
  2640 + return { scanned: rows.length, resumed, stillBlocked };
  2641 +}
  2642 +
  2643 +/**
2544 2644 * 扣减(「只增不减」,§四.A.3)。始终写 root run 那一行。
2545 2645 * ponytail: 读-改-写不是原子的,当前单进程串行驱动(runAuto 一次只推一个节点)下够用;
2546 2646 * 升级路径是把自增下沉成一条 `UPDATE ... SET budget_json = json_set(...)` 的 SQL。
... ... @@ -2568,11 +2668,22 @@ function spendBudget(run, { agentCalls = 0, tokens = 0 } = {}) {
2568 2668 }
2569 2669  
2570 2670 /**
2571   - * 预算耗尽 → **整棵 run 树挂起等人工**,不是失败(§四.A.3):
  2671 + * 资源类原因(预算耗尽 / 账户欠费)→ **整棵 run 树挂起等人工**,不是失败(§四.A.3 / 27-P4 §4.4):
2572 2672 * root run → blocked,树上所有非终态 run 一律置 pause_requested=1。
2573 2673 * 同样只做一次 listRunTree 索引查询。
  2674 + *
  2675 + * @param {object} run
  2676 + * @param {object} [opts]
  2677 + * @param {string} [opts.reason='budget-exhausted'] 写进 `graph_runs.block_reason`(机器可读,
  2678 + * 欠费自动恢复的扫描靠它过滤),默认值保留旧调用方(checkBudget 那条路径)的行为不变。
  2679 + * @param {string} [opts.summary] 人读的事件摘要,不传则按 reason 给个默认句子。
2574 2680 */
2575   -function suspendRunTree(run) {
  2681 +function suspendRunTree(run, opts = {}) {
  2682 + const reason = opts.reason || 'budget-exhausted';
  2683 + const summary = opts.summary
  2684 + || (reason === 'billing-arrears'
  2685 + ? '账户欠费:整棵 run 树挂起等人工(不是失败)'
  2686 + : '预算耗尽:整棵 run 树挂起等人工(不是失败)');
2576 2687 const rootRunId = run.root_run_id;
2577 2688 const tree = listRunTree(rootRunId);
2578 2689 for (const r of tree) {
... ... @@ -2581,9 +2692,8 @@ function suspendRunTree(run) {
2581 2692 }
2582 2693 const root = getRun(rootRunId);
2583 2694 if (root && canTransitionRun(root.run_status, 'blocked')) {
2584   - sse.transitionRun(rootRunId, root.run_status, 'blocked', {}, {
2585   - summary: '预算耗尽:整棵 run 树挂起等人工(不是失败)',
2586   - reason: 'budget-exhausted',
  2695 + sse.transitionRun(rootRunId, root.run_status, 'blocked', { blockReason: reason }, {
  2696 + summary, reason,
2587 2697 });
2588 2698 }
2589 2699 return (getRun(run.id) || {}).run_status;
... ... @@ -2726,6 +2836,11 @@ function startRunSync(runId, { mode } = {}) {
2726 2836 getDb().prepare(`UPDATE graph_runs SET execution_mode = ?, updated_at = ? WHERE id = ?`)
2727 2837 .run(mode, now(), runId);
2728 2838 }
  2839 + // 27-P4 T5:block_reason 只在 suspendRunTree 挂起那一刻写,「启动」(不管从 blocked/paused/
  2840 + // ready 起)一律清掉——不清的话,一次欠费挂起恢复之后再因为别的原因二次挂起,界面还会显示
  2841 + // 上一次的旧理由。这条路径只有「启动」会碰它,不存在并发写冲突,用无条件 UPDATE 而不是走
  2842 + // sse.transitionRun 的 patch(那边的 from 已经在上面的 admitOrRequeue 里转掉了,这里补一刀更简单)。
  2843 + getDb().prepare(`UPDATE graph_runs SET block_reason = NULL WHERE id = ?`).run(runId);
2729 2844  
2730 2845 const resumed = resumeDescendants(run, mode);
2731 2846 const seeded = fresh ? seedEntryNodes(runId) : [];
... ... @@ -4207,6 +4322,11 @@ module.exports = {
4207 4322 wallclockElapsedMs,
4208 4323 suspendRunTree,
4209 4324  
  4325 + // 27-P4 T5:账户欠费门(可插拔桩子)+ 自动恢复
  4326 + checkBilling,
  4327 + setBillingGate,
  4328 + resumeBillingClearedRuns,
  4329 +
4210 4330 // 重试级联判据(§四.C 末段)
4211 4331 diffContracts,
4212 4332 applyRetryCascade,
... ...
server/services/work-graph-db.js
... ... @@ -326,6 +326,13 @@ function getDb() {
326 326 -- DEFAULT_MAX_CONCURRENCY(引擎侧解析,不在 DB 层给默认值,理由同其它可空列——
327 327 -- 应用层的默认值改了不用迁移存量行)。
328 328 max_concurrency INTEGER,
  329 + -- 27-P4 T5:root run 挂起(run_status='blocked')的机器可读原因,只有
  330 + -- suspendRunTree 写它('budget-exhausted' | 'billing-arrears'),startRunSync
  331 + -- 「启动」时无条件清空。只在 root run 上有意义——suspendRunTree 只把树的 root
  332 + -- 转成 blocked,子 run 只收到 pause_requested,不动它们的 block_reason(它们本来就没有)。
  333 + -- 存在的意义是让「欠费清零后自动恢复」这类扫描能按原因过滤(run_status/block_reason
  334 + -- 复合索引),不用去解析 timeline 里的 event 文本。
  335 + block_reason TEXT,
329 336 created_at TEXT NOT NULL,
330 337 updated_at TEXT NOT NULL
331 338 );
... ... @@ -649,6 +656,15 @@ function getDb() {
649 656 if (!hasColumn(db, 'graph_runs', 'max_concurrency')) {
650 657 db.exec(`ALTER TABLE graph_runs ADD COLUMN max_concurrency INTEGER`);
651 658 }
  659 + // 27-P4 T5:graph_runs 追加 block_reason,可空,旧行 NULL = 「不是因为欠费/预算挂起的
  660 + // blocked run」(批 3 时代 blocked 只有熔断/死路一种成因,回填 NULL 如实反映当时没有这个概念)。
  661 + if (!hasColumn(db, 'graph_runs', 'block_reason')) {
  662 + db.exec(`ALTER TABLE graph_runs ADD COLUMN block_reason TEXT`);
  663 + }
  664 + // 欠费自动恢复的扫描入口:`WHERE run_status='blocked' AND block_reason='billing-arrears'
  665 + // AND parent_run_id IS NULL`,加个复合索引配这个查询。
  666 + db.exec(`CREATE INDEX IF NOT EXISTS idx_graph_runs_block_reason
  667 + ON graph_runs(run_status, block_reason) WHERE parent_run_id IS NULL`);
652 668 if (!hasColumn(db, 'goals', 'reported_at')) {
653 669 db.exec(`ALTER TABLE goals ADD COLUMN reported_at TEXT`);
654 670 db.exec(`
... ... @@ -1355,6 +1371,7 @@ const RUN_PATCH_COLUMNS = {
1355 1371 stopRequested: 'stop_requested',
1356 1372 executionMode: 'execution_mode',
1357 1373 budgetJson: 'budget_json',
  1374 + blockReason: 'block_reason',
1358 1375 };
1359 1376  
1360 1377 /**
... ...
server/tests/unit/graph-engine-advance-batch.test.js
... ... @@ -143,9 +143,13 @@ describe(&#39;advanceBatch(27-P4 T4/T6)&#39;, () =&gt; {
143 143 await engine.advanceOne(runId); // entry → success,点亮 a/b/c
144 144 calls.length = 0; // 只看这一批 advanceBatch 触发的调用,entry 那次不算
145 145  
146   - // 刻意不 await:advanceBatch 在自己第一次真正挂起(Promise.allSettled)之前全程同步,
147   - // 所以这一行语句本身执行完,三个节点的 claim + executor 调用就都已经发生了。
  146 + // advanceBatch 内部在拾取候选之后、正式 claim 之前会 `await checkBilling(run)`
  147 + // (27-P4 T5 接入的欠费门)——即使没注入 billingGateFn、内部零真实 await,`await` 本身
  148 + // 也至少让出一次 microtask。所以这里不能再指望「调用这一行语句本身执行完就已经 claim 好了」,
  149 + // 用一次 microtask flush 让它跨过那一次 await,claim 循环仍然是同步的,跨过去之后
  150 + // 三个节点必然已经全部 claim + 调用完(不依赖真实定时器,不是计时手段)。
148 151 const batchPromise = engine.advanceBatch(runId, { maxConcurrency: 3 });
  152 + await Promise.resolve();
149 153  
150 154 expect(calls.sort()).toEqual(['a', 'b', 'c']);
151 155 expect(statusMap(runId)).toMatchObject({ a: 'running', b: 'running', c: 'running' });
... ... @@ -225,6 +229,7 @@ describe(&#39;advanceBatch(27-P4 T4/T6)&#39;, () =&gt; {
225 229 await engine.advanceOne(runId); // entry → success
226 230  
227 231 const batchPromise = engine.advanceBatch(runId); // 不传 maxConcurrency,读 run.max_concurrency=2
  232 + await Promise.resolve(); // 跨过 checkBilling 那次 await(见上一条用例的注释)
228 233 const running = Object.entries(statusMap(runId)).filter(([, s]) => s === 'running');
229 234 expect(running).toHaveLength(2); // 三个 ready 里只拾取了两个,第三个仍是 ready
230 235  
... ...