/** * Unit Test — 墙钟预算看门狗(`sweepWallclockBudgets`) * * 挡的是什么:`checkBudget` 只在 `advanceOne`/`advanceBatch` 拾到 ready 节点时才查—— * manual 模式没人点「下一步」、或 auto 驱动循环随进程重启丢失时,`run_status='running'` * 的 run 从此再也不会触发一次 checkBudget,wallclock_ms 超没超都没人知道。 * 真事:T-msx6toej/run_56ea2c598153,manual 模式,卡 135 小时才被人工发现。 */ import { describe, it, expect, afterAll, beforeEach } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-wc-watchdog-')); process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite'); const engine = require('../../services/graph-engine'); const wdb = require('../../services/work-graph-db'); const HOUR = 3600_000; const budget = (spent) => JSON.stringify({ wallclock_ms: 2 * HOUR, total_agent_calls: 100, spent }); function seedRun(id, spentObj, status = 'running') { const db = wdb.getDb(); const t = '2026-08-17T12:06:00.000Z'; db.prepare(`INSERT INTO graph_runs (id, graph_kind, graph_id, company_id, root_run_id, execution_mode, run_status, budget_json, created_at, updated_at) VALUES (?, 'org', 'g1', 'co1', ?, 'manual', ?, ?, ?, ?)`) .run(id, id, status, budget(spentObj), t, t); return wdb.getRun(id); } describe('墙钟看门狗 · sweepWallclockBudgets', () => { afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); beforeEach(() => wdb.getDb().prepare('DELETE FROM graph_runs').run()); it('没人再推进、wallclock 早超的 running root run:扫一遍就被挂起', () => { // started_at 是 6 天前的绝对时刻,没人点过「下一步」——checkBudget 从没被调过 seedRun('r_stale', { agent_calls: 4, tokens: 53802, started_at: '2026-08-17T12:06:06.128Z' }, 'running'); const out = engine.sweepWallclockBudgets(); expect(out.suspended).toEqual(['r_stale']); const after = wdb.getRun('r_stale'); expect(after.run_status).toBe('blocked'); expect(after.block_reason).toBe('budget-exhausted'); }); it('还在预算内的 running run:扫一遍原地不动', () => { seedRun('r_fresh', { agent_calls: 1, tokens: 100, active_ms: 0, started_at: null }, 'running'); // 这一条本身没有 started_at(从未真正计时过),wallclockElapsedMs = active_ms = 0 const out = engine.sweepWallclockBudgets(); expect(out.suspended).toEqual([]); expect(wdb.getRun('r_fresh').run_status).toBe('running'); }); it('挂起期间(blocked)不算「还在跑」——只扫 running,不会被误判', () => { seedRun('r_blocked', { agent_calls: 7, started_at: '2026-08-17T12:06:06.128Z' }, 'blocked'); const out = engine.sweepWallclockBudgets(); expect(out.scanned).toBe(0); expect(wdb.getRun('r_blocked').run_status).toBe('blocked'); }); it('没配 wallclock_ms 的 run:不在扫描范围内', () => { const db = wdb.getDb(); db.prepare(`INSERT INTO graph_runs (id, graph_kind, graph_id, company_id, root_run_id, execution_mode, run_status, budget_json, created_at, updated_at) VALUES ('r_nowc','org','g1','co1','r_nowc','manual','running', ?, ?, ?)`) .run(JSON.stringify({ total_agent_calls: 10, spent: { agent_calls: 1 } }), '2026-08-17T12:06:00.000Z', '2026-08-17T12:06:00.000Z'); const out = engine.sweepWallclockBudgets(); expect(out.scanned).toBe(0); expect(wdb.getRun('r_nowc').run_status).toBe('running'); }); });