/** * Unit Tests — graph-engine.js(P3/P4 T10) * * 逐条对应 docs/todo/system-design/27-p3-p4-work-graph-console.md §六 验收标准: * 阶段 A:2(手动逐节点)/ 6(org graph 走同一套代码路径)/ 7(subgraph 起子 run) * 阶段 B:8(单边硬顶)/ 9(fail-fallback-edge 校验)/ 9b(多边不架空熔断)/ * 9c(infra_flake 不烧计数)/ 9d(三值透传)/ 10(预算耗尽整棵树挂起)/ 11(三层级联) * 外加 §四.A.4 非法转换在服务层被拒、§四.A.6 advanceOne 幂等。 * * 隔离手法与 graph-db.test.js / org-graph-service.test.js 一致:先设 GRAPH_DB_PATH 再 require, * 真实的 data/graph.sqlite 全程不被碰到。 * run 目录(stream.log / timeline.ndjson)同样隔离:先设 GRAPH_RUNS_DIR 指到临时目录再 require * graph-run-sse(它在模块加载时读一次这个变量),跑完删掉;仓库 data/companies/ 不留残留。 * * agent 调用一律走 setAgentExecutor 注入的假 executor,子图规划走 setPlanWorkGraph 注入的假规划, * 全程不起 Claude CLI。 * * // 假设(R-16待定案): 归因保持 org node 粒度封装,org 层回退只退到 org node,不跨层寻址子节点。 * // 假设(R-16待定案): 归因节点名保留 validator-reflection,不改 validator-triager。 */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; describe('graph-engine', () => { let graphDb; let orgSvc; let wdb; let engine; let runsDir; const TEST_DB_PATH = '/tmp/test-graph-engine.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-engine-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', // graph-run-sse 在模块顶部快照 GRAPH_RUNS_DIR,必须连它一起重新 require, // 否则别的测试文件先加载过它时会拿着 null 往仓库 data/companies/ 写 '../../services/graph-run-sse', '../../services/graph-engine', ]) { delete require.cache[require.resolve(m)]; } graphDb = require('../../services/graph-db'); orgSvc = require('../../services/org-graph-service'); 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(); // graph_runs.parent_node_run_id ↔ graph_run_nodes.run_id 是循环外键,删除期间先关掉 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 org_graph_edges; DELETE FROM org_graph_nodes; DELETE FROM system_agents; `); db.pragma('foreign_keys = ON'); engine.setAgentExecutor(null); // 回默认实现,各用例自己再注入 }); // ────────────────────────────────────────────────────────────── // 夹具 // ────────────────────────────────────────────────────────────── let seq = 0; /** 每个用例一个独立 company:idx_graph_runs_one_active_root 是按 company 约束的 */ const nextCompany = () => `co-${++seq}`; /** work_graph_nodes.system_agent_id 有外键,先把本体登记上 */ function ensureAgents(ids) { const db = wdb.getDb(); const now = 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}`, now, now); } /** 建一张 work graph 定义 + 一次 run,并推到 ready */ function buildWorkRun({ companyId, nodes, edges, mode = 'manual', budget = null }) { ensureAgents(nodes.map((n) => (typeof n === 'string' ? n : n.systemAgentId || n.nodeName))); const g = wdb.createWorkGraph({ companyId, nodes, edges }); expect(g.errors || []).toEqual([]); const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: mode, budget, }); engine.markRunReady(run.id); return { graphId: g.id, runId: run.id, nodeIds: g.nodeIds }; } /** * 假 executor。behaviors: {nodeName: result | (nthCall, ctx) => result} * 返回 calls 数组,供断言「第几次调用了谁」「熔断之后有没有又调 agent」。 */ function fakeExecutor(behaviors) { const calls = []; engine.setAgentExecutor(async (ctx) => { const nth = calls.filter((c) => c.node === ctx.nodeName).length + 1; calls.push({ node: ctx.nodeName, attempt: ctx.attempt, remediationNote: ctx.remediationNote }); const b = behaviors[ctx.nodeName]; const out = typeof b === 'function' ? b(nth, ctx) : b; return out || { status: 'success' }; }); return calls; } const nodeByName = (runId, name) => wdb.getRunState(runId).nodes.find((n) => n.name === name); const statusMap = (runId) => Object.fromEntries(wdb.getRunState(runId).nodes.map((n) => [n.name, n.status])); const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; // ────────────────────────────────────────────────────────────── // §四.A.4 状态机:非法转换在服务层被拒 // ────────────────────────────────────────────────────────────── describe('状态机(§四.A.4)', () => { it('对 success 节点直接推进(success → running)被服务层拒绝并抛 InvalidTransitionError', async () => { const companyId = nextCompany(); const { runId } = buildWorkRun({ companyId, nodes: ['a', 'b'], edges: [{ from: 'a', to: 'b' }] }); fakeExecutor({ a: { status: 'success' }, b: { status: 'success' } }); engine.startRun(runId); await engine.nextStep(runId); // a → success const a = nodeByName(runId, 'a'); expect(a.status).toBe('success'); // 转换表里 success 只有一条出路:人工「重试」打回 ready expect(wdb.canTransitionNode('success', 'running')).toBe(false); expect(wdb.canTransitionNode('success', 'ready')).toBe(true); expect(() => wdb.updateNodeStatus(a.id, null, 'running')).toThrow(wdb.InvalidTransitionError); try { wdb.updateNodeStatus(a.id, 'success', 'running'); throw new Error('应当抛错'); } catch (err) { expect(err.name).toBe('InvalidTransitionError'); expect(err.from).toBe('success'); expect(err.to).toBe('running'); } // 被拒之后节点没被改动 expect(nodeByName(runId, 'a').status).toBe('success'); }); it('run 级非法转换同样被拒(completed → running)', async () => { const companyId = nextCompany(); const { runId } = buildWorkRun({ companyId, nodes: ['solo'], edges: [] }); fakeExecutor({ solo: { status: 'success' } }); engine.startRun(runId); await engine.nextStep(runId); expect(wdb.getRun(runId).run_status).toBe('completed'); expect(wdb.canTransitionRun('completed', 'running')).toBe(false); expect(() => wdb.updateRunStatus(runId, 'completed', 'running')).toThrow( wdb.InvalidTransitionError ); }); }); // ────────────────────────────────────────────────────────────── // §四.A.6 幂等 // ────────────────────────────────────────────────────────────── describe('advanceOne 幂等(§四.A.6)', () => { it('并发触发两次:只执行一次,第二次返回当前状态而不报错', async () => { const companyId = nextCompany(); const { runId } = buildWorkRun({ companyId, nodes: ['a', 'b'], edges: [{ from: 'a', to: 'b' }] }); const gate = deferred(); const calls = []; engine.setAgentExecutor(async (ctx) => { calls.push(ctx.nodeName); await gate.promise; // 卡住第一次调用,让第二次 advanceOne 在它没结算时进来 return { status: 'success' }; }); engine.startRun(runId); const first = engine.advanceOne(runId); // 抢到 a,卡在 executor 里 const second = engine.advanceOne(runId); // 同一时刻的第二次触发 gate.resolve(); const [r1, r2] = await Promise.all([first, second]); // 只真正执行了一次 expect(calls).toEqual(['a']); expect(r1.ok).toBe(true); expect(r1.node.name).toBe('a'); // 第二次不报错、不重复执行,只是回报当前状态 expect(r2.ok).toBe(false); expect(['no-ready-node', 'taken-by-other']).toContain(r2.reason); expect(r2.runStatus).toBe('running'); expect(nodeByName(runId, 'a').status).toBe('success'); expect(nodeByName(runId, 'a').attempt).toBe(0); }); it('底层条件更新就是那道闸:ready → running 第二次返回受影响行数 0', async () => { const companyId = nextCompany(); const { runId } = buildWorkRun({ companyId, nodes: ['a'], edges: [] }); engine.startRun(runId); const a = nodeByName(runId, 'a'); expect(a.status).toBe('ready'); expect(wdb.updateNodeStatus(a.id, 'ready', 'running')).toBe(1); expect(wdb.updateNodeStatus(a.id, 'ready', 'running')).toBe(0); // 结算也只生效一次:第二次 settleNode 返回 taken-by-other 而不是重复推进 const s1 = engine.settleNode(runId, a.id, { status: 'success' }); expect(s1.ok).toBe(true); const s2 = engine.settleNode(runId, a.id, { status: 'success' }); expect(s2.ok).toBe(false); expect(s2.reason).toBe('taken-by-other'); }); }); // ────────────────────────────────────────────────────────────── // §六 验收 2:手动模式逐节点推进,每次只推进一个 // ────────────────────────────────────────────────────────────── describe('手动模式(§六 验收 2)', () => { it('连续「下一步」逐节点推进到 completed,每次只推进一个节点', async () => { const companyId = nextCompany(); const chain = ['n1', 'n2', 'n3', 'n4']; const { runId } = buildWorkRun({ companyId, nodes: chain, edges: [ { from: 'n1', to: 'n2' }, { from: 'n2', to: 'n3' }, { from: 'n3', to: 'n4' }, ], }); const calls = fakeExecutor(Object.fromEntries(chain.map((n) => [n, { status: 'success' }]))); const started = engine.startRun(runId); expect(started.runStatus).toBe('running'); expect(started.driver).toBeUndefined(); // manual 模式不自我调度 expect(statusMap(runId)).toEqual({ n1: 'ready', n2: 'pending', n3: 'pending', n4: 'pending' }); for (let i = 0; i < chain.length; i++) { const res = await engine.nextStep(runId); expect(res.ok).toBe(true); expect(res.node.name).toBe(chain[i]); // 每次点击只发生一次 agent 调用 expect(calls).toHaveLength(i + 1); const map = statusMap(runId); expect(chain.slice(0, i + 1).every((n) => map[n] === 'success')).toBe(true); // 下游最多只有紧邻的一个被点亮,其余仍是 pending(没有因为 ready 就自动继续) if (i + 1 < chain.length) { expect(map[chain[i + 1]]).toBe('ready'); expect(chain.slice(i + 2).every((n) => map[n] === 'pending')).toBe(true); expect(wdb.getRun(runId).run_status).toBe('running'); } } expect(wdb.getRun(runId).run_status).toBe('completed'); expect(calls.map((c) => c.node)).toEqual(chain); }); }); // ────────────────────────────────────────────────────────────── // §六 验收 6:org graph 与 work graph 走同一套代码路径(R-15.4 核心验收点) // ────────────────────────────────────────────────────────────── describe('引擎泛化:org graph 与 work graph 同一套代码路径(§六 验收 6 / R-15.4)', () => { /** 每个 org node 的 work graph:一个 agent_call 节点,名字带 org node 前缀 */ function planSingleNodeGraph(companyId) { engine.setPlanWorkGraph(async (ctx) => { const worker = `${ctx.nodeName}-worker`; ensureAgents([worker]); const g = wdb.createWorkGraph({ companyId, nodes: [worker], edges: [] }); expect(g.errors || []).toEqual([]); return { workGraphId: g.id }; }); } it('graph_kind=org 的 run 用同一批 markRunReady / startRun / advanceOne 跑到 completed', async () => { const companyId = nextCompany(); const ORG = ['org-planner', 'org-coder', 'org-validator']; expect( orgSvc.writeGraph(companyId, { nodes: ORG, edges: [ { from: 'org-planner', to: 'org-coder', condition: 'success' }, { from: 'org-coder', to: 'org-validator', condition: 'success' }, ], }).ok ).toBe(true); planSingleNodeGraph(companyId); const calls = fakeExecutor({}); // 缺省全 success // org run 的 graph_id 就是 company_id(§四.A.2) const run = wdb.createRun({ graphKind: 'org', graphId: companyId, companyId, executionMode: 'auto', }); engine.markRunReady(run.id); const started = engine.startRun(run.id, { mode: 'auto' }); await started.driver; const state = wdb.getRunState(run.id); expect(state.run.graph_kind).toBe('org'); expect(state.run.run_status).toBe('completed'); // getRunState 的节点顺序是 ORDER BY created_at, id,同一次 createRun 里 created_at 相同、 // id 是随机 hex,所以顺序本身不保证——按名字排序比较 expect(state.nodes.map((n) => n.name).sort()).toEqual([...ORG].sort()); expect(state.nodes.every((n) => n.status === 'success')).toBe(true); // org 节点全部是 subgraph executor(§四.D.0),各自起了一张子 run expect(state.nodes.every((n) => n.nodeKind === 'subgraph')).toBe(true); expect(state.nodes.every((n) => Boolean(n.childRunId))).toBe(true); // 子 run 与父 run 在同一张 graph_runs 表、同一棵树上,只是 graph_kind 不同 const tree = wdb.listRunTree(run.id); expect(tree).toHaveLength(4); const children = tree.filter((r) => r.id !== run.id); expect(children.every((r) => r.graph_kind === 'work')).toBe(true); expect(children.every((r) => r.run_status === 'completed')).toBe(true); expect(children.every((r) => r.root_run_id === run.id)).toBe(true); expect(calls.map((c) => c.node).sort()).toEqual(ORG.map((n) => `${n}-worker`).sort()); }); it('org run 的「下一步」语义与 work run 完全一致(同样每次只推进一个节点)', async () => { const companyId = nextCompany(); expect( orgSvc.writeGraph(companyId, { nodes: ['org-planner', 'org-coder'], edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success' }], }).ok ).toBe(true); planSingleNodeGraph(companyId); fakeExecutor({}); const run = wdb.createRun({ graphKind: 'org', graphId: companyId, companyId }); engine.markRunReady(run.id); engine.startRun(run.id); // 第一步:只把 org-planner 起成 subgraph,父节点停在 running 等子 run const s1 = await engine.nextStep(run.id); expect(s1.ok).toBe(true); expect(s1.reason).toBe('awaiting-child-run'); expect(statusMap(run.id)).toEqual({ 'org-planner': 'running', 'org-coder': 'pending' }); expect(wdb.listRunTree(run.id)).toHaveLength(2); // 第二步作用在子 run 上:子 run 收敛后回写父节点 success,父层才点亮 org-coder const child = wdb.listRunTree(run.id).find((r) => r.parent_run_id === run.id); const s2 = await engine.nextStep(child.id); expect(s2.ok).toBe(true); expect(wdb.getRun(child.id).run_status).toBe('completed'); expect(statusMap(run.id)).toEqual({ 'org-planner': 'success', 'org-coder': 'ready' }); }); }); // ────────────────────────────────────────────────────────────── // §六 验收 7 / 9d:subgraph 节点 + 三值透传 // ────────────────────────────────────────────────────────────── describe('subgraph 节点与三值透传(§四.D.0 / §六 验收 7、9d)', () => { const NOTE = '规划阶段给的输入约束不足,请补齐验收口径再重做'; it('起子 run、child_run_id 回填、子 run 收敛后父节点拿到 success', async () => { const companyId = nextCompany(); const { runId } = buildWorkRun({ companyId, nodes: [{ nodeName: 'wrapper', nodeKind: 'subgraph' }, 'after'], edges: [{ from: 'wrapper', to: 'after' }], mode: 'auto', }); let childGraphId = null; engine.setPlanWorkGraph(async (ctx) => { expect(ctx.nodeName).toBe('wrapper'); ensureAgents(['inner-a', 'inner-b']); const g = wdb.createWorkGraph({ companyId, nodes: ['inner-a', 'inner-b'], edges: [{ from: 'inner-a', to: 'inner-b' }], }); childGraphId = g.id; return { workGraphId: g.id }; }); fakeExecutor({}); await engine.startRun(runId, { mode: 'auto' }).driver; const wrapper = nodeByName(runId, 'wrapper'); expect(wrapper.status).toBe('success'); expect(wrapper.childRunId).toBeTruthy(); const child = wdb.getRun(wrapper.childRunId); expect(child.graph_id).toBe(childGraphId); expect(child.parent_run_id).toBe(runId); expect(child.parent_node_run_id).toBe(wrapper.id); expect(child.root_run_id).toBe(runId); expect(child.run_status).toBe('completed'); expect(nodeByName(runId, 'after').status).toBe('success'); expect(wdb.getRun(runId).run_status).toBe('completed'); }); it('子 run 内部归因失败:父节点同时拿到 fail + fail_reason + remediation_note 三个值并据此选边', async () => { const companyId = nextCompany(); // 父图:coder → validator(subgraph);validator 的两条 fail 出边靠 fail_reason 区分, // 兜底边(fail_reason 为空)指向 escalate,精确匹配边回退到 coder。 const { runId, graphId } = buildWorkRun({ companyId, nodes: ['coder', { nodeName: 'validator', nodeKind: 'subgraph' }, 'escalate'], edges: [ { from: 'coder', to: 'validator' }, { from: 'validator', to: 'coder', condition: 'fail', failReason: 'input_underspecified', edgeKind: 'backward' }, { from: 'validator', to: 'escalate', condition: 'fail' }, // 兜底边 ], mode: 'auto', }); engine.setPlanWorkGraph(async () => { ensureAgents(['impl', 'validator-reflection']); const g = wdb.createWorkGraph({ companyId, nodes: ['impl', 'validator-reflection'], edges: [ { from: 'impl', to: 'validator-reflection' }, // 归因节点判失败 → 打回 impl(子图内部的回退边,兜底边即它本身) { from: 'validator-reflection', to: 'impl', condition: 'fail', edgeKind: 'backward' }, ], }); expect(g.errors || []).toEqual([]); return { workGraphId: g.id }; }); fakeExecutor({ impl: { status: 'success' }, // 归因节点:R-16 要求它同时产出 fail_reason 与 remediation_note 'validator-reflection': { status: 'fail', failReason: 'input_underspecified', remediationNote: NOTE, }, escalate: { status: 'success' }, }); await engine.startRun(runId, { mode: 'auto' }).driver; // 子 run 因内部硬顶收敛到 blocked const childRunId = nodeByName(runId, 'validator').childRunId; expect(childRunId).toBeTruthy(); expect(wdb.getRun(childRunId).run_status).toBe('blocked'); // ① fail:父层沿 fail 边路由;② fail_reason:选中的是精确匹配那条(回 coder),不是兜底边 const parentLoops = wdb.listLoopRecords(runId); expect(parentLoops).toHaveLength(1); const def = wdb.readDefGraph('work', graphId); const exactEdge = def.edges.find((e) => e.condition === 'fail' && e.failReason === 'input_underspecified'); expect(parentLoops[0].edge_id).toBe(exactEdge.id); // 兜底边没被走:escalate 从未被执行 expect(nodeByName(runId, 'escalate').status).not.toBe('success'); // ③ remediation_note:子图归因节点的判词穿透子 run 边界到了父层 expect(parentLoops[0].remediation_note).toBe(NOTE); expect(parentLoops[0].loop_count).toBe(1); // 回退目标 coder 被重新点亮并 attempt+1 expect(nodeByName(runId, 'coder').attempt).toBe(1); }); /** * 【本用例的期望在第六轮按 R-17.1 重写过——全套用例里唯一一条】 * * 依据:27-requirement.md R-17.1「归因精确到 work graph node 层」定案,**推翻**了原先的 * org node 粒度封装。原期望是「回退后起一张**新**子 run(整张 work graph 重跑)」, * 定案后的语义是「**复用**旧子 run,只重置被点名的那个 node,attempt+1」—— * 语义本身变了,不是实现退化,所以这一条的期望必须跟着改(R-17.1 明确这是唯一允许的情形)。 * * 新期望验的正是「复用」这四件事: * ① 同一个 child_run_id(子 run 复用,不新建) * ② work graph 定义复用(workgraph-planning 只被调一次,不重新规划) * ③ 只有被点名的 node 被重置且 attempt 递增 * ④ contract_hash unchanged 的下游一次都没被重跑 * 外加原用例那条底线断言(不许死锁)原样保留。 */ it('回退后 subgraph 节点复用同一张子 run,只重置被点名的 node(R-17.1:跨层定点重置)', async () => { const companyId = nextCompany(); // 父图 = org 层的形状:coder / validator 都是 subgraph 节点,validator 判失败退回 coder const { runId } = buildWorkRun({ companyId, nodes: [ { nodeName: 'coder', nodeKind: 'subgraph' }, { nodeName: 'validator', nodeKind: 'subgraph' }, ], edges: [ { from: 'coder', to: 'validator' }, { from: 'validator', to: 'coder', condition: 'fail', edgeKind: 'backward' }, // 兜底边即它本身 ], mode: 'auto', }); const planned = {}; engine.setPlanWorkGraph(async (ctx) => { planned[ctx.nodeName] = (planned[ctx.nodeName] || 0) + 1; ensureAgents(['impl-a', 'impl-b', 'validator-triager']); const g = ctx.nodeName === 'coder' ? wdb.createWorkGraph({ companyId, nodes: ['impl-a', 'impl-b'], edges: [{ from: 'impl-a', to: 'impl-b' }], }) : wdb.createWorkGraph({ companyId, nodes: ['validator-triager'], edges: [] }); expect(g.errors || []).toEqual([]); return { workGraphId: g.id }; }); const calls = fakeExecutor({ 'impl-a': { status: 'success' }, 'impl-b': { status: 'success' }, // 归因:根因在 coder 那张 work graph 的 impl-a 上(跨层地址) 'validator-triager': { status: 'fail', failReason: 'impl_bug', failNodeRef: { orgNode: 'coder', workNode: 'impl-a' }, remediationNote: 'impl-a 少处理了空输入', }, }); await engine.startRun(runId, { mode: 'auto' }).driver; // ① 子 run 复用:coder 名下**始终只有一张**子 run const coder = nodeByName(runId, 'coder'); const coderChildren = wdb.listRunTree(runId).filter((r) => r.parent_node_run_id === coder.id); expect(coderChildren).toHaveLength(1); expect(coder.childRunId).toBe(coderChildren[0].id); // ② work graph 定义复用:两个 org node 各自只规划过一次,回退不重画图 expect(planned).toEqual({ coder: 1, validator: 1 }); // ③ 只有被点名的 impl-a 被重置并重跑,attempt 跟着递增 const inner = Object.fromEntries( wdb.getRunState(coder.childRunId).nodes.map((n) => [n.name, n]) ); expect(inner['impl-a'].attempt).toBeGreaterThan(0); expect(calls.filter((c) => c.node === 'impl-a').length).toBeGreaterThan(1); // 判词随定点重置落到被点名的那一行,重跑时读得回来 expect(calls.filter((c) => c.node === 'impl-a' && c.remediationNote === 'impl-a 少处理了空输入').length) .toBeGreaterThanOrEqual(1); // ④ contract_hash unchanged(这两个节点都没产 contract.json)→ 下游 impl-b 一次都没重跑 expect(inner['impl-b'].attempt).toBe(0); expect(calls.filter((c) => c.node === 'impl-b')).toHaveLength(1); // 账本记下了这次回退的落点(控制台据此显示回退粒度) const records = wdb.listLoopRecords(runId); expect(records.length).toBeGreaterThan(0); const ref = JSON.parse(records[0].target_node_ref); expect(ref).toMatchObject({ orgNode: 'coder', workNode: 'impl-a', childRunId: coder.childRunId, nodeRunId: inner['impl-a'].id, }); // 底线(原用例保留):不许死锁 —— 节点不能永远停在 running,root run 要收敛到终态 expect(nodeByName(runId, 'validator').status).not.toBe('running'); expect(coder.status).not.toBe('running'); expect(wdb.getRun(runId).run_status).toBe('blocked'); // 撞熔断硬顶后收敛等人工 }); it('契约变了就按四分类级联:跨层定点重置的下游被打回重跑(R-17.1 复用 §四.C 的判据)', async () => { const companyId = nextCompany(); const { runId } = buildWorkRun({ companyId, nodes: [ { nodeName: 'coder', nodeKind: 'subgraph' }, { nodeName: 'validator', nodeKind: 'subgraph' }, ], edges: [ { from: 'coder', to: 'validator' }, { from: 'validator', to: 'coder', condition: 'fail', edgeKind: 'backward' }, ], mode: 'auto', }); engine.setPlanWorkGraph(async (ctx) => { ensureAgents(['impl-a', 'impl-b', 'validator-triager']); const g = ctx.nodeName === 'coder' ? wdb.createWorkGraph({ companyId, nodes: ['impl-a', 'impl-b'], edges: [{ from: 'impl-a', to: 'impl-b' }], }) : wdb.createWorkGraph({ companyId, nodes: ['validator-triager'], edges: [] }); return { workGraphId: g.id }; }); // impl-a 每一轮都改写自己的 contract.json(exports 的值每轮不同)→ hash 每轮都变 let round = 0; const calls = []; engine.setAgentExecutor(async (ctx) => { calls.push({ node: ctx.nodeName, attempt: ctx.attempt }); if (ctx.nodeName === 'impl-a') { round += 1; const dir = path.join(runsDir, ctx.rootRunId, 'nodes', ctx.nodeRunId); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( path.join(dir, 'contract.json'), JSON.stringify({ exports: { api: `v${round}` } }), 'utf8', ); return { status: 'success' }; } if (ctx.nodeName === 'validator-triager') { return { status: 'fail', failReason: 'impl_bug', failNodeRef: { orgNode: 'coder', workNode: 'impl-a' }, }; } return { status: 'success' }; }); await engine.startRun(runId, { mode: 'auto' }).driver; const coder = nodeByName(runId, 'coder'); const inner = Object.fromEntries( wdb.getRunState(coder.childRunId).nodes.map((n) => [n.name, n]) ); // contract 每轮都变 → added/changed 非空 → impl-b 被打回 pending 并重跑 expect(inner['impl-b'].attempt).toBeGreaterThan(0); expect(calls.filter((c) => c.node === 'impl-b').length).toBeGreaterThan(1); // 仍然是同一张子 run(级联发生在子 run 内部,不是重开一张) expect(wdb.listRunTree(runId).filter((r) => r.parent_node_run_id === coder.id)).toHaveLength(1); }); it('跨层地址解析不了时退化成整张图重跑,不新建子 run、也不死锁', async () => { const companyId = nextCompany(); const { runId } = buildWorkRun({ companyId, nodes: [ { nodeName: 'coder', nodeKind: 'subgraph' }, { nodeName: 'validator', nodeKind: 'subgraph' }, ], edges: [ { from: 'coder', to: 'validator' }, { from: 'validator', to: 'coder', condition: 'fail', edgeKind: 'backward' }, ], mode: 'auto', }); engine.setPlanWorkGraph(async (ctx) => { ensureAgents(['impl-a', 'impl-b', 'validator-triager']); const g = ctx.nodeName === 'coder' ? wdb.createWorkGraph({ companyId, nodes: ['impl-a', 'impl-b'], edges: [{ from: 'impl-a', to: 'impl-b' }], }) : wdb.createWorkGraph({ companyId, nodes: ['validator-triager'], edges: [] }); return { workGraphId: g.id }; }); const calls = fakeExecutor({ 'impl-a': { status: 'success' }, 'impl-b': { status: 'success' }, // 归因点名了一个图上根本不存在的节点 → 地址解析失败 'validator-triager': { status: 'fail', failReason: 'impl_bug', failNodeRef: { orgNode: 'coder', workNode: '并不存在的节点' }, }, }); await engine.startRun(runId, { mode: 'auto' }).driver; const coder = nodeByName(runId, 'coder'); // 复用同一张子 run(定义仍然复用),但整张图作废重跑:两个节点都重跑了 expect(wdb.listRunTree(runId).filter((r) => r.parent_node_run_id === coder.id)).toHaveLength(1); expect(calls.filter((c) => c.node === 'impl-a').length).toBeGreaterThan(1); expect(calls.filter((c) => c.node === 'impl-b').length).toBeGreaterThan(1); // 账本上 target_node_ref 为空 = 「这一次退的是整张图」,控制台据此显示粒度 expect(wdb.listLoopRecords(runId).every((r) => r.target_node_ref === null)).toBe(true); // 仍然不死锁 expect(wdb.getRun(runId).run_status).toBe('blocked'); expect(wdb.getRunState(runId).nodes.every((n) => n.status !== 'running')).toBe(true); }); }); // ────────────────────────────────────────────────────────────── // §六 验收 8:单边回退硬顶 // ────────────────────────────────────────────────────────────── describe('回退熔断 · 单边硬顶(§四.D.2.1 / §六 验收 8)', () => { it('同一条 backward 边第 4 次回退不再调用任何 agent,直接 force_terminated 收敛到 blocked', async () => { const companyId = nextCompany(); const { runId, graphId } = buildWorkRun({ companyId, nodes: ['impl', 'check'], edges: [ { from: 'impl', to: 'check' }, { from: 'check', to: 'impl', condition: 'fail', edgeKind: 'backward' }, ], mode: 'auto', }); const calls = fakeExecutor({ impl: { status: 'success' }, check: { status: 'fail', remediationNote: '再修一遍' }, }); await engine.startRun(runId, { mode: 'auto' }).driver; const records = wdb.listLoopRecords(runId).sort((a, b) => a.loop_count - b.loop_count); expect(records.map((r) => r.loop_count)).toEqual([1, 2, 3, 4]); expect(records.map((r) => r.verdict)).toEqual([ 'registered', 'registered', 'registered', 'force_terminated', ]); const def = wdb.readDefGraph('work', graphId); const backEdge = def.edges.find((e) => e.edgeKind === 'backward'); expect(records.every((r) => r.edge_id === backEdge.id)).toBe(true); expect(nodeByName(runId, 'check').status).toBe('blocked'); expect(wdb.getRun(runId).run_status).toBe('blocked'); expect(wdb.getRun(runId).backward_count).toBe(4); // 第 4 次是纯整数比较判死的:熔断之后没有再发生任何 agent 调用 expect(calls.filter((c) => c.node === 'check')).toHaveLength(4); expect(calls.filter((c) => c.node === 'impl')).toHaveLength(4); // 前三轮的判词被带给了下一轮的执行者 expect(calls.filter((c) => c.node === 'impl' && c.remediationNote === '再修一遍').length) .toBeGreaterThanOrEqual(3); }); }); // ────────────────────────────────────────────────────────────── // 39 号文档 P4「确定性通路」:回退熔断收敛时顺手开一条 draft task // ────────────────────────────────────────────────────────────── describe('回退熔断 · 确定性通路自动开新 task(39 号文档 §二 P4)', () => { it('单边硬顶 force_terminated 时,会在同一个 goal 下新开一条 draft task,report_to 继承自 goal', async () => { const companyId = nextCompany(); const goal = wdb.createGoal({ companyId, title: '测试 goal', description: '测试 goal 的描述', priority: 'P2', createdBy: 'human', reportTo: 'some-upstream-agent', }); const task = wdb.createTask({ goalId: goal.id, companyId, title: '测试 task', description: '测试 task 的描述', executionMode: 'auto', priority: 'P2', createdBy: 'human', }); const { runId } = buildWorkRun({ companyId, nodes: ['impl', 'check'], edges: [ { from: 'impl', to: 'check' }, { from: 'check', to: 'impl', condition: 'fail', edgeKind: 'backward' }, ], mode: 'auto', }); // 让这条 run 看起来是「这条 task 当前的 run」——只有 runId === rootRunId 且 // current_run_id 指回它,maybeProposeReplanTask 才会触发(避免嵌套子图重复开)。 wdb.updateTask(task.id, { currentRunId: runId }); fakeExecutor({ impl: { status: 'success' }, check: { status: 'fail', remediationNote: '反复修不好' }, }); await engine.startRun(runId, { mode: 'auto' }).driver; // 熔断收敛本身不受影响(既有断言的等价物) expect(wdb.getRun(runId).run_status).toBe('blocked'); const tasksInGoal = wdb.listCompanyTasks(companyId, { goalId: goal.id, includeArchived: true }); expect(tasksInGoal).toHaveLength(2); const created = tasksInGoal.find((t) => t.id !== task.id); expect(created).toBeTruthy(); expect(created.status).toBe('draft'); expect(created.created_by).toBe('system'); // report_to 继承自 goal(39 号文档 §四.2 已拍板),不是继承自触发它的 task expect(created.report_to).toBe('some-upstream-agent'); expect(created.description).toContain('反复修不好'); }); it('嵌套子图自己的回退熔断(runId !== rootRunId)不会新开 task——只在根 run 触发一次', async () => { // buildWorkRun 建出的是顶层 run,rootRunId 本来就等于自己;这里改用 // 「run 不是任何 task 的 current_run_id」这条更好触发的早退路径验证同一条防重复保证: // 没有匹配的 task 行,maybeProposeReplanTask 必须安静地什么都不做,不能抛错。 const companyId = nextCompany(); const { runId } = buildWorkRun({ companyId, nodes: ['impl', 'check'], edges: [ { from: 'impl', to: 'check' }, { from: 'check', to: 'impl', condition: 'fail', edgeKind: 'backward' }, ], mode: 'auto', }); fakeExecutor({ impl: { status: 'success' }, check: { status: 'fail', remediationNote: '再修一遍' }, }); await expect(engine.startRun(runId, { mode: 'auto' }).driver).resolves.not.toThrow(); expect(wdb.getRun(runId).run_status).toBe('blocked'); // 没有任何 task 挂这条 run,不应该凭空生出一条 task 来(company 下 tasks 表应仍为空) expect(wdb.listCompanyTasks(companyId, {})).toHaveLength(0); }); }); // ────────────────────────────────────────────────────────────── // §六 验收 9b:多边不架空熔断(R-16 发现的 3×N 漏洞) // ────────────────────────────────────────────────────────────── describe('回退熔断 · 多边不架空(§六 验收 9b / R-16)', () => { it('3 条回退边各自都没到 3,但 run 级 backward_count 到 5 时仍 force_terminated', async () => { const companyId = nextCompany(); const { runId } = buildWorkRun({ companyId, nodes: ['a', 'b', 'c', 'check'], edges: [ { from: 'a', to: 'b' }, { from: 'b', to: 'c' }, { from: 'c', to: 'check' }, { from: 'check', to: 'a', condition: 'fail', failReason: 'r1', edgeKind: 'backward' }, { from: 'check', to: 'b', condition: 'fail', failReason: 'r2', edgeKind: 'backward' }, { from: 'check', to: 'c', condition: 'fail', edgeKind: 'backward' }, // 兜底边 ], mode: 'auto', }); // 归因每次归到不同的边:r1 → r2 → 未知值(落兜底边)→ 循环 const rotation = ['r1', 'r2', 'no-such-reason']; const calls = fakeExecutor({ a: { status: 'success' }, b: { status: 'success' }, c: { status: 'success' }, check: (nth) => ({ status: 'fail', failReason: rotation[(nth - 1) % rotation.length] }), }); await engine.startRun(runId, { mode: 'auto' }).driver; const records = wdb.listLoopRecords(runId); // run 级计数在第 6 次回退撞顶(backward_count > 5) expect(records).toHaveLength(6); expect(wdb.getRun(runId).backward_count).toBe(6); // 单边计数全都没到硬顶(各 2 次 < 3),单靠它熔断会被架空 const perEdge = {}; for (const r of records) perEdge[r.edge_id] = Math.max(perEdge[r.edge_id] || 0, r.loop_count); expect(Object.keys(perEdge)).toHaveLength(3); expect(Math.max(...Object.values(perEdge))).toBeLessThanOrEqual(3); expect(Math.max(...Object.values(perEdge))).toBe(2); // 但 run 级计数把它兜住了 expect(records.filter((r) => r.verdict === 'force_terminated')).toHaveLength(1); const terminated = records.find((r) => r.verdict === 'force_terminated'); expect(terminated.loop_count).toBeLessThanOrEqual(3); // 不是单边撞的顶 expect(nodeByName(runId, 'check').status).toBe('blocked'); expect(wdb.getRun(runId).run_status).toBe('blocked'); // 熔断之后不再调用任何 agent expect(calls.filter((c) => c.node === 'check')).toHaveLength(6); }); }); // ────────────────────────────────────────────────────────────── // §六 验收 9c:infra_flake 不烧任何计数 // ────────────────────────────────────────────────────────────── describe('infra_flake(§四.D.2.1 末段 / §六 验收 9c)', () => { it('归因为 infra_flake 时原地重跑,loop_records 不增行、backward_count 不递增', async () => { const companyId = nextCompany(); const { runId } = buildWorkRun({ companyId, nodes: ['w', 'v'], edges: [{ from: 'w', to: 'v' }], mode: 'auto', }); const calls = fakeExecutor({ // 第一次被基础设施抖动打断,第二次正常跑通 w: (nth) => (nth === 1 ? { status: 'fail', failReason: 'infra_flake' } : { status: 'success' }), v: { status: 'success' }, }); await engine.startRun(runId, { mode: 'auto' }).driver; expect(calls.filter((c) => c.node === 'w')).toHaveLength(2); // 原地重跑了一次 expect(nodeByName(runId, 'w').status).toBe('success'); expect(nodeByName(runId, 'w').attempt).toBe(0); // 不烧该节点的 retry 预算 expect(wdb.listLoopRecords(runId)).toHaveLength(0); // 不写回退记录 expect(wdb.getRun(runId).backward_count).toBe(0); // 不加 run 级计数 expect(wdb.getRun(runId).run_status).toBe('completed'); }); // 30 号文档 §四:infra_flake 是唯一没有熔断线的失败路径,真撞过一次确定性死循环 // (归因节点把"该重跑别的节点"的判词误判成自己的 infra_flake,每轮产出雷同、 // 永远不停)。锁住修完之后的行为:连续判 infra_flake 到硬顶就停止原地重跑,改判 blocked。 it('连续判 infra_flake 超过硬顶就停止原地重跑,改判 blocked(不是无限循环)', async () => { const companyId = nextCompany(); const { runId } = buildWorkRun({ companyId, nodes: ['w'], edges: [], mode: 'auto', }); // 每次都判 infra_flake,永不恢复——模拟真实撞见的那种"归因结论每轮雷同"死循环 const calls = fakeExecutor({ w: { status: 'fail', failReason: 'infra_flake' }, }); await engine.startRun(runId, { mode: 'auto' }).driver; // 硬顶 3:第 4 次判定 infra_flake 时熔断(1、2、3 次原地重跑,第 4 次不再重跑) expect(calls.filter((c) => c.node === 'w')).toHaveLength(4); expect(nodeByName(runId, 'w').status).toBe('blocked'); expect(nodeByName(runId, 'w').attempt).toBe(0); // 全程没烧 attempt——熔断走的不是 backward 那条账 expect(wdb.listLoopRecords(runId)).toHaveLength(0); // 也没有污染 backward 的熔断账本 expect(wdb.getRun(runId).backward_count).toBe(0); expect(wdb.getRun(runId).run_status).toBe('blocked'); }); }); // ────────────────────────────────────────────────────────────── // §六 验收 9:fail-fallback-edge 写入前校验(§三.7 第五项 / T14) // ────────────────────────────────────────────────────────────── describe('fail-fallback-edge 校验(§四.D.2.2 末段 / §六 验收 9)', () => { const NODES = ['plan', 'code', 'verify']; const withFallback = [ { from: 'plan', to: 'code' }, { from: 'code', to: 'verify' }, { from: 'verify', to: 'code', condition: 'fail', failReason: 'impl_bug', edgeKind: 'backward' }, { from: 'verify', to: 'plan', condition: 'fail', edgeKind: 'backward' }, // 兜底 ]; const withoutFallback = withFallback.slice(0, 3); // 去掉兜底边 it('有 fail 出边却无兜底边的 work graph 被拒,且一行都不落库', () => { const companyId = nextCompany(); ensureAgents(NODES); const bad = wdb.validateWorkGraph(NODES, withoutFallback); expect(bad.ok).toBe(false); expect(bad.errors.map((e) => e.rule)).toContain('fail-fallback-edge'); expect(bad.errors.find((e) => e.rule === 'fail-fallback-edge').detail).toContain('verify'); const res = wdb.createWorkGraph({ companyId, nodes: NODES, edges: withoutFallback }); expect(res.ok).toBe(false); expect(res.errors.map((e) => e.rule)).toContain('fail-fallback-edge'); expect( wdb.getDb().prepare(`SELECT COUNT(*) c FROM work_graphs WHERE company_id = ?`).get(companyId).c ).toBe(0); // 补上兜底边就通过 expect(wdb.validateWorkGraph(NODES, withFallback).ok).toBe(true); expect(wdb.createWorkGraph({ companyId, nodes: NODES, edges: withFallback }).ok).toBe(true); }); it('org 层同一条校验也要有(§三.7:org / work 两层都加)', () => { const v = orgSvc.validateGraph(NODES, withoutFallback); expect(v.ok).toBe(false); expect(v.errors.map((e) => e.rule)).toContain('fail-fallback-edge'); }); }); // ────────────────────────────────────────────────────────────── // 31-work-graph-fail-fallback-coverage 决策 A:strict 模式——除入口节点外, // 一条 fail 边都没有也算违规(不再要求"先画了别的 fail 边"这个前提)。 // 默认(非 strict)行为保持不变,上面那组用例已经覆盖;这里只测 strict:true 的新增部分。 // ────────────────────────────────────────────────────────────── describe('fail-fallback-edge 的 strict 模式(31 号文档决策 A,仅 workgraph-planning 落库路径生效)', () => { // a 是唯一入口;b 有上游可退却一条 fail 边都没有;c 有 fail 边但没兜底 const NODES = ['a', 'b', 'c']; const baseEdges = [ { from: 'a', to: 'b' }, { from: 'b', to: 'c' }, ]; it('非 strict:b 没有 fail 边不算违规(保持旧行为)', () => { const v = wdb.validateWorkGraph(NODES, baseEdges); expect(v.errors.map((e) => e.rule)).not.toContain('fail-fallback-edge'); }); it('strict:入口节点 a 没有 fail 边仍然合法', () => { const v = wdb.validateWorkGraph(NODES, baseEdges, { strictFailFallback: true }); const detail = v.errors.filter((e) => e.rule === 'fail-fallback-edge').map((e) => e.detail).join('\n'); expect(detail).not.toContain('节点 a '); }); it('strict:非入口节点 b 一条 fail 边都没有 → 违规', () => { const v = wdb.validateWorkGraph(NODES, baseEdges, { strictFailFallback: true }); expect(v.ok).toBe(false); const bEdge = v.errors.find((e) => e.rule === 'fail-fallback-edge' && e.detail.includes('节点 b ')); expect(bEdge).toBeTruthy(); expect(bEdge.detail).toContain('没有任何 fail 出边'); }); it('strict:非入口节点 c 有 fail 边但没兜底 → 违规(与旧语义相同的那半)', () => { const edges = [...baseEdges, { from: 'c', to: 'b', condition: 'fail', failReason: 'impl_bug', edgeKind: 'backward' }]; const v = wdb.validateWorkGraph(NODES, edges, { strictFailFallback: true }); const cEdge = v.errors.find((e) => e.rule === 'fail-fallback-edge' && e.detail.includes('节点 c ')); expect(cEdge).toBeTruthy(); expect(cEdge.detail).toContain('没有一条 fail_reason 为空的兜底边'); }); it('strict:b、c 都补上兜底边后通过', () => { const edges = [ ...baseEdges, { from: 'b', to: 'a', condition: 'fail', edgeKind: 'backward' }, { from: 'c', to: 'b', condition: 'fail', edgeKind: 'backward' }, ]; const v = wdb.validateWorkGraph(NODES, edges, { strictFailFallback: true }); expect(v.ok).toBe(true); }); it('strict:多入口图不豁免任何节点(single-entry 已经拒了,规则 5 不该悄悄放过)', () => { // a、b 都是入口(forward 入度为 0),任何一个都没有 fail 边 const edges = [{ from: 'a', to: 'c' }, { from: 'b', to: 'c' }]; const v = wdb.validateWorkGraph(NODES, edges, { strictFailFallback: true }); expect(v.errors.map((e) => e.rule)).toContain('single-entry'); const noFailback = v.errors.filter((e) => e.rule === 'fail-fallback-edge'); expect(noFailback.some((e) => e.detail.includes('节点 a '))).toBe(true); expect(noFailback.some((e) => e.detail.includes('节点 b '))).toBe(true); }); it('真实落库路径(createWorkGraphSafely 等价调用)默认就是 strict:' + 'workgraph-planning 交回一张没给非入口节点配兜底边的图会被拒', () => { const companyId = nextCompany(); ensureAgents(NODES); const res = wdb.createWorkGraph({ companyId, nodes: NODES, edges: baseEdges, strictFailFallback: true }); expect(res.ok).toBe(false); expect(res.errors.map((e) => e.rule)).toContain('fail-fallback-edge'); }); }); // ────────────────────────────────────────────────────────────── // §六 验收 10:预算扣完 → 整棵 run 树挂起 // ────────────────────────────────────────────────────────────── describe('任务级总预算(§四.A.3 / §六 验收 10)', () => { it('root run 预算扣完时整棵 run 树挂起,而不是某一层单独失败', async () => { const companyId = nextCompany(); const { runId } = buildWorkRun({ companyId, nodes: [{ nodeName: 'org-node', nodeKind: 'subgraph' }], edges: [], mode: 'auto', budget: { total_agent_calls: 2 }, }); engine.setPlanWorkGraph(async () => { ensureAgents(['s1', 's2', 's3']); const g = wdb.createWorkGraph({ companyId, nodes: ['s1', 's2', 's3'], edges: [{ from: 's1', to: 's2' }, { from: 's2', to: 's3' }], }); return { workGraphId: g.id }; }); const calls = fakeExecutor({}); await engine.startRun(runId, { mode: 'auto' }).driver; const root = wdb.getRun(runId); const child = wdb.listRunTree(runId).find((r) => r.parent_run_id === runId); // 预算只挂 root run,子 run 扣同一个池 expect(JSON.parse(root.budget_json).spent.agent_calls).toBe(2); expect(child.budget_json).toBeNull(); expect(calls).toHaveLength(1); // 子图规划那次不走 agentExecutor,只有 s1 真跑了 // 「扣完不是失败,是整棵 run 树挂起等人工」 expect(root.run_status).toBe('blocked'); expect(root.pause_requested).toBe(1); expect(child.run_status).toBe('running'); // 不是 failed / 不是某一层单独挂掉 expect(child.pause_requested).toBe(1); expect(wdb.listRunTree(runId).every((r) => r.pause_requested === 1)).toBe(true); // 挂起点之后的节点原样留着,人工加预算后还能接着跑 const childNodes = Object.fromEntries( wdb.getRunState(child.id).nodes.map((n) => [n.name, n.status]) ); expect(childNodes).toEqual({ s1: 'success', s2: 'ready', s3: 'pending' }); // 再推一次仍然被预算闸门挡住,并且明确回报原因 const again = await engine.advanceOne(child.id, { ignorePause: true }); expect(again.ok).toBe(false); expect(again.reason).toBe('budget-exhausted'); expect(again.budget.exhausted).toContain('total_agent_calls'); }); }); // ────────────────────────────────────────────────────────────── // §六 验收 11:三层级联暂停 // ────────────────────────────────────────────────────────────── describe('三层级联(§四.C / §六 验收 11)', () => { it('暂停 root run:子 run、孙 run 都在下一个节点边界停下', async () => { const companyId = nextCompany(); const { runId } = buildWorkRun({ companyId, nodes: [{ nodeName: 'lvl1', nodeKind: 'subgraph' }], edges: [], }); // 第二层也是 subgraph,第三层才是真 agent → 三层 run 树 engine.setPlanWorkGraph(async (ctx) => { const isSecondLevel = ctx.nodeName === 'lvl1'; const nodes = isSecondLevel ? [{ nodeName: 'lvl2', nodeKind: 'subgraph' }] : ['lvl3-worker']; ensureAgents(['lvl2', 'lvl3-worker']); const g = wdb.createWorkGraph({ companyId, nodes, edges: [] }); expect(g.errors || []).toEqual([]); return { workGraphId: g.id }; }); const calls = fakeExecutor({}); engine.startRun(runId); await engine.advanceTree(runId); // 起第二层 await engine.advanceTree(runId); // 起第三层 const tree = wdb.listRunTree(runId); expect(tree).toHaveLength(3); const child = tree.find((r) => r.parent_run_id === runId); const grand = tree.find((r) => r.parent_run_id === child.id); expect(grand).toBeTruthy(); expect(calls).toHaveLength(0); // 第三层的 agent 还没被拾取 const res = engine.pauseRun(runId); expect(res.cascaded.sort()).toEqual([child.id, grand.id].sort()); // 三层都收到软信号 for (const id of [runId, child.id, grand.id]) { expect(wdb.getRun(id).pause_requested).toBe(1); } // 手头没有 running 节点的那一层立刻落 paused;上面两层各自卡在 subgraph 节点上, // 暂停不打断已经在跑的节点(§四.C),等它到节点边界 expect(wdb.getRun(grand.id).run_status).toBe('paused'); expect(wdb.getRun(runId).run_status).toBe('running'); expect(wdb.getRun(child.id).run_status).toBe('running'); // 孙 run 在下一个节点边界停下:不再拾取新节点,也没有发生 agent 调用 const blocked = await engine.advanceOne(grand.id); expect(blocked.ok).toBe(false); expect(blocked.reason).toBe('pause-requested'); expect(calls).toHaveLength(0); expect(wdb.getRunState(grand.id).nodes[0].status).toBe('ready'); // 自动驱动器也一步都推不动 const auto = await engine.runAuto(runId); expect(auto.steps).toBe(0); expect(calls).toHaveLength(0); // 「下一步」只作用于当前层级(§四.C 定案):单步孙 run 能推进,父层不受影响 const stepped = await engine.nextStep(grand.id); expect(stepped.ok).toBe(true); expect(calls.map((c) => c.node)).toEqual(['lvl3-worker']); expect(wdb.getRun(runId).pause_requested).toBe(1); }); }); });