Commit 090ded5f234448e5a619100813f2550e2bd55d32
1 parent
e95e77f0
test(graph-engine): 覆盖 T5 欠费门 + 自动恢复(27-P4)
新增 graph-engine-billing-gate.test.js(7 条): - 未注入 billingGateFn 时零影响 - advanceOne / advanceBatch 欠费时都正确挂起(reason=billing-arrears, block_reason 落库,节点停在 ready 没被 claim) - startRunSync「启动」清空 block_reason - resumeBillingClearedRuns 正确恢复已清零的 run、放过仍欠费的 run, 且支持按 companyId 限定扫描范围 - advanceBatch 排序回归用例:已收敛(零 ready 节点)只是恰好预算/欠费也耗尽的 run,正确收敛到 completed,不会被误判成挂起 隔离验证:临时 worktree 里单独跑(graph-engine.js/work-graph-db.js 当前正被 另一个会话同时编辑 fail-fallback-edge 规则,工作区里混着两份改动,跑全量套件 会被那边未完成的改动带崩;这条测试只依赖已提交的 T3-T5 代码,临时 worktree 里跑通即可确认与那份改动无关)。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
1 changed file
with
230 additions
and
0 deletions
server/tests/unit/graph-engine-billing-gate.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * Unit Tests — 账户欠费门 + 自动恢复(27-P4 T5) | |
| 3 | + * | |
| 4 | + * 覆盖: | |
| 5 | + * - setBillingGate 未注入时对现有行为零影响(默认「未欠费」) | |
| 6 | + * - 欠费时 advanceOne / advanceBatch 都会 suspendRunTree,reason='billing-arrears', | |
| 7 | + * 写进 graph_runs.block_reason,ok:false | |
| 8 | + * - startRunSync「启动」时清空 block_reason | |
| 9 | + * - resumeBillingClearedRuns:欠费清零后自动恢复;仍欠费的留在 blocked 且不重复推进 | |
| 10 | + * - advanceBatch 的排序 bug 回归:候选先查、budget/billing 后查——一个已经收敛 | |
| 11 | + * (零 ready 节点)只是恰好预算也耗尽的 run,必须正确收敛到 completed, | |
| 12 | + * 不能被误判成 budget-exhausted | |
| 13 | + * | |
| 14 | + * 隔离手法与 graph-engine-advance-batch.test.js 一致。 | |
| 15 | + */ | |
| 16 | +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; | |
| 17 | +import fs from 'fs'; | |
| 18 | +import os from 'os'; | |
| 19 | +import path from 'path'; | |
| 20 | + | |
| 21 | +describe('账户欠费门 + 自动恢复(27-P4 T5)', () => { | |
| 22 | + let wdb; | |
| 23 | + let engine; | |
| 24 | + let runsDir; | |
| 25 | + const TEST_DB_PATH = '/tmp/test-graph-engine-billing-gate.sqlite'; | |
| 26 | + | |
| 27 | + beforeAll(() => { | |
| 28 | + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { | |
| 29 | + try { fs.unlinkSync(f); } catch {} | |
| 30 | + } | |
| 31 | + process.env.GRAPH_DB_PATH = TEST_DB_PATH; | |
| 32 | + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-billing-gate-runs-')); | |
| 33 | + process.env.GRAPH_RUNS_DIR = runsDir; | |
| 34 | + for (const m of [ | |
| 35 | + '../../services/graph-db', | |
| 36 | + '../../services/graph-service', | |
| 37 | + '../../services/org-graph-service', | |
| 38 | + '../../services/work-graph-db', | |
| 39 | + '../../services/graph-run-sse', | |
| 40 | + '../../services/graph-engine', | |
| 41 | + ]) { | |
| 42 | + delete require.cache[require.resolve(m)]; | |
| 43 | + } | |
| 44 | + wdb = require('../../services/work-graph-db'); | |
| 45 | + engine = require('../../services/graph-engine'); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + afterAll(() => { | |
| 49 | + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { | |
| 50 | + try { fs.unlinkSync(f); } catch {} | |
| 51 | + } | |
| 52 | + delete process.env.GRAPH_DB_PATH; | |
| 53 | + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} | |
| 54 | + delete process.env.GRAPH_RUNS_DIR; | |
| 55 | + }); | |
| 56 | + | |
| 57 | + beforeEach(() => { | |
| 58 | + const db = wdb.getDb(); | |
| 59 | + db.pragma('foreign_keys = OFF'); | |
| 60 | + db.exec(` | |
| 61 | + DELETE FROM graph_run_loop_records; | |
| 62 | + DELETE FROM graph_run_nodes; | |
| 63 | + DELETE FROM graph_runs; | |
| 64 | + DELETE FROM work_graph_edges; | |
| 65 | + DELETE FROM work_graph_nodes; | |
| 66 | + DELETE FROM work_graphs; | |
| 67 | + DELETE FROM system_agents; | |
| 68 | + `); | |
| 69 | + db.pragma('foreign_keys = ON'); | |
| 70 | + engine.setAgentExecutor(null); | |
| 71 | + engine.setBillingGate(null); // 每个用例结束都要回默认桩子,不然会串到下一个用例 | |
| 72 | + }); | |
| 73 | + | |
| 74 | + let seq = 0; | |
| 75 | + const nextCompany = () => `p4t5-co-${++seq}`; | |
| 76 | + | |
| 77 | + function ensureAgents(ids) { | |
| 78 | + const db = wdb.getDb(); | |
| 79 | + const ts = new Date().toISOString(); | |
| 80 | + const ins = db.prepare(` | |
| 81 | + INSERT OR IGNORE INTO system_agents | |
| 82 | + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) | |
| 83 | + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?) | |
| 84 | + `); | |
| 85 | + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, ts, ts); | |
| 86 | + } | |
| 87 | + | |
| 88 | + function buildSoloRun({ companyId = nextCompany(), maxConcurrency = null } = {}) { | |
| 89 | + ensureAgents(['solo']); | |
| 90 | + const g = wdb.createWorkGraph({ companyId, nodes: ['solo'], edges: [] }); | |
| 91 | + expect(g.errors || []).toEqual([]); | |
| 92 | + const run = wdb.createRun({ | |
| 93 | + graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual', maxConcurrency, | |
| 94 | + }); | |
| 95 | + engine.markRunReady(run.id); | |
| 96 | + return { runId: run.id, companyId }; | |
| 97 | + } | |
| 98 | + | |
| 99 | + it('未注入 billingGateFn 时零影响:checkBilling 恒 ok,节点正常跑完', async () => { | |
| 100 | + const { runId } = buildSoloRun(); | |
| 101 | + engine.setAgentExecutor(async () => ({ status: 'success' })); | |
| 102 | + engine.startRun(runId); | |
| 103 | + const res = await engine.advanceOne(runId); | |
| 104 | + expect(res.ok).toBe(true); | |
| 105 | + expect(wdb.getRun(runId).run_status).toBe('completed'); | |
| 106 | + }); | |
| 107 | + | |
| 108 | + it('advanceOne:欠费时挂起整棵树,reason=billing-arrears,block_reason 落库', async () => { | |
| 109 | + const { runId } = buildSoloRun(); | |
| 110 | + engine.setAgentExecutor(async () => ({ status: 'success' })); // 不该被调到 | |
| 111 | + engine.setBillingGate(() => true); // 恒欠费 | |
| 112 | + engine.startRun(runId); | |
| 113 | + | |
| 114 | + const res = await engine.advanceOne(runId); | |
| 115 | + expect(res.ok).toBe(false); | |
| 116 | + expect(res.reason).toBe('billing-arrears'); | |
| 117 | + expect(res.billing).toEqual({ ok: false, inArrears: true }); | |
| 118 | + | |
| 119 | + const run = wdb.getRun(runId); | |
| 120 | + expect(run.run_status).toBe('blocked'); | |
| 121 | + expect(run.block_reason).toBe('billing-arrears'); | |
| 122 | + // 欠费门跟 checkBudget 位置一致——都在「拾到 ready 节点」之后、真正 claim(ready→running | |
| 123 | + // 的条件更新)之前查,所以挂起时节点还停在 ready,没有被 claim(更没有被结算成任何终态)。 | |
| 124 | + expect(wdb.getRunState(runId).nodes[0].status).toBe('ready'); | |
| 125 | + }); | |
| 126 | + | |
| 127 | + it('advanceBatch(maxConcurrency>1):欠费时同样挂起,reason=billing-arrears', async () => { | |
| 128 | + const companyId = nextCompany(); | |
| 129 | + ensureAgents(['entry', 'a', 'b']); | |
| 130 | + const g = wdb.createWorkGraph({ | |
| 131 | + companyId, | |
| 132 | + nodes: ['entry', 'a', 'b'], | |
| 133 | + edges: [{ from: 'entry', to: 'a' }, { from: 'entry', to: 'b' }], // 单入口,fan-out 出 a/b | |
| 134 | + }); | |
| 135 | + expect(g.errors || []).toEqual([]); | |
| 136 | + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' }); | |
| 137 | + engine.markRunReady(run.id); | |
| 138 | + engine.setAgentExecutor(async () => ({ status: 'success' })); | |
| 139 | + engine.startRun(run.id); | |
| 140 | + await engine.advanceOne(run.id); // entry → success,点亮 a/b(这一步不欠费) | |
| 141 | + | |
| 142 | + engine.setBillingGate(() => ({ inArrears: true })); // 现在才欠费 | |
| 143 | + const res = await engine.advanceBatch(run.id, { maxConcurrency: 3 }); | |
| 144 | + expect(res.ok).toBe(false); | |
| 145 | + expect(res.reason).toBe('billing-arrears'); | |
| 146 | + expect(wdb.getRun(run.id).run_status).toBe('blocked'); | |
| 147 | + expect(wdb.getRun(run.id).block_reason).toBe('billing-arrears'); | |
| 148 | + }); | |
| 149 | + | |
| 150 | + it('startRunSync「启动」清空 block_reason:欠费挂起 → 手动点启动 → block_reason 变 null', async () => { | |
| 151 | + const { runId } = buildSoloRun(); | |
| 152 | + engine.setAgentExecutor(async () => ({ status: 'success' })); | |
| 153 | + engine.setBillingGate(() => true); | |
| 154 | + engine.startRun(runId); | |
| 155 | + await engine.advanceOne(runId); | |
| 156 | + expect(wdb.getRun(runId).block_reason).toBe('billing-arrears'); | |
| 157 | + | |
| 158 | + engine.setBillingGate(null); // 欠费清零 | |
| 159 | + engine.startRun(runId); // 人工点「启动」 | |
| 160 | + expect(wdb.getRun(runId).block_reason).toBeNull(); | |
| 161 | + expect(wdb.getRun(runId).run_status).toBe('running'); | |
| 162 | + }); | |
| 163 | + | |
| 164 | + it('resumeBillingClearedRuns:欠费清零的 run 被自动恢复并跑完,仍欠费的原地不动', async () => { | |
| 165 | + const { runId: cleared } = buildSoloRun({ companyId: 'p4t5-cleared' }); | |
| 166 | + const { runId: stillArrears } = buildSoloRun({ companyId: 'p4t5-still' }); | |
| 167 | + | |
| 168 | + engine.setAgentExecutor(async () => ({ status: 'success' })); | |
| 169 | + engine.setBillingGate(() => true); | |
| 170 | + engine.startRun(cleared); | |
| 171 | + await engine.advanceOne(cleared); | |
| 172 | + engine.startRun(stillArrears); | |
| 173 | + await engine.advanceOne(stillArrears); | |
| 174 | + expect(wdb.getRun(cleared).run_status).toBe('blocked'); | |
| 175 | + expect(wdb.getRun(stillArrears).run_status).toBe('blocked'); | |
| 176 | + | |
| 177 | + // 只有 `cleared` 那家公司欠费清零了,`stillArrears` 还欠着 | |
| 178 | + engine.setBillingGate((companyId) => companyId === 'p4t5-still'); | |
| 179 | + | |
| 180 | + const sweep = await engine.resumeBillingClearedRuns(); | |
| 181 | + expect(sweep.scanned).toBe(2); | |
| 182 | + expect(sweep.resumed).toEqual([cleared]); | |
| 183 | + expect(sweep.stillBlocked).toEqual([stillArrears]); | |
| 184 | + | |
| 185 | + expect(wdb.getRun(cleared).run_status).toBe('running'); // 恢复了,节点原地继续跑(手动模式停在 running) | |
| 186 | + expect(wdb.getRun(cleared).block_reason).toBeNull(); | |
| 187 | + expect(wdb.getRun(stillArrears).run_status).toBe('blocked'); // 纹丝不动 | |
| 188 | + expect(wdb.getRun(stillArrears).block_reason).toBe('billing-arrears'); | |
| 189 | + }); | |
| 190 | + | |
| 191 | + it('resumeBillingClearedRuns 支持按 companyId 只扫一家', async () => { | |
| 192 | + const { runId: a } = buildSoloRun({ companyId: 'p4t5-scope-a' }); | |
| 193 | + const { runId: b } = buildSoloRun({ companyId: 'p4t5-scope-b' }); | |
| 194 | + engine.setAgentExecutor(async () => ({ status: 'success' })); | |
| 195 | + engine.setBillingGate(() => true); | |
| 196 | + engine.startRun(a); await engine.advanceOne(a); | |
| 197 | + engine.startRun(b); await engine.advanceOne(b); | |
| 198 | + | |
| 199 | + engine.setBillingGate(null); // 两家都清零了,但这次只扫 a | |
| 200 | + const sweep = await engine.resumeBillingClearedRuns({ companyId: 'p4t5-scope-a' }); | |
| 201 | + expect(sweep.scanned).toBe(1); | |
| 202 | + expect(sweep.resumed).toEqual([a]); | |
| 203 | + expect(wdb.getRun(a).run_status).toBe('running'); | |
| 204 | + expect(wdb.getRun(b).run_status).toBe('blocked'); // 没被这次扫描碰到 | |
| 205 | + }); | |
| 206 | + | |
| 207 | + it('advanceBatch 排序回归:候选先查、budget/billing 后查——已收敛(零 ready 节点)' | |
| 208 | + + '只是恰好预算/欠费也耗尽的 run,正确收敛到 completed,不会被误判成挂起', async () => { | |
| 209 | + const { runId } = buildSoloRun(); | |
| 210 | + engine.startRun(runId); | |
| 211 | + // 绕开引擎直接把节点写成 success(不经过 settleNode,run_status 就不会被顺带收敛), | |
| 212 | + // 构造「run_status 还是 running,但已经没有 ready/running 节点」这个状态—— | |
| 213 | + // 同款手法 graph-engine-advance-batch.test.js 已经在用。 | |
| 214 | + const node = wdb.getRunState(runId).nodes[0]; | |
| 215 | + expect(wdb.updateNodeStatus(node.id, 'ready', 'running')).toBe(1); | |
| 216 | + expect(wdb.updateNodeStatus(node.id, 'running', 'success')).toBe(1); | |
| 217 | + | |
| 218 | + // 预算与欠费都打成「耗尽/欠费」——如果 advanceBatch 在查候选之前就先查这两道门, | |
| 219 | + // 会误判成 budget-exhausted 或 billing-arrears 并把这个其实已经跑完的 run 挂起。 | |
| 220 | + wdb.getDb().prepare(`UPDATE graph_runs SET budget_json = ? WHERE id = ?`) | |
| 221 | + .run(JSON.stringify({ total_agent_calls: 0, spent: { agent_calls: 999 } }), runId); | |
| 222 | + engine.setBillingGate(() => true); | |
| 223 | + | |
| 224 | + const res = await engine.advanceBatch(runId, { maxConcurrency: 3 }); | |
| 225 | + expect(res.ok).toBe(false); | |
| 226 | + expect(res.reason).toBe('no-ready-node'); | |
| 227 | + expect(wdb.getRun(runId).run_status).toBe('completed'); | |
| 228 | + expect(wdb.getRun(runId).block_reason).toBeNull(); | |
| 229 | + }); | |
| 230 | +}); | ... | ... |