/** * 图执行的**真实链路 smoke** —— 全程零注入,唯一被替换的是最外层的 Claude CLI 子进程。 * * ── 这条测试还的是哪笔债 ────────────────────────────────────────────── * [27-P3/P4](../../../docs/todo/system-design/27-p3-p4-work-graph-console.md) §十 末 * 「四条硬规矩」第 2 条: * * > **测试全绿 ≠ 能跑**。P3 的引擎用例至今仍一律注入假 executor / 假 planner, * > 第 7、8、9、10 号缺陷和当时的「planWorkGraph 未接线」全部在覆盖范围之外—— * > **夹具正好绕开了缺失的那一环**。……**凡是靠注入点验的能力,都要有一条真实路径的 * > smoke 进 CI 才算验收完成**——这条欠账仍未还。 * * 在这之前,唯一走真实路径的是 `scripts/real-pm-smoke.js`(人工跑、要真 API 额度、 * 而且验的是 DevLoop 不是图执行),**没进 CI,随时会腐坏**。 * * ── 「零注入」的边界画在哪 ──────────────────────────────────────────── * 不调 `setAgentExecutor` / `setPlanWorkGraph`。链路完整走: * advanceOne → defaultAgentExecutor → AgentManager → ClaudeCliProtocol * → onAcpEvent(mock/files, session/result) → 落盘 → readNodeProduct * → settleNode → spendBudget → 边界守卫 * 只有最后那层真子进程被 `LEMONBOSS_CLAUDE_MODE=mock` 换成 fixture 回放 —— * 真 LLM 进不了 CI(要额度、要分钟级时间、输出不确定),但**「真 LLM」和「真代码路径」 * 是两件事**,这里换掉前者、保住后者。 * * ── 三条断言各盯一处「假 executor 天然绕开」的接线 ──────────────────── * ① 产物落盘:`mock/files` → `defaultAgentExecutor` 的 onAcpEvent → 节点产物目录。 * 假 executor 直接返回 `{status:'success'}`,这一环整个不存在。 * ② token 计数:`session/result.usage` → onAcpEvent 累加 → `spendBudget`。 * 这是 §六 验收 10 卡了两轮的那一维(「三个维度写在一起,只有它没有产出方」)。 * ③ 边界守卫真的开了:`boundaryGuardApplicable` 只在 `agentExecutor === defaultAgentExecutor` * 时返回 true —— 注入假 executor 的用例里它**恒为 false**,那条分支实际零覆盖。 * * ── 两支都要走 ──────────────────────────────────────────────────────── * `agent_call` 与 `subgraph` 是两条**不同的**默认实现(`defaultAgentExecutor` / * `defaultPlanWorkGraph`),各有各的 onAcpEvent 累加、各有各的守卫开关分支。 * 只覆盖前者的话,「规划子图」那半条链仍然是零真实覆盖 —— 所以下面 ④⑤ 专走 subgraph。 * * ── 隔离 ────────────────────────────────────────────────────────────── * GRAPH_DB_PATH / GRAPH_RUNS_DIR 都指临时目录,且必须在 require 之前设 * (两个模块都在顶部快照)。`LEMONBOSS_CLAUDE_MODE` 同理:ClaudeCliProtocol 的 * MOCK_MODE 是模块级 const。 */ process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-real-chain-')); process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite'); process.env.GRAPH_RUNS_DIR = path.join(TMP, 'runs'); // fixture 名 = 本体 id(图执行侧的 agent 名带公司前缀,mock 会先剥掉)。 // 用 prd-planning:本体真实存在,fixture 自带 files[](含 readNodeProduct 要读的 // attempt-{attempt}.json)与 usage。 const AGENT = 'prd-planning'; const PLANNER = 'workgraph-planning'; // subgraph 那一支的规划本体(fixture 同名) const WRAPPER = 'zz-smoke-wrapper'; // 承载子图的那个节点,只是个名字,不会被调用 const COMPANY = 'zz-real-chain-co'; let wdb; let engine; describe('图执行真实链路 smoke(零注入,只换掉真 CLI 子进程)', () => { let runId; let nodeRunId; let rootRunId; beforeAll(async () => { for (const m of ['../../services/graph-db', '../../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'); const db = wdb.getDb(); const now = new Date().toISOString(); 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', ?, ?)`) .run(AGENT, AGENT, `system/agents/${AGENT}`, now, now); const g = wdb.createWorkGraph({ companyId: COMPANY, nodes: [{ nodeName: AGENT, systemAgentId: AGENT }], edges: [] }); expect(g.errors || []).toEqual([]); const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: COMPANY, executionMode: 'manual', // 额度给足:这条 smoke 要验的是「有没有数」,不是「够不够花」 budget: { total_agent_calls: 10, total_tokens: 5_000_000, spent: { agent_calls: 0, tokens: 0 } }, }); engine.markRunReady(run.id); runId = run.id; // createRun 的返回值不一定带 root_run_id,从库里读回来(这条 run 自己就是 root) rootRunId = wdb.getRun(run.id).root_run_id; expect(rootRunId).toBe(run.id); // ready → running(「下一步」只对 running/paused 可用) engine.startRun(runId, { mode: 'manual' }); const out = await engine.nextStep(runId, { task: '真实链路 smoke:产出一份 PRD' }); if (!out.ok) throw new Error(`nextStep 没推动:${JSON.stringify(out)}`); nodeRunId = out.node.id; // 节点必须真的跑完 —— 停在 running / fail 的话下面三条断言验的是残局 expect(out.node.status).toBe('success'); }, 60_000); afterAll(() => { // 真实链路会在本体下建公司态目录(AgentManager 的 cwd 是 system/agents/{aid}/{cid}/{taskId})。 // 它 gitignore 挡着、不进版本库,但留着会让别的用例遍历 system/agents/ 时多扫一份, // 也让 `ls system/agents/prd-planning/` 出现一个没人认领的公司。 for (const a of [AGENT, PLANNER]) { fs.rmSync(path.join(__dirname, '../../../system/agents', a, COMPANY), { recursive: true, force: true }); } delete process.env.LEMONBOSS_CLAUDE_MODE; delete process.env.GRAPH_DB_PATH; delete process.env.GRAPH_RUNS_DIR; fs.rmSync(TMP, { recursive: true, force: true }); }); it('① mock/files 真的落到了节点产物目录(假 executor 下这一环整个不存在)', () => { const dir = path.join(process.env.GRAPH_RUNS_DIR, rootRunId, 'nodes', nodeRunId); expect(fs.existsSync(path.join(dir, 'attempt-0.json'))).toBe(true); expect(fs.readFileSync(path.join(dir, 'prd.md'), 'utf8')).toContain('PRD'); // attempt-0.json 是引擎 readNodeProduct 要读的那份 —— 它读不到就判不出 verdict, // 所以这条同时也证明了 `{attempt}` 占位符替换那一步没坏 const product = JSON.parse(fs.readFileSync(path.join(dir, 'attempt-0.json'), 'utf8')); expect(product.verdict).toBe('no_prior_failure'); }); it('② token 用量真的从 session/result.usage 数进了预算(§六 验收 10 那一维)', () => { const spent = JSON.parse(wdb.getRun(rootRunId).budget_json).spent; expect(spent.agent_calls).toBe(1); // fixture 报 1200 + 340;断言等值而不是 >0 —— 「大于 0」在累加写成 `=` 而非 `+=` 时也成立 expect(spent.tokens).toBe(1540); }); // ── subgraph 那一支:defaultPlanWorkGraph ───────────────────────────── // // 覆盖边界说清楚:这里**跳过 `resolvePlanningAgentName` 里的按需 hire**(预先塞一行 // company_agents 让它认为雇过了)。理由是 hire 要一条真实的公司记录(`this.get(cid)`), // 而那条路径另有 `company-hire-org-layer.test.js` 专门盯着 —— 这条 smoke 要验的是 // 「规划这一段链路通不通」,不是「招聘通不通」。 function seedPlannerHired() { const db = wdb.getDb(); const now = new Date().toISOString(); 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', ?, ?)`) .run(PLANNER, PLANNER, `system/agents/${PLANNER}`, now, now); // subgraph 节点自己也要有 system_agents 行(work_graph_nodes.system_agent_id 是外键)。 // 它只是「哪个 org 角色拥有这张子图」的名字,不会被真的调用 —— 调的是 PLANNER。 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', ?, ?)`) .run(WRAPPER, WRAPPER, `system/agents/${WRAPPER}`, now, now); db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at) VALUES (?, ?, ?, 'active', ?)`).run(COMPANY, PLANNER, PLANNER, now); } it('④ subgraph 节点真的把 workgraph-planning 的图 JSON 变成了一条子 run', async () => { seedPlannerHired(); const g = wdb.createWorkGraph({ companyId: COMPANY, nodes: [{ nodeName: WRAPPER, systemAgentId: WRAPPER, nodeKind: 'subgraph' }], edges: [], }); expect(g.errors || []).toEqual([]); const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: COMPANY, executionMode: 'manual', budget: { total_agent_calls: 10, total_tokens: 5_000_000, spent: { agent_calls: 0, tokens: 0 } }, }); engine.markRunReady(run.id); engine.startRun(run.id); const out = await engine.nextStep(run.id, { task: '真实链路 smoke:规划一张子图' }); if (!out.ok) throw new Error(`subgraph 没推动:${JSON.stringify(out)}`); // 父节点停在 running 等子 run,这是 subgraph 的正常形态(不是没跑动) expect(out.reason).toBe('awaiting-child-run'); expect(out.node.childRunId).toBeTruthy(); // 子 run 的定义必须来自 fixture 那张图 —— 注入假 planner 时这一整段(回复解析 → // createWorkGraphSafely → 建子 run)都不存在 const child = wdb.getRun(out.node.childRunId); expect(child.graph_kind).toBe('work'); const childNodes = wdb.getDb() .prepare(`SELECT def_node_id FROM graph_run_nodes WHERE run_id = ?`).all(child.id); expect(childNodes).toHaveLength(1); const def = wdb.getDb() .prepare(`SELECT node_name, system_agent_id FROM work_graph_nodes WHERE id = ?`) .get(childNodes[0].def_node_id); expect(def.node_name).toBe(AGENT); expect(def.system_agent_id).toBe(AGENT); // ⑤ 规划那一轮烧的 token 也进了预算。**它走的是另一段 onAcpEvent** // (defaultPlanWorkGraph 自己那条),与 ② 验的不是同一段代码。 const spent = JSON.parse(wdb.getRun(run.root_run_id || run.id).budget_json).spent; expect(spent.tokens).toBe(1060); // fixture 报 800 + 260 }, 60_000); it('⑥ validator 判失败时引擎真的按失败处理(27-P3B §六 验收 4,此前只是「静态成立」)', async () => { // 原验收标准:「造一份非法契约跑 benchmark-validator:判决为失败且引擎按失败处理 // (**不是** success)」。P3B 当时的结论是 🟡「静态成立,未真跑」—— 人读代码把 // fixture verdict → FAILING_VERDICTS → readNodeProduct → callAgentNode 判 fail // 这条链读通了,但没有任何一次真跑或真链路证据。 // // 「读通了」和「跑通了」之间隔着的正是这条 smoke 要补的东西:产物要真的落盘、 // 要真的被 readNodeProduct 读到、verdict 要真的被认出来。任何一环断了, // 节点都会以 success 收场 —— 而**判错成 success 的代价是不合格产物直接放行, // 且不留痕迹**(P3B §八 那段「两种错代价不对称」说的就是这个方向)。 const VALIDATOR = 'benchmark-validator'; // 另开一家公司:一家公司同时只允许一条活跃 root run(idx_graph_runs_one_active_root), // 上面几条留下的 run 还在活跃集里,共用公司会撞 RunSlotBusyError —— 那是约束在正常工作, // 不是这条用例要验的东西。 const CO = `${COMPANY}-v4`; const db = wdb.getDb(); const now = new Date().toISOString(); 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', ?, ?)`) .run(VALIDATOR, VALIDATOR, `system/agents/${VALIDATOR}`, now, now); const g = wdb.createWorkGraph({ companyId: CO, // 带一条 fail 兜底边:没有出边时失败节点收在 `blocked`(挂起等人工), // 那也是「按失败处理」,但看不出**路由**有没有走通。加上边,判据才完整: // 失败 → 节点 fail → 沿 fail 边点亮下游。 nodes: [ { nodeName: VALIDATOR, systemAgentId: VALIDATOR }, { nodeName: AGENT, systemAgentId: AGENT }, ], edges: [{ from: VALIDATOR, to: AGENT, condition: 'fail' }], }); expect(g.errors || []).toEqual([]); const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: CO, executionMode: 'manual' }); engine.markRunReady(run.id); engine.startRun(run.id); const out = await engine.nextStep(run.id, { task: '真实链路 smoke:验一份非法契约' }); if (!out.ok) throw new Error(`validator 没推动:${JSON.stringify(out)}`); // 这两行就是验收 4 的全部:**不是 success**,且归因落了库 expect(out.node.status).toBe('fail'); // fail_reason 取自封闭域 ATTRIBUTION_REASONS —— fixture 里写个自创的词会被 // 按设计降级成 null(引擎不认识的归因一律不信),第一版就是这么红的 expect(out.node.failReason).toBe('input_underspecified'); // 路由也要走通:失败的下一步是沿 fail 边点亮下游,不是原地不动 expect((out.activated || []).map((a) => a.name)).toContain(AGENT); // 判词也要带下去 —— 没有它,下一轮 agent 不知道要修什么(§八.14 那条四环全通的链路)。 // 注意判词落在**被点亮的下游节点**那一行,不是失败节点自己那一行: // 「判词的载体 = 点亮它的那次状态转换」,这样多条回退边并存时天然不串台。 const downstream = wdb.getDb() .prepare(`SELECT remediation_note FROM graph_run_nodes WHERE run_id = ? AND def_node_id = (SELECT id FROM work_graph_nodes WHERE work_graph_id = ? AND node_name = ?)`) .get(run.id, g.id, AGENT); expect(downstream.remediation_note).toContain('test_script'); fs.rmSync(path.join(__dirname, '../../../system/agents', VALIDATOR, CO), { recursive: true, force: true }); }, 60_000); it('⑦ 31-work-graph-fail-fallback-coverage T7:非入口节点自判失败时,' + '引擎沿新补的兜底边自动打回上游——不调 retryNode,不需要人工点重试', async () => { // 复现 e2e-demo 那次真实卡住的最小形状:prd(入口)--success--> sdd, // sdd 自己写出 fail_reason=input_underspecified 却没有精确匹配边时, // 退到这条新补的兜底边 sdd --fail(无 failReason)--> prd(backward)。 // 这正是 planner.json 补的那条边的最小复现,用真实 graph-engine 代码路径验证—— // 不是「补了边、结构校验能过」,是「补了边之后引擎运行时真的会走它」。 const SDD_AGENT = 'sdd-planning'; const CO = `${COMPANY}-t7`; const db = wdb.getDb(); const now = new Date().toISOString(); 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', ?, ?)`) .run(SDD_AGENT, SDD_AGENT, `system/agents/${SDD_AGENT}`, now, now); const g = wdb.createWorkGraph({ companyId: CO, nodes: [ { nodeName: 'prd', systemAgentId: AGENT }, { nodeName: 'sdd', systemAgentId: SDD_AGENT }, ], edges: [ { from: 'prd', to: 'sdd', condition: 'success' }, { from: 'sdd', to: 'prd', condition: 'fail', edgeKind: 'backward' }, // 31 号文档新补的那条兜底边 ], }); expect(g.errors || []).toEqual([]); const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: CO, executionMode: 'manual' }); engine.markRunReady(run.id); engine.startRun(run.id); // 第一步:prd(入口)真的跑成功 const step1 = await engine.nextStep(run.id, { task: 'T7 smoke:先跑 prd' }); if (!step1.ok) throw new Error(`prd 没推动:${JSON.stringify(step1)}`); expect(step1.node.status).toBe('success'); expect((step1.activated || []).map((a) => a.name)).toContain('sdd'); // 第二步:sdd 自判 input_underspecified,图上没有为这个 fail_reason 精确匹配的边, // 应该落到兜底边、自动打回 prd —— 全程没有任何一处调用 retryNode。 const step2 = await engine.nextStep(run.id, { task: 'T7 smoke:跑 sdd,会自判失败' }); if (!step2.ok) throw new Error(`sdd 没推动:${JSON.stringify(step2)}`); // backward 边一旦命中,settleNode 会把「回退窗口内」的节点(含 sdd 自己)连同它一起重置回 // pending,等下一轮从 prd 重新走一遍——所以 step2.node(sdd 那一行)读到的已经是重置后的 // 状态,不是它刚判失败那一瞬间的快照。真正证明"失败被路由"的是 backwardEdgeId + activated。 expect(step2.node.status).toBe('pending'); // sdd 自己被回退窗口一并重置,等下一轮 expect(step2.backwardEdgeId).toBeTruthy(); // 命中了一条真实的 backward 边(不是死路 blocked) // 这一行就是 T7 要证的东西:sdd 判失败之后,prd 被引擎自己点亮,不是停在原地等人工 expect((step2.activated || []).map((a) => a.name)).toContain('prd'); // 命中的确实是 31 号文档新补的那条兜底边(sdd → prd, fail, 无 failReason) const hitEdge = wdb.getDb() .prepare(`SELECT from_node_id, to_node_id, condition, fail_reason FROM work_graph_edges WHERE id = ?`) .get(step2.backwardEdgeId); const nodeName = (id) => wdb.getDb().prepare(`SELECT node_name FROM work_graph_nodes WHERE id = ?`).get(id).node_name; expect(nodeName(hitEdge.from_node_id)).toBe('sdd'); expect(nodeName(hitEdge.to_node_id)).toBe('prd'); expect(hitEdge.fail_reason).toBeNull(); // sdd 真的自判了 input_underspecified(不是被引擎凭空发明的)——从它这次调用的归因文件读回 const sddNodeRunId = step1.activated.find((a) => a.name === 'sdd').id; const sddDir = path.join(process.env.GRAPH_RUNS_DIR, run.root_run_id || run.id, 'nodes', sddNodeRunId); const sddAttempt = JSON.parse(fs.readFileSync(path.join(sddDir, 'attempt-0.json'), 'utf8')); expect(sddAttempt.fail_reason).toBe('input_underspecified'); // 判词也真的带到了被点亮的 prd 节点上(remediation_note 的载体是「点亮它的那次状态转换」, // 与 ⑥ 同一条规矩),不是丢在 sdd 自己那一行没人看得到 const prdRow = wdb.getDb() .prepare(`SELECT remediation_note, status FROM graph_run_nodes WHERE run_id = ? AND def_node_id = (SELECT id FROM work_graph_nodes WHERE work_graph_id = ? AND node_name = ?)`) .get(run.id, g.id, 'prd'); expect(prdRow.status).toBe('ready'); expect(prdRow.remediation_note).toContain('contract.json'); for (const a of [SDD_AGENT, AGENT]) { fs.rmSync(path.join(__dirname, '../../../system/agents', a, CO), { recursive: true, force: true }); } }, 60_000); it('③ 边界守卫在这条路径上是**开着**的(注入假 executor 时它恒为假,那条分支零覆盖)', () => { // 判据取自引擎自己的开关函数,而不是去数 timeline 事件: // 守卫在宿主仓库干净时本来就不产生事件,拿「没有事件」当「没开」会反过来判错。 expect(engine.boundaryGuardApplicable({ nodeKind: 'agent_call' })).toBe(true); expect(engine.boundaryGuardApplicable({ nodeKind: 'subgraph' })).toBe(true); // 对照:注入之后必须变假 —— 少了这条,上面两句在函数写成 `return true` 时也全绿。 // 两支各有各的开关分支,所以两边都要验。 engine.setAgentExecutor(async () => ({ status: 'success' })); engine.setPlanWorkGraph(async () => ({ workGraphId: 'wg_000000000000' })); expect(engine.boundaryGuardApplicable({ nodeKind: 'agent_call' })).toBe(false); expect(engine.boundaryGuardApplicable({ nodeKind: 'subgraph' })).toBe(false); engine.setAgentExecutor(null); engine.setPlanWorkGraph(null); }); });