Commit 090ded5f234448e5a619100813f2550e2bd55d32

Authored by yibo
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>
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 +});
... ...