graph-real-chain-smoke.test.js
24.5 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
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
/**
* 图执行的**真实链路 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('⑧ 双层熔断真的会撞顶收敛到 blocked(27-P3/P4 §四.D.2.1,此前只有注入式覆盖)', async () => {
// 复用 ⑦ 的形状(prd --success--> sdd --fail(backward)--> prd):sdd 这份 fixture
// 每次都确定性判 input_underspecified,一直回退到同一条 backward 边,
// 真实驱动 nextStep 反复跑,直到单边硬顶 LOOP_HARD_CAP_EDGE=3 撞顶
// (第 4 次命中该边)——验证熔断不是靠假 executor 摆出来的分支覆盖,
// 是真实链路(真 AgentManager → 真 ClaudeCliProtocol mock 回放)跑出来的收敛结果。
const SDD_AGENT = 'sdd-planning';
const CO = `${COMPANY}-t8`;
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' },
],
});
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);
// 一路真跑到收敛(blocked)——安全阀 20 步防跑飞(每轮 prd+sdd 两步,撞顶最多需要 4 轮)。
let lastRun = wdb.getRun(run.id);
for (let i = 0; i < 20 && lastRun.run_status === 'running'; i++) {
const step = await engine.nextStep(run.id, { task: `T8 smoke:第 ${i} 步` });
if (!step.ok) break; // 没有 ready 节点了——要么收敛完,要么在等别的东西
lastRun = wdb.getRun(run.id);
}
expect(lastRun.run_status).toBe('blocked');
// 熔断确实是撞的**单边**硬顶(这张图只有一条 backward 边,不会先撞 run 级),
// 且账本上留痕:最后一条记录 verdict=force_terminated,loop_count 越过 LOOP_HARD_CAP_EDGE。
const records = wdb.listLoopRecords(run.id);
expect(records.length).toBeGreaterThan(0);
const terminated = records.find((r) => r.verdict === 'force_terminated');
expect(terminated).toBeTruthy();
expect(terminated.loop_count).toBeGreaterThan(engine.LOOP_HARD_CAP_EDGE);
// 被熔断收敛的是 sdd(回退边的源节点),不是 prd
const sddRow = wdb.getDb()
.prepare(`SELECT 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 = ?)
ORDER BY attempt DESC LIMIT 1`)
.get(run.id, g.id, 'sdd');
expect(sddRow.status).toBe('blocked');
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);
});
});