graph-real-chain-smoke.test.js
16.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
/**
* 图执行的**真实链路 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('③ 边界守卫在这条路径上是**开着**的(注入假 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);
});
});