graph-engine-fail-reason.test.js
17.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
/**
* 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');
});
});