Commit 0c562d9d0b65a96611bc00f1b316436354a9306b

Authored by yibo
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>
server/index.js
... ... @@ -171,6 +171,20 @@ async function start() {
171 171 } catch (err) {
172 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 188 const heartbeatService = require('./services/heartbeat-service');
175 189 heartbeatService.start();
176 190 // 公司级心跳 (Pipeline 自动驱动)
... ...
server/services/graph-engine.js
... ... @@ -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 2765 * 扣减(「只增不减」,§四.A.3)。始终写 root run 那一行。
2697 2766 * ponytail: 读-改-写不是原子的,当前单进程串行驱动(runAuto 一次只推一个节点)下够用;
2698 2767 * 升级路径是把自增下沉成一条 `UPDATE ... SET budget_json = json_set(...)` 的 SQL。
... ... @@ -4379,6 +4448,11 @@ module.exports = {
4379 4448 setBillingGate,
4380 4449 resumeBillingClearedRuns,
4381 4450  
  4451 + // 27-P4 §4.4:墙钟预算看门狗(manual 模式无人点「下一步」/ auto 驱动循环随进程重启丢失时的兜底)
  4452 + sweepWallclockBudgets,
  4453 + startWallclockWatchdog,
  4454 + stopWallclockWatchdog,
  4455 +
4382 4456 // 重试级联判据(§四.C 末段)
4383 4457 diffContracts,
4384 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 +});
... ...