Commit 0c562d9d0b65a96611bc00f1b316436354a9306b
1 parent
03bd96b7
fix(graph-engine): 补墙钟预算看门狗,堵 manual/auto 两条驱动路径失活后无人发现超时的口子
manual 模式没人点「下一步」、auto 驱动循环随进程重启丢失时,run_status='running' 的 run 再也不会触发 checkBudget,wallclock_ms 超时无人发现(实例:T-msx6toej 卡 135 小时才被人工揪出)。新增 sweepWallclockBudgets + 60s 兜底定时器 startWallclockWatchdog,复用既有 checkBudget/suspendRunTree 路径,接入点在 index.js DISABLE_BACKGROUND_AGENTS 早退之前。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
3 changed files
with
168 additions
and
0 deletions
server/index.js
| @@ -171,6 +171,20 @@ async function start() { | @@ -171,6 +171,20 @@ async function start() { | ||
| 171 | } catch (err) { | 171 | } catch (err) { |
| 172 | console.error('[Startup] 任务队列扫描失败:', err.message); | 172 | console.error('[Startup] 任务队列扫描失败:', err.message); |
| 173 | } | 173 | } |
| 174 | + // 27-P4 §4.4:墙钟预算看门狗。manual 模式没人点「下一步」、或 auto 模式的驱动循环 | ||
| 175 | + // (纯内存 promise 链)随进程重启丢失时,run_status='running' 的 run 从此再也不会 | ||
| 176 | + // 触发 checkBudget —— wallclock_ms 早超了也没人发现(T-msx6toej 卡 135 小时才被人工揪出)。 | ||
| 177 | + // 起跑前先补一次全量扫描,再起 60s 兜底定时器,同 task-scheduler 的形状。 | ||
| 178 | + try { | ||
| 179 | + const engine = require('./services/graph-engine'); | ||
| 180 | + const swept = engine.sweepWallclockBudgets(); | ||
| 181 | + if (swept.suspended.length) { | ||
| 182 | + console.log(`[Startup] ⏱️ 墙钟看门狗:挂起 ${swept.suspended.length} 条超时仍在跑的 run`); | ||
| 183 | + } | ||
| 184 | + engine.startWallclockWatchdog(); | ||
| 185 | + } catch (err) { | ||
| 186 | + console.error('[Startup] 墙钟看门狗启动失败:', err.message); | ||
| 187 | + } | ||
| 174 | const heartbeatService = require('./services/heartbeat-service'); | 188 | const heartbeatService = require('./services/heartbeat-service'); |
| 175 | heartbeatService.start(); | 189 | heartbeatService.start(); |
| 176 | // 公司级心跳 (Pipeline 自动驱动) | 190 | // 公司级心跳 (Pipeline 自动驱动) |
server/services/graph-engine.js
| @@ -2693,6 +2693,75 @@ async function resumeBillingClearedRuns(opts = {}) { | @@ -2693,6 +2693,75 @@ async function resumeBillingClearedRuns(opts = {}) { | ||
| 2693 | } | 2693 | } |
| 2694 | 2694 | ||
| 2695 | /** | 2695 | /** |
| 2696 | + * 墙钟预算的兜底扫描——「7×24 无人值守缺看门狗」(27-P4 §4.4)的又一个具体实例, | ||
| 2697 | + * 这次挡的是 `wallclock_ms` 那一维而不是欠费。 | ||
| 2698 | + * | ||
| 2699 | + * `checkBudget` 只在 `advanceOne`/`advanceBatch` 拾到 ready 节点时才查(§四.A.3 的 | ||
| 2700 | + * 「拾到了才查」,即将真花钱时才查,不在空转时误判)——这对**正在被推进**的 run 没问题, | ||
| 2701 | + * 出问题的是**没人再推进它**的 run:manual 模式没人点「下一步」(`POST /graph-runs/:id/next`), | ||
| 2702 | + * 或 auto 模式的驱动循环(`runAuto`)是纯内存 promise 链,进程一重启就消失、 | ||
| 2703 | + * 没有任何东西在启动时把它接回去。两条路径的共同症状是 `run_status='running'` | ||
| 2704 | + * 却再也没有任何调用会触发 checkBudget——wallclock_ms 早就超了,界面上却和正常在跑 | ||
| 2705 | + * 一模一样,直到有人手工发现。 | ||
| 2706 | + * | ||
| 2707 | + * 实例:`T-msx6toej`/`run_56ea2c598153`,manual 模式,2026-08-17 12:06 起跑, | ||
| 2708 | + * `wallclock_ms` 预算 2 小时,卡到 2026-08-23 才被发现——中途没有任何一次 checkBudget。 | ||
| 2709 | + * | ||
| 2710 | + * 只扫 root run:`budget_json` 恒挂在 root(`readBudget` 的口径,子 run 永远是 null), | ||
| 2711 | + * 扫子 run 拿到的预算池和扫 root 是同一份,多扫一遍纯浪费。 | ||
| 2712 | + * | ||
| 2713 | + * 复用 `checkBudget` + `suspendRunTree`——跟 `advanceOne` 判耗尽时走的是同一条路径, | ||
| 2714 | + * 不发明第二种「超时」形态。 | ||
| 2715 | + * | ||
| 2716 | + * @param {object} [opts] | ||
| 2717 | + * @param {string} [opts.companyId] 只扫这一家公司;不传扫全部 | ||
| 2718 | + * @returns {{scanned: number, suspended: string[]}} | ||
| 2719 | + */ | ||
| 2720 | +function sweepWallclockBudgets(opts = {}) { | ||
| 2721 | + const rows = getDb() | ||
| 2722 | + .prepare( | ||
| 2723 | + opts.companyId | ||
| 2724 | + ? `SELECT * FROM graph_runs WHERE run_status = 'running' AND parent_run_id IS NULL | ||
| 2725 | + AND budget_json LIKE '%wallclock_ms%' AND company_id = ?` | ||
| 2726 | + : `SELECT * FROM graph_runs WHERE run_status = 'running' AND parent_run_id IS NULL | ||
| 2727 | + AND budget_json LIKE '%wallclock_ms%'` | ||
| 2728 | + ) | ||
| 2729 | + .all(...(opts.companyId ? [opts.companyId] : [])); | ||
| 2730 | + | ||
| 2731 | + const suspended = []; | ||
| 2732 | + for (const run of rows) { | ||
| 2733 | + try { | ||
| 2734 | + const gate = checkBudget(run); | ||
| 2735 | + if (!gate.ok && gate.exhausted.includes('wallclock_ms')) { | ||
| 2736 | + suspendRunTree(run); | ||
| 2737 | + suspended.push(run.id); | ||
| 2738 | + } | ||
| 2739 | + } catch (err) { | ||
| 2740 | + console.error(`[graph-engine] sweepWallclockBudgets run=${run.id} 异常: ${err.message}`); | ||
| 2741 | + } | ||
| 2742 | + } | ||
| 2743 | + return { scanned: rows.length, suspended }; | ||
| 2744 | +} | ||
| 2745 | + | ||
| 2746 | +const WALLCLOCK_SWEEP_INTERVAL_MS = 60 * 1000; | ||
| 2747 | +let wallclockSweepTimer = null; | ||
| 2748 | + | ||
| 2749 | +/** 起兜底定时器。`unref()`——不该拖着进程不退出;幂等——重复调只留一个(同 task-scheduler 的形状)。 */ | ||
| 2750 | +function startWallclockWatchdog(intervalMs = WALLCLOCK_SWEEP_INTERVAL_MS) { | ||
| 2751 | + if (wallclockSweepTimer) return wallclockSweepTimer; | ||
| 2752 | + wallclockSweepTimer = setInterval(() => { | ||
| 2753 | + try { sweepWallclockBudgets(); } catch (err) { console.error(`[graph-engine] 墙钟看门狗异常: ${err.message}`); } | ||
| 2754 | + }, intervalMs); | ||
| 2755 | + if (typeof wallclockSweepTimer.unref === 'function') wallclockSweepTimer.unref(); | ||
| 2756 | + return wallclockSweepTimer; | ||
| 2757 | +} | ||
| 2758 | + | ||
| 2759 | +function stopWallclockWatchdog() { | ||
| 2760 | + if (wallclockSweepTimer) clearInterval(wallclockSweepTimer); | ||
| 2761 | + wallclockSweepTimer = null; | ||
| 2762 | +} | ||
| 2763 | + | ||
| 2764 | +/** | ||
| 2696 | * 扣减(「只增不减」,§四.A.3)。始终写 root run 那一行。 | 2765 | * 扣减(「只增不减」,§四.A.3)。始终写 root run 那一行。 |
| 2697 | * ponytail: 读-改-写不是原子的,当前单进程串行驱动(runAuto 一次只推一个节点)下够用; | 2766 | * ponytail: 读-改-写不是原子的,当前单进程串行驱动(runAuto 一次只推一个节点)下够用; |
| 2698 | * 升级路径是把自增下沉成一条 `UPDATE ... SET budget_json = json_set(...)` 的 SQL。 | 2767 | * 升级路径是把自增下沉成一条 `UPDATE ... SET budget_json = json_set(...)` 的 SQL。 |
| @@ -4379,6 +4448,11 @@ module.exports = { | @@ -4379,6 +4448,11 @@ module.exports = { | ||
| 4379 | setBillingGate, | 4448 | setBillingGate, |
| 4380 | resumeBillingClearedRuns, | 4449 | resumeBillingClearedRuns, |
| 4381 | 4450 | ||
| 4451 | + // 27-P4 §4.4:墙钟预算看门狗(manual 模式无人点「下一步」/ auto 驱动循环随进程重启丢失时的兜底) | ||
| 4452 | + sweepWallclockBudgets, | ||
| 4453 | + startWallclockWatchdog, | ||
| 4454 | + stopWallclockWatchdog, | ||
| 4455 | + | ||
| 4382 | // 重试级联判据(§四.C 末段) | 4456 | // 重试级联判据(§四.C 末段) |
| 4383 | diffContracts, | 4457 | diffContracts, |
| 4384 | applyRetryCascade, | 4458 | applyRetryCascade, |
server/tests/unit/graph-engine-wallclock-watchdog.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * Unit Test — 墙钟预算看门狗(`sweepWallclockBudgets`) | ||
| 3 | + * | ||
| 4 | + * 挡的是什么:`checkBudget` 只在 `advanceOne`/`advanceBatch` 拾到 ready 节点时才查—— | ||
| 5 | + * manual 模式没人点「下一步」、或 auto 驱动循环随进程重启丢失时,`run_status='running'` | ||
| 6 | + * 的 run 从此再也不会触发一次 checkBudget,wallclock_ms 超没超都没人知道。 | ||
| 7 | + * 真事:T-msx6toej/run_56ea2c598153,manual 模式,卡 135 小时才被人工发现。 | ||
| 8 | + */ | ||
| 9 | +import { describe, it, expect, afterAll, beforeEach } from 'vitest'; | ||
| 10 | +import fs from 'fs'; | ||
| 11 | +import os from 'os'; | ||
| 12 | +import path from 'path'; | ||
| 13 | + | ||
| 14 | +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-wc-watchdog-')); | ||
| 15 | +process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite'); | ||
| 16 | + | ||
| 17 | +const engine = require('../../services/graph-engine'); | ||
| 18 | +const wdb = require('../../services/work-graph-db'); | ||
| 19 | + | ||
| 20 | +const HOUR = 3600_000; | ||
| 21 | +const budget = (spent) => JSON.stringify({ wallclock_ms: 2 * HOUR, total_agent_calls: 100, spent }); | ||
| 22 | + | ||
| 23 | +function seedRun(id, spentObj, status = 'running') { | ||
| 24 | + const db = wdb.getDb(); | ||
| 25 | + const t = '2026-08-17T12:06:00.000Z'; | ||
| 26 | + db.prepare(`INSERT INTO graph_runs | ||
| 27 | + (id, graph_kind, graph_id, company_id, root_run_id, execution_mode, run_status, budget_json, created_at, updated_at) | ||
| 28 | + VALUES (?, 'org', 'g1', 'co1', ?, 'manual', ?, ?, ?, ?)`) | ||
| 29 | + .run(id, id, status, budget(spentObj), t, t); | ||
| 30 | + return wdb.getRun(id); | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +describe('墙钟看门狗 · sweepWallclockBudgets', () => { | ||
| 34 | + afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); | ||
| 35 | + beforeEach(() => wdb.getDb().prepare('DELETE FROM graph_runs').run()); | ||
| 36 | + | ||
| 37 | + it('没人再推进、wallclock 早超的 running root run:扫一遍就被挂起', () => { | ||
| 38 | + // started_at 是 6 天前的绝对时刻,没人点过「下一步」——checkBudget 从没被调过 | ||
| 39 | + seedRun('r_stale', { agent_calls: 4, tokens: 53802, started_at: '2026-08-17T12:06:06.128Z' }, 'running'); | ||
| 40 | + | ||
| 41 | + const out = engine.sweepWallclockBudgets(); | ||
| 42 | + | ||
| 43 | + expect(out.suspended).toEqual(['r_stale']); | ||
| 44 | + const after = wdb.getRun('r_stale'); | ||
| 45 | + expect(after.run_status).toBe('blocked'); | ||
| 46 | + expect(after.block_reason).toBe('budget-exhausted'); | ||
| 47 | + }); | ||
| 48 | + | ||
| 49 | + it('还在预算内的 running run:扫一遍原地不动', () => { | ||
| 50 | + seedRun('r_fresh', { agent_calls: 1, tokens: 100, active_ms: 0, started_at: null }, 'running'); | ||
| 51 | + // 这一条本身没有 started_at(从未真正计时过),wallclockElapsedMs = active_ms = 0 | ||
| 52 | + | ||
| 53 | + const out = engine.sweepWallclockBudgets(); | ||
| 54 | + | ||
| 55 | + expect(out.suspended).toEqual([]); | ||
| 56 | + expect(wdb.getRun('r_fresh').run_status).toBe('running'); | ||
| 57 | + }); | ||
| 58 | + | ||
| 59 | + it('挂起期间(blocked)不算「还在跑」——只扫 running,不会被误判', () => { | ||
| 60 | + seedRun('r_blocked', { agent_calls: 7, started_at: '2026-08-17T12:06:06.128Z' }, 'blocked'); | ||
| 61 | + | ||
| 62 | + const out = engine.sweepWallclockBudgets(); | ||
| 63 | + | ||
| 64 | + expect(out.scanned).toBe(0); | ||
| 65 | + expect(wdb.getRun('r_blocked').run_status).toBe('blocked'); | ||
| 66 | + }); | ||
| 67 | + | ||
| 68 | + it('没配 wallclock_ms 的 run:不在扫描范围内', () => { | ||
| 69 | + const db = wdb.getDb(); | ||
| 70 | + db.prepare(`INSERT INTO graph_runs | ||
| 71 | + (id, graph_kind, graph_id, company_id, root_run_id, execution_mode, run_status, budget_json, created_at, updated_at) | ||
| 72 | + VALUES ('r_nowc','org','g1','co1','r_nowc','manual','running', ?, ?, ?)`) | ||
| 73 | + .run(JSON.stringify({ total_agent_calls: 10, spent: { agent_calls: 1 } }), '2026-08-17T12:06:00.000Z', '2026-08-17T12:06:00.000Z'); | ||
| 74 | + | ||
| 75 | + const out = engine.sweepWallclockBudgets(); | ||
| 76 | + | ||
| 77 | + expect(out.scanned).toBe(0); | ||
| 78 | + expect(wdb.getRun('r_nowc').run_status).toBe('running'); | ||
| 79 | + }); | ||
| 80 | +}); |