Commit e95e77f0a3a0749984098876cec121610d00d2f6
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>
Showing
3 changed files
with
158 additions
and
16 deletions
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('advanceBatch(27-P4 T4/T6)', () => { |
| 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('advanceBatch(27-P4 T4/T6)', () => { |
| 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 | ... | ... |