/** * Unit Test — engine.extendBudget:给 blocked run 追加预算 + 结清卡死的墙钟锚点 * * 背景:run_b962ea87f58f 因 wallclock_ms 耗尽被 block,`spent.started_at` 卡在 block * 之前的旧时刻没有断开(该 run 被 block 那一刻树里还有别的 run 在跑,settleWallclock * 判定「不结算」,见 budget-wallclock-active.test.js 那条同名用例)。 * 只加 wallclock_ms 上限不够:`startRunSync` 一启动就走 settleWallclock('running'), * 见到 started_at 非空会拒绝重锚,新加的额度立刻被「挂起等待的时长」吃光。 * 这个测试钉住 extendBudget 必须同时把这个锚点结清。 */ 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-extend-budget-')); 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; function seedRun(id, budgetObj, status = 'blocked') { const db = wdb.getDb(); const t = '2026-08-23T05:23:52.886Z'; 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, JSON.stringify(budgetObj), t, t); return wdb.getRun(id); } describe('extendBudget · 追加预算并结清卡死的墙钟锚点', () => { afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); beforeEach(() => wdb.getDb().prepare('DELETE FROM graph_runs').run()); it('run_b962ea87f58f 的真实形态:追加后立即可用,且锚点被断开', () => { const stuckStartedAt = '2026-08-23T05:23:52.886Z'; // 距 now() 已远超 2h const run = seedRun('r_stuck', { total_agent_calls: 100, total_tokens: 5_000_000, wallclock_ms: 2 * HOUR, spent: { agent_calls: 35, tokens: 249_324, active_ms: 0, started_at: stuckStartedAt }, }); // 追加前:卡死的锚点让墙钟早就判耗尽 expect(engine.checkBudget(run).exhausted).toContain('wallclock_ms'); const out = engine.extendBudget(run.id, { wallclock_ms: 4 * HOUR }); expect(out.ok).toBe(true); expect(out.budget.wallclock_ms).toBe(6 * HOUR); expect(out.budget.spent.started_at).toBeNull(); // 锚点必须断开 expect(out.budget.spent.active_ms).toBeGreaterThan(0); // 挂起时长已并入 active_ms const fresh = wdb.getRun('r_stuck'); expect(engine.checkBudget(fresh).ok).toBe(true); // 「启动」重锚不会被旧锚点拒绝——settleWallclock('running') 应该重新开一段计时 const settled = engine.settleWallclock('r_stuck', 'running'); expect(settled.started_at).toBeTruthy(); }); it('目标维度本来就不限额时忽略该维度;一维都没追加成就报错', () => { seedRun('r_partial', { total_agent_calls: 100, spent: { agent_calls: 5 } }); // wallclock_ms 本来就没配额度,追加它是 no-op;total_agent_calls 有配额,真正生效 const out = engine.extendBudget('r_partial', { wallclock_ms: HOUR, total_agent_calls: 50 }); expect(out.applied).toEqual({ total_agent_calls: 50 }); expect(out.budget.total_agent_calls).toBe(150); expect(out.budget.wallclock_ms).toBeUndefined(); seedRun('r_none', { total_agent_calls: 100, spent: {} }); expect(() => engine.extendBudget('r_none', { wallclock_ms: HOUR })).toThrow(); }); });