/** * Unit Tests — 账户欠费门 + 自动恢复(27-P4 T5) * * 覆盖: * - setBillingGate 未注入时对现有行为零影响(默认「未欠费」) * - 欠费时 advanceOne / advanceBatch 都会 suspendRunTree,reason='billing-arrears', * 写进 graph_runs.block_reason,ok:false * - startRunSync「启动」时清空 block_reason * - resumeBillingClearedRuns:欠费清零后自动恢复;仍欠费的留在 blocked 且不重复推进 * - advanceBatch 的排序 bug 回归:候选先查、budget/billing 后查——一个已经收敛 * (零 ready 节点)只是恰好预算也耗尽的 run,必须正确收敛到 completed, * 不能被误判成 budget-exhausted * * 隔离手法与 graph-engine-advance-batch.test.js 一致。 */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; describe('账户欠费门 + 自动恢复(27-P4 T5)', () => { let wdb; let engine; let runsDir; const TEST_DB_PATH = '/tmp/test-graph-engine-billing-gate.sqlite'; beforeAll(() => { for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { try { fs.unlinkSync(f); } catch {} } process.env.GRAPH_DB_PATH = TEST_DB_PATH; runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-billing-gate-runs-')); process.env.GRAPH_RUNS_DIR = runsDir; for (const m of [ '../../services/graph-db', '../../services/graph-service', '../../services/org-graph-service', '../../services/work-graph-db', '../../services/graph-run-sse', '../../services/graph-engine', ]) { delete require.cache[require.resolve(m)]; } wdb = require('../../services/work-graph-db'); engine = require('../../services/graph-engine'); }); afterAll(() => { for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { try { fs.unlinkSync(f); } catch {} } delete process.env.GRAPH_DB_PATH; try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} delete process.env.GRAPH_RUNS_DIR; }); beforeEach(() => { const db = wdb.getDb(); db.pragma('foreign_keys = OFF'); db.exec(` DELETE FROM graph_run_loop_records; DELETE FROM graph_run_nodes; DELETE FROM graph_runs; DELETE FROM work_graph_edges; DELETE FROM work_graph_nodes; DELETE FROM work_graphs; DELETE FROM system_agents; `); db.pragma('foreign_keys = ON'); engine.setAgentExecutor(null); engine.setBillingGate(null); // 每个用例结束都要回默认桩子,不然会串到下一个用例 }); let seq = 0; const nextCompany = () => `p4t5-co-${++seq}`; function ensureAgents(ids) { const db = wdb.getDb(); const ts = new Date().toISOString(); const ins = db.prepare(` INSERT OR IGNORE INTO system_agents (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?) `); for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, ts, ts); } function buildSoloRun({ companyId = nextCompany(), maxConcurrency = null } = {}) { ensureAgents(['solo']); const g = wdb.createWorkGraph({ companyId, nodes: ['solo'], edges: [] }); expect(g.errors || []).toEqual([]); const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual', maxConcurrency, }); engine.markRunReady(run.id); return { runId: run.id, companyId }; } it('未注入 billingGateFn 时零影响:checkBilling 恒 ok,节点正常跑完', async () => { const { runId } = buildSoloRun(); engine.setAgentExecutor(async () => ({ status: 'success' })); engine.startRun(runId); const res = await engine.advanceOne(runId); expect(res.ok).toBe(true); expect(wdb.getRun(runId).run_status).toBe('completed'); }); it('advanceOne:欠费时挂起整棵树,reason=billing-arrears,block_reason 落库', async () => { const { runId } = buildSoloRun(); engine.setAgentExecutor(async () => ({ status: 'success' })); // 不该被调到 engine.setBillingGate(() => true); // 恒欠费 engine.startRun(runId); const res = await engine.advanceOne(runId); expect(res.ok).toBe(false); expect(res.reason).toBe('billing-arrears'); expect(res.billing).toEqual({ ok: false, inArrears: true }); const run = wdb.getRun(runId); expect(run.run_status).toBe('blocked'); expect(run.block_reason).toBe('billing-arrears'); // 欠费门跟 checkBudget 位置一致——都在「拾到 ready 节点」之后、真正 claim(ready→running // 的条件更新)之前查,所以挂起时节点还停在 ready,没有被 claim(更没有被结算成任何终态)。 expect(wdb.getRunState(runId).nodes[0].status).toBe('ready'); }); it('advanceBatch(maxConcurrency>1):欠费时同样挂起,reason=billing-arrears', async () => { const companyId = nextCompany(); ensureAgents(['entry', 'a', 'b']); const g = wdb.createWorkGraph({ companyId, nodes: ['entry', 'a', 'b'], edges: [{ from: 'entry', to: 'a' }, { from: 'entry', to: 'b' }], // 单入口,fan-out 出 a/b }); expect(g.errors || []).toEqual([]); const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' }); engine.markRunReady(run.id); engine.setAgentExecutor(async () => ({ status: 'success' })); engine.startRun(run.id); await engine.advanceOne(run.id); // entry → success,点亮 a/b(这一步不欠费) engine.setBillingGate(() => ({ inArrears: true })); // 现在才欠费 const res = await engine.advanceBatch(run.id, { maxConcurrency: 3 }); expect(res.ok).toBe(false); expect(res.reason).toBe('billing-arrears'); expect(wdb.getRun(run.id).run_status).toBe('blocked'); expect(wdb.getRun(run.id).block_reason).toBe('billing-arrears'); }); it('startRunSync「启动」清空 block_reason:欠费挂起 → 手动点启动 → block_reason 变 null', async () => { const { runId } = buildSoloRun(); engine.setAgentExecutor(async () => ({ status: 'success' })); engine.setBillingGate(() => true); engine.startRun(runId); await engine.advanceOne(runId); expect(wdb.getRun(runId).block_reason).toBe('billing-arrears'); engine.setBillingGate(null); // 欠费清零 engine.startRun(runId); // 人工点「启动」 expect(wdb.getRun(runId).block_reason).toBeNull(); expect(wdb.getRun(runId).run_status).toBe('running'); }); it('resumeBillingClearedRuns:欠费清零的 run 被自动恢复并跑完,仍欠费的原地不动', async () => { const { runId: cleared } = buildSoloRun({ companyId: 'p4t5-cleared' }); const { runId: stillArrears } = buildSoloRun({ companyId: 'p4t5-still' }); engine.setAgentExecutor(async () => ({ status: 'success' })); engine.setBillingGate(() => true); engine.startRun(cleared); await engine.advanceOne(cleared); engine.startRun(stillArrears); await engine.advanceOne(stillArrears); expect(wdb.getRun(cleared).run_status).toBe('blocked'); expect(wdb.getRun(stillArrears).run_status).toBe('blocked'); // 只有 `cleared` 那家公司欠费清零了,`stillArrears` 还欠着 engine.setBillingGate((companyId) => companyId === 'p4t5-still'); const sweep = await engine.resumeBillingClearedRuns(); expect(sweep.scanned).toBe(2); expect(sweep.resumed).toEqual([cleared]); expect(sweep.stillBlocked).toEqual([stillArrears]); expect(wdb.getRun(cleared).run_status).toBe('running'); // 恢复了,节点原地继续跑(手动模式停在 running) expect(wdb.getRun(cleared).block_reason).toBeNull(); expect(wdb.getRun(stillArrears).run_status).toBe('blocked'); // 纹丝不动 expect(wdb.getRun(stillArrears).block_reason).toBe('billing-arrears'); }); it('resumeBillingClearedRuns 支持按 companyId 只扫一家', async () => { const { runId: a } = buildSoloRun({ companyId: 'p4t5-scope-a' }); const { runId: b } = buildSoloRun({ companyId: 'p4t5-scope-b' }); engine.setAgentExecutor(async () => ({ status: 'success' })); engine.setBillingGate(() => true); engine.startRun(a); await engine.advanceOne(a); engine.startRun(b); await engine.advanceOne(b); engine.setBillingGate(null); // 两家都清零了,但这次只扫 a const sweep = await engine.resumeBillingClearedRuns({ companyId: 'p4t5-scope-a' }); expect(sweep.scanned).toBe(1); expect(sweep.resumed).toEqual([a]); expect(wdb.getRun(a).run_status).toBe('running'); expect(wdb.getRun(b).run_status).toBe('blocked'); // 没被这次扫描碰到 }); it('advanceBatch 排序回归:候选先查、budget/billing 后查——已收敛(零 ready 节点)' + '只是恰好预算/欠费也耗尽的 run,正确收敛到 completed,不会被误判成挂起', async () => { const { runId } = buildSoloRun(); engine.startRun(runId); // 绕开引擎直接把节点写成 success(不经过 settleNode,run_status 就不会被顺带收敛), // 构造「run_status 还是 running,但已经没有 ready/running 节点」这个状态—— // 同款手法 graph-engine-advance-batch.test.js 已经在用。 const node = wdb.getRunState(runId).nodes[0]; expect(wdb.updateNodeStatus(node.id, 'ready', 'running')).toBe(1); expect(wdb.updateNodeStatus(node.id, 'running', 'success')).toBe(1); // 预算与欠费都打成「耗尽/欠费」——如果 advanceBatch 在查候选之前就先查这两道门, // 会误判成 budget-exhausted 或 billing-arrears 并把这个其实已经跑完的 run 挂起。 wdb.getDb().prepare(`UPDATE graph_runs SET budget_json = ? WHERE id = ?`) .run(JSON.stringify({ total_agent_calls: 0, spent: { agent_calls: 999 } }), runId); engine.setBillingGate(() => true); const res = await engine.advanceBatch(runId, { maxConcurrency: 3 }); expect(res.ok).toBe(false); expect(res.reason).toBe('no-ready-node'); expect(wdb.getRun(runId).run_status).toBe('completed'); expect(wdb.getRun(runId).block_reason).toBeNull(); }); });