/** * Unit Tests — 引擎自判失败的 fail_reason + 失败上下文进提示词(27-P3B W12 补完 / W9) * * ── W12 补完锁的是什么 ────────────────────────────────────────────── * W12 第一轮只把失败**原因文本**送进了 timeline 与 `node.output.output_excerpt` * (= 控制台的「运行日志」tab),`graph_run_nodes.fail_reason` 仍然恒 null。 * 真跑与界面走查都撞到同一幕:点开失败节点,**概览 tab 第一眼是「失败、原因 —」**, * 要知道去翻第三个 tab 才看得到「Agent not found: prd-planning」。 * 本文件锁三类「引擎自己判定的失败」都在 fail_reason 上留名,四环走完: * 产生(catch / 守卫 / 子 run 派生)→ 传递(outcome.failReason → settleNode) * → 落库(graph_run_nodes.fail_reason,与状态同一次条件更新) * → 读回(getRunState —— `GET /api/graph-runs/:id` 与概览 tab 走的就是这条) * * ── W9 锁的是什么 ────────────────────────────────────────────────── * `validator-triager/agent.md` 承诺「提示词里有:哪个节点失败、第几 attempt、失败摘要」, * 而在此之前**全仓库没有任何代码提供它**。这里锁两件事: * ① 有前序失败时,下一个节点的 ctx.task 里真的出现了这三样; * ② 没有前序失败时**一个字都不塞**(不白烧上下文)。 * 其中最关键的一例是 backward 回退之后:`resetForwardDownstream` 会把失败节点重置成 * pending 并 `fail_reason = NULL`,所以「查库找最近失败节点」在**最需要它的那条路径上 * 恰好查不到**。本文件显式断言了那一刻库里确实没有失败行,而提示词里仍然有。 * * 隔离手法同 graph-engine.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require, * 仓库里的 data/graph.sqlite 与 data/companies/ 全程不被碰到;executor 全是注入的假货, * 不起任何 Claude CLI。 */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; describe('引擎自判失败的 fail_reason 与失败上下文(W12 补完 / W9)', () => { let wdb; let engine; let sse; let runsDir; const TEST_DB_PATH = '/tmp/test-graph-engine-fail-reason.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-fail-reason-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'); sse = require('../../services/graph-run-sse'); 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.setPlanWorkGraph(null); }); let seq = 0; const nextCompany = () => `w12b-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); } /** @returns {{runId: string, companyId: string}} */ function buildRun(nodes, edges = []) { const companyId = nextCompany(); ensureAgents(nodes.map((n) => (typeof n === 'string' ? n : n.systemAgentId))); const g = wdb.createWorkGraph({ companyId, nodes, edges }); expect(g.errors || []).toEqual([]); const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' }); engine.markRunReady(run.id); return { runId: run.id, companyId }; } const nodeByName = (runId, name) => wdb.getRunState(runId).nodes.find((n) => n.name === name); const streamEvents = (runId) => fs.readFileSync(path.join(runsDir, runId, 'stream.log'), 'utf8') .split('\n\n').filter(Boolean) .map((block) => ({ event: (/^event: (.+)$/m.exec(block) || [])[1], data: JSON.parse(/^data: (.+)$/m.exec(block)[1]), })); // ──────────────────────────────────────────────────────────────── // W12 补完 · 三类引擎自判失败都在 fail_reason 上留名 // ──────────────────────────────────────────────────────────────── it('取值域:三个引擎取值与 agent 的归因取值域**不重叠**(复用会劫持精确 fail 边的路由)', () => { const engineReasons = Object.values(engine.ENGINE_FAIL_REASONS); expect(engineReasons).toEqual(['node_error', 'boundary_escape', 'child_run_failed']); // infra_flake 在 routeFailure 里有特判(原地重跑、不烧计数)。任何一个引擎取值撞上它 // 都会让确定性失败(如 Agent not found)原地重跑到预算耗尽。 expect(engineReasons).not.toContain(engine.INFRA_FLAKE_REASON); }); it('① executor 抛异常:fail_reason=node_error 真的落库(不是只进 timeline)', async () => { const { runId } = buildRun(['prd-planning']); engine.setAgentExecutor(async () => { throw new Error('Agent not found: prd-planning'); }); engine.startRun(runId); await engine.nextStep(runId); // 读回走的是 getRunState —— 与 GET /api/graph-runs/:id、概览 tab 同一条路 const node = nodeByName(runId, 'prd-planning'); expect(node.status).toBe('blocked'); expect(node.failReason).toBe('node_error'); // 落库那一环单独验一次:直接读表,排除「只是 getRunState 在拼装时编出来的」 const row = wdb.getDb().prepare('SELECT fail_reason FROM graph_run_nodes WHERE id = ?').get(node.id); expect(row.fail_reason).toBe('node_error'); // SSE 那一环(控制台实时轨)同样带上,不然界面要等下一次轮询才更新 const out = streamEvents(runId).filter((e) => e.event === 'node.output'); expect(out).toHaveLength(1); expect(out[0].data.fail_reason).toBe('node_error'); // 原因文本仍在摘要里(W12 第一轮的成果不回退) expect(out[0].data.output_excerpt).toBe('Agent not found: prd-planning'); }); it('① 路由不变:node_error 匹配不到精确边,仍走兜底边(只加信息,不改选边)', async () => { const { runId } = buildRun( ['impl', 'escalate', 'fixer'], [ // 精确边写的是 agent 归因取值;引擎自判的 node_error 不许劫持它 { from: 'impl', to: 'fixer', condition: 'fail', failReason: 'impl_bug' }, { from: 'impl', to: 'escalate', condition: 'fail' }, ], ); engine.setAgentExecutor(async (ctx) => { if (ctx.nodeName === 'impl') throw new Error('Agent not found: impl'); return { status: 'success' }; }); engine.startRun(runId); await engine.nextStep(runId); expect(nodeByName(runId, 'impl').failReason).toBe('node_error'); expect(nodeByName(runId, 'escalate').status).toBe('ready'); // 兜底边被点亮 expect(nodeByName(runId, 'fixer').status).toBe('pending'); // 精确边没被劫持 }); it('② 边界守卫判越界:success 被降级成 fail,且署名 boundary_escape', async () => { const { runId } = buildRun(['coder']); engine.startRun(runId); // 守卫在生产里只对默认执行器开(单测一律注入假 executor),所以这里直接验它的判罚: // before/after 两张快照由用例给定,不跑 git、不起 CLI。 const ctx = { runId, run: wdb.getRun(runId), nodeRunId: nodeByName(runId, 'coder').id, attempt: 0, projectRoot: '/tmp/p' }; const settled = engine.applyBoundaryGuard( ctx, '', // 调用前:仓库干净 { status: 'success', output: 'done' }, // agent 自称成功 '?? server/routes/echo.js\nM server/index.js\n', // 调用后:宿主仓库多了两处改动 ); expect(settled.status).toBe('fail'); expect(settled.failReason).toBe('boundary_escape'); expect(settled.output).toContain('[边界守卫]'); }); it('② agent 自己给了归因时不被覆盖(越界只是附加事实,不是根因)', () => { const { runId } = buildRun(['coder']); engine.startRun(runId); const ctx = { runId, run: wdb.getRun(runId), nodeRunId: nodeByName(runId, 'coder').id, attempt: 0, projectRoot: null }; const settled = engine.applyBoundaryGuard( ctx, '', { status: 'fail', failReason: 'impl_bug' }, '?? outside.txt\n', ); expect(settled.failReason).toBe('impl_bug'); }); it('② agent 运行态文件不算越界 → 不判 fail,也不署名', () => { const { runId } = buildRun(['coder']); engine.startRun(runId); const ctx = { runId, run: wdb.getRun(runId), nodeRunId: nodeByName(runId, 'coder').id, attempt: 0, projectRoot: null }; const settled = engine.applyBoundaryGuard( ctx, '', { status: 'success' }, 'M system/agents/agent-creator/todo.md\n', ); expect(settled.status).toBe('success'); expect(settled.failReason).toBeUndefined(); }); it('③ 子 run 失败且子图一个归因都没有:父节点署名 child_run_failed', async () => { // 父图:一个 subgraph 节点 const { runId, companyId } = buildRun([{ nodeName: 'org-coder', systemAgentId: 'org-coder', nodeKind: 'subgraph' }]); ensureAgents(['impl']); const child = wdb.createWorkGraph({ companyId, nodes: ['impl'], edges: [] }); expect(child.errors || []).toEqual([]); engine.setPlanWorkGraph(async () => ({ workGraphId: child.id })); // 子图里那个节点固定失败,且不给 fail_reason(= 子图没有任何归因产出) engine.setAgentExecutor(async () => ({ status: 'fail', failReason: null, output: '子节点炸了' })); engine.startRun(runId); await engine.advanceTree(runId); await engine.advanceTree(runId); await engine.advanceTree(runId); const parent = nodeByName(runId, 'org-coder'); expect(parent.status).toBe('blocked'); expect(parent.failReason).toBe('child_run_failed'); }); it('③ 子图给得出更具体的根因时,child_run_failed 不许盖掉它', async () => { const { runId, companyId } = buildRun([{ nodeName: 'org-coder', systemAgentId: 'org-coder', nodeKind: 'subgraph' }]); ensureAgents(['impl']); const child = wdb.createWorkGraph({ companyId, nodes: ['impl'], edges: [] }); engine.setPlanWorkGraph(async () => ({ workGraphId: child.id })); engine.setAgentExecutor(async () => ({ status: 'fail', failReason: 'impl_bug' })); engine.startRun(runId); await engine.advanceTree(runId); await engine.advanceTree(runId); await engine.advanceTree(runId); expect(nodeByName(runId, 'org-coder').failReason).toBe('impl_bug'); }); // ──────────────────────────────────────────────────────────────── // W9 · 失败上下文进提示词 // ──────────────────────────────────────────────────────────────── it('首个节点没有前序失败:提示词里一个字的失败上下文都不塞', async () => { const { runId } = buildRun(['solo']); const tasks = []; engine.setAgentExecutor(async (ctx) => { tasks.push(ctx.task); return { status: 'success' }; }); engine.startRun(runId); await engine.nextStep(runId); expect(tasks).toHaveLength(1); expect(tasks[0]).not.toContain('前序失败上下文'); }); it('有前序失败:下一个节点的提示词里有节点名 / attempt / 失败摘要三样', async () => { const { runId } = buildRun( ['impl', 'triager'], [{ from: 'impl', to: 'triager', condition: 'fail' }], ); const tasks = new Map(); engine.setAgentExecutor(async (ctx) => { tasks.set(ctx.nodeName, ctx.task); if (ctx.nodeName === 'impl') throw new Error('Agent not found: impl'); return { status: 'success' }; }); engine.startRun(runId); await engine.nextStep(runId); // impl 抛异常 → fail → 沿 fail 边点亮 triager await engine.nextStep(runId); // triager const prompt = tasks.get('triager'); expect(prompt).toContain('[前序失败上下文'); expect(prompt).toContain('impl'); // 哪个节点 expect(prompt).toContain('attempt-0'); // 第几 attempt expect(prompt).toContain('Agent not found: impl'); // 失败摘要 expect(prompt).toContain('node_error'); // 引擎判定的 fail_reason // agent.md 里 target_node 三个取名来源的第 (a) 个,就靠这一句接上 expect(prompt).toContain('target_node'); }); it('backward 回退之后仍然拿得到失败上下文 —— 而此刻库里已经查不到任何失败行', async () => { // validator 骨架的形状:triager 是入口,check 失败后退回 triager const { runId } = buildRun( ['triager', 'check'], [ { from: 'triager', to: 'check', condition: 'success' }, { from: 'check', to: 'triager', condition: 'fail', edgeKind: 'backward' }, ], ); const prompts = []; engine.setAgentExecutor(async (ctx) => { prompts.push({ name: ctx.nodeName, attempt: ctx.attempt, task: ctx.task }); if (ctx.nodeName === 'check' && ctx.attempt === 0) { return { status: 'fail', output: '契约非法:acceptance 缺字段' }; } return { status: 'success' }; }); engine.startRun(runId); await engine.nextStep(runId); // triager attempt 0 → success await engine.nextStep(runId); // check attempt 0 → fail → backward 退回 triager await engine.nextStep(runId); // triager attempt 1 ← 要在这一轮的提示词里看到失败上下文 // 关键判据:此刻库里**一行失败都没有** —— handleBackwardEdge → resetForwardDownstream // 已经把 check 重置成 pending 并 fail_reason = NULL。所以「查库找最近失败节点」在这里 // 必然落空,失败上下文只能从 append-only 的 timeline 读回。 const rows = wdb.getRunState(runId).nodes; expect(rows.filter((n) => n.status === 'fail' || n.status === 'blocked')).toEqual([]); expect(rows.filter((n) => n.failReason)).toEqual([]); const second = prompts.filter((p) => p.name === 'triager')[1]; expect(second).toBeTruthy(); expect(second.attempt).toBe(1); expect(second.task).toContain('[前序失败上下文'); expect(second.task).toContain('check'); expect(second.task).toContain('契约非法:acceptance 缺字段'); }); it('失败摘要超长时按定长截断,不把整份输出灌进下一个节点的提示词', async () => { const { runId } = buildRun( ['impl', 'triager'], [{ from: 'impl', to: 'triager', condition: 'fail' }], ); const huge = 'y'.repeat(5000); const tasks = new Map(); engine.setAgentExecutor(async (ctx) => { tasks.set(ctx.nodeName, ctx.task); if (ctx.nodeName === 'impl') return { status: 'fail', output: huge }; return { status: 'success' }; }); engine.startRun(runId); await engine.nextStep(runId); await engine.nextStep(runId); const prompt = tasks.get('triager'); expect(prompt).toContain('已截断'); expect(prompt.length).toBeLessThan(huge.length); }); it('subgraph 节点(workgraph-planning)的任务描述同样带上失败上下文', async () => { const { runId, companyId } = buildRun( [ { nodeName: 'org-coder', systemAgentId: 'org-coder' }, { nodeName: 'org-planner', systemAgentId: 'org-planner', nodeKind: 'subgraph' }, ], [{ from: 'org-coder', to: 'org-planner', condition: 'fail' }], ); ensureAgents(['impl']); const child = wdb.createWorkGraph({ companyId, nodes: ['impl'], edges: [] }); let planningTask = null; engine.setPlanWorkGraph(async (ctx) => { planningTask = ctx.task; return { workGraphId: child.id }; }); engine.setAgentExecutor(async (ctx) => { if (ctx.nodeName === 'org-coder') throw new Error('Agent not found: org-coder'); return { status: 'success' }; }); engine.startRun(runId); await engine.nextStep(runId); // org-coder 抛异常 → fail 边点亮 org-planner await engine.nextStep(runId); // org-planner(subgraph)→ 走 planWorkGraph expect(planningTask).toContain('[前序失败上下文'); expect(planningTask).toContain('org-coder'); expect(planningTask).toContain('Agent not found: org-coder'); }); });