org-layer-contracts.test.js
17 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
/**
* Unit Tests — R-19 **org 层**数据契约:先有读回方,才谈声明方
*
* 起因是第五次真 CLI 冒烟:`graph_edge_contracts` 里 `edge_layer='org'` 是 0 行。
* 直觉判断是「orggraph-planning 模板没教它声明」,核实之后这个判断是**错的**:
*
* - work-graph-db.readDefGraph 给 org 图的节点**全部**写 nodeKind='subgraph'
* (注释原文:「org graph 的节点全部是 subgraph executor」);
* - graph-engine.describeInputs 第一行就是 `ctx.nodeKind === 'subgraph'` 时返回空串。
*
* 两条合起来 = org 层的边契约在输入侧**一个读回方都没有**。先补模板的话,产出的正是
* 「声明了没人读」——本仓库已经踩了 14 次的那个形状。
*
* 所以读回方接在 `buildPlanningPrompt`(org 节点派给 workgraph-planning 的提示词)上:
* org 层契约的语义正落在这里 —— 「你这个 org node 要吃上游产出的 X、要产下游要吃的 Y,
* 于是你规划的子图必须让入口节点消费 X、必须有节点产出 Y」。
*
* 本文件锁四件事:
* ① 读回方成立 org 边上的 produces → 规划提示词里出现 ③bis 段 + 上游实例的**绝对路径**
* ② 三种口径 查得到给绝对路径 / required 查不到给 ⚠️ / 可选查不到整行省略
* ③ 无契约不留空标题 一条契约都没有时整段(含标题)消失,不给 agent「查过了,没有」的错觉
* ④ 模板防漂移 orggraph-planning 模板里出现的每个「边上声明键」都必须被
* org-graph-service.normalizeContracts 真读到;kind 取值域逐字对齐 ARTIFACT_KINDS
*
* buildPlanningPrompt / describeOrgContracts **故意不导出**(graph-engine.js 的口径:
* 不为测试新开公开面),所以 ①②③ 全部走 advanceOne 这个真入口:
* 用 setPlanWorkGraph 只替掉**上游** org 节点的规划(给它一张现成子图),
* 被测节点则委托回真的 `defaultPlanWorkGraph`,靠替换 AgentManager 截获它发出的提示词。
*
* 隔离手法同 graph-files-index.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,
* 仓库 data/ 全程不被碰;Claude CLI 一次都不起(AgentManager 被换成假货)。
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
const REPO = path.resolve(__dirname, '../../..');
describe('R-19 org 层数据契约(读回方 = buildPlanningPrompt)', () => {
let wdb;
let orgSvc;
let engine;
let runsDir;
const TEST_DB_PATH = '/tmp/test-org-layer-contracts.sqlite';
const AM_PATH = require.resolve('../../src/core/agent-manager');
/** defaultPlanWorkGraph 发出的提示词,一次调用一条 */
let captured = [];
let savedAgentManager;
/**
* 假 AgentManager:截下提示词就抛。
*
* 抛而不是「回一张合法子图」是刻意的:本文件要验的是**提示词内容**,
* 让规划"成功"还得替它造一张过五项校验的子图,那些代码与被测行为无关。
* defaultPlanWorkGraph 对 callAgent 抛出的错不重试(只对「图画错了」重试),
* 所以一次 advanceOne 恰好产出一条提示词,节点按 node_error 失败 —— 正是我们要的。
*/
class FakeAgentManager {
async callAgent(agentName, prompt) {
captured.push({ agentName, prompt });
throw new Error('fake-agent-manager: 已截获提示词,不真的调 CLI');
}
}
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(), 'org-contracts-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');
orgSvc = require('../../services/org-graph-service');
engine = require('../../services/graph-engine');
// defaultPlanWorkGraph 里是 `require('../src/core/agent-manager')`(调用时才 require),
// 所以换掉 require.cache 里的 exports 就够,不必去 patch 原型 —— 也就不会构造真的
// AgentManager(它的构造函数会挂上全局 LogManager / SessionPool)。afterAll 原样还回去。
savedAgentManager = require.cache[AM_PATH];
require.cache[AM_PATH] = {
id: AM_PATH, filename: AM_PATH, loaded: true, children: [], paths: [],
exports: FakeAgentManager,
};
});
afterAll(() => {
if (savedAgentManager) require.cache[AM_PATH] = savedAgentManager;
else delete require.cache[AM_PATH];
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;
for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) {
delete require.cache[require.resolve(m)];
}
});
// ── 脚手架 ───────────────────────────────────────────────────
let seq = 0;
/** 前缀刻意带 tmp-:resolvePlanningAgentName 会拿它去 REPO/data/companies 下找目录,
* 找不到就试着 hire 一次;hire 第一步 get(companyId) 读不到 company.json 直接抛,
* 被 catch 掉 —— 全程不在仓库 data/ 下建任何东西。 */
const nextCompany = () => `tmp-orgc-${process.pid}-${++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);
}
/**
* 起一张 org run,把入口节点跑通(它的子图写出 `writes` 里的文件并被索引),
* 让第二个 org 节点变 ready,然后再推一步 —— 那一步就会调真的 defaultPlanWorkGraph。
*
* @param edges org 边,可带 produces / needs
* @param writes 入口节点的子图那个 work 节点要落盘的文件 {文件名: 内容}
* @returns {Promise<{prompt: string, runId: string}>} 被测节点(org-coder)收到的规划提示词
*/
async function planOrgRun({ companyId, taskId, nodes, edges, writes = {} }) {
expect(orgSvc.writeGraph(companyId, { nodes, edges }).ok).toBe(true);
ensureAgents(['plan-worker']);
const sub = wdb.createWorkGraph({ companyId, taskId, nodes: ['plan-worker'], edges: [] });
expect(sub.errors || []).toEqual([]);
// 上游 org 节点用注入的假规划(一张现成子图);被测节点委托回真路径。
engine.setPlanWorkGraph(async (ctx) => {
if (ctx.nodeName === 'org-planner') return { workGraphId: sub.id };
return engine.defaultPlanWorkGraph(ctx);
});
// 子图里那个 work 节点:把文件写进自己的私有目录,outputRef 指向该目录
// —— 形状与 defaultAgentExecutor 一致,settleNode 的 indexNodeOutputs 才会登记它。
engine.setAgentExecutor(async (ctx) => {
const dir = path.join(runsDir, ctx.run.root_run_id, 'nodes', ctx.nodeRunId);
fs.mkdirSync(dir, { recursive: true });
for (const [name, body] of Object.entries(writes)) {
fs.writeFileSync(path.join(dir, name), body);
}
return { status: 'success', outputRef: dir };
});
let runId;
try {
const run = wdb.createRun({ graphKind: 'org', graphId: companyId, companyId, taskId });
runId = run.id;
engine.markRunReady(run.id);
engine.startRun(run.id, { mode: 'manual' });
captured = [];
await engine.advanceOne(run.id); // org-planner → 起子 run
const child = wdb.listRunTree(run.id).find((r) => r.parent_run_id === run.id);
await engine.advanceOne(child.id); // 子图那个节点跑完 → 回写父节点
expect(wdb.getRun(child.id).run_status).toBe('completed');
await engine.advanceOne(run.id); // org-coder → 真 defaultPlanWorkGraph
} finally {
engine.setPlanWorkGraph(null);
engine.setAgentExecutor(null);
}
expect(captured).toHaveLength(1);
return { prompt: captured[0].prompt, runId };
}
// ── ① 读回方成立(最关键的一条)───────────────────────────────
it('① org 边上声明 produces → 被测 org node 的规划提示词里出现 ③bis 段与上游实例的绝对路径', async () => {
const companyId = nextCompany();
const taskId = 'task-org-in';
const { prompt } = await planOrgRun({
companyId, taskId,
nodes: ['org-planner', 'org-coder', 'org-validator'],
edges: [
// 入边:上游 org node 会产出一份 brd 交给 org-coder
{ from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['brd'] },
// 出边:org-coder 自己要产出一份 sdd 给下游 org node;
// 同一条边上的 needs 是**下游**要吃的(产出方是隔了一个节点的 org-planner),
// 它不是 org-coder 的产出 —— 混进去就会命令 org-coder 再产一份 brd。
{ from: 'org-coder', to: 'org-validator', condition: 'success', produces: ['sdd'], needs: ['brd'] },
],
writes: { 'brd.md': '# 业务需求' },
});
// 提示词确实发给了规划本体(不是发给别人)
expect(captured[0].agentName).toMatch(/workgraph-planning$/);
// 入边那一半:给的是**绝对路径**,且指向真实落盘的那份 brd.md
expect(prompt).toContain('③bis');
expect(prompt).toContain('### 上游节点交给你的输入');
const abs = prompt.match(/^- brd:(.+)$/m);
expect(abs, '提示词里没有 brd 那一行').toBeTruthy();
expect(path.isAbsolute(abs[1])).toBe(true);
// 真正的证据不是「长得像路径」,而是这个路径**读得出上游刚写的那份内容**。
// (rel_path 是相对公司根算的,节点私有目录在 GRAPH_RUNS_DIR 覆盖下位于公司根之外,
// 所以拼回来的绝对路径带 ../ 归一后并不以公司根开头 —— 不拿前缀做断言。)
expect(fs.readFileSync(abs[1], 'utf-8')).toBe('# 业务需求');
expect(prompt).not.toContain('⚠️ 上游声明会产出');
// 出边那一半:硬约束语气 + 点名 kind
expect(prompt).toContain('### 你这一层必须产出的东西(硬约束)');
expect(prompt).toMatch(/必须有节点真的产出上面每一个 kind/);
// 只列出边上 role='produces' 的那些。同一条出边上的 needs(brd)是下游要吃的东西,
// 混进来就变成「命令 org-coder 再产一份 brd」——一条它根本没答应过的硬约束。
const produced = prompt.match(/找输入的:(.+)$/m);
expect(produced, '提示词里没有「必须产出」那一行').toBeTruthy();
expect(produced[1]).toBe('sdd');
// 出边声明的 kind 不该被误当成「上游给你的输入」列在入边那一半里
expect(prompt).not.toMatch(/^- sdd:/m);
});
// ── ② 另两种口径:required 查不到给 ⚠️;可选查不到整行省略 ──────
it('② required 声明查不到实例 → ⚠️;required:false 查不到 → 整行省略(不留半句话)', async () => {
const companyId = nextCompany();
const taskId = 'task-org-miss';
const { prompt } = await planOrgRun({
companyId, taskId,
nodes: ['org-planner', 'org-coder'],
edges: [{
from: 'org-planner', to: 'org-coder', condition: 'success',
// brd 会真的落盘;acceptance 是 required 但没人产;log 是可选且没人产
produces: ['brd', 'acceptance', { kind: 'log', required: false }],
}],
writes: { 'brd.md': '# 业务需求' },
});
expect(prompt).toMatch(/^- brd:\//m);
expect(prompt).toContain('- acceptance:⚠️ 上游声明会产出,但索引里查不到实例');
// 可选且查不到 → 一行都不留(留一行「查不到」会让 agent 以为这是个待办)
expect(prompt).not.toMatch(/^- log[::]/m);
});
// ── ③ 一条契约都没有时整段消失(含标题)──────────────────────
it('③ org 图上一条契约都没声明 → ③bis 整段不出现(空标题会被读成「查过了,没有」)', async () => {
const companyId = nextCompany();
const { prompt } = await planOrgRun({
companyId, taskId: 'task-org-none',
nodes: ['org-planner', 'org-coder'],
edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success' }],
writes: { 'brd.md': '# 业务需求' }, // 实例在索引里,但图上没人声明要它
});
expect(prompt).not.toContain('③bis');
expect(prompt).not.toContain('上游节点交给你的输入');
expect(prompt).not.toContain('必须产出的东西');
// 其余四项输入照旧在(证明省略的只是这一段,不是整段提示词拼挂了)
expect(prompt).toContain('## ③ 可读的 artifacts/');
expect(prompt).toContain('## ④ 上一轮 remediation_note');
});
// ── ⑤ 产出侧的事实:org 节点自己什么都不登记 ────────────────────
it('⑤ org 层的 produces 在产出侧不靠 indexNodeOutputs:org 节点 output_ref 恒为 null,实例是子 run 里的 work 节点登记的', async () => {
const companyId = nextCompany();
const taskId = 'task-org-out';
const { runId } = await planOrgRun({
companyId, taskId,
nodes: ['org-planner', 'org-coder'],
edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['brd'] }],
writes: { 'brd.md': '# 业务需求' },
});
// org 节点是 subgraph executor,它的 outcome 由 deriveChildOutcome 拼,**不带 outputRef**。
// 所以 indexNodeOutputs 在 org run 上第一行就 return —— 3076 行那句 layer 三元里的
// 'org' 分支对 org run 是走不到的死路径。这不是缺陷,是「org 节点自己不写盘」的必然结果。
const orgNodes = wdb.getDb()
.prepare(`SELECT * FROM graph_run_nodes WHERE run_id = ?`).all(runId);
expect(orgNodes.length).toBeGreaterThan(0);
expect(orgNodes.map((n) => n.output_ref)).toEqual(orgNodes.map(() => null));
// 那份 brd 的索引行是**子 run 里那个 work 节点**登记的(node_run_id 属于子 run)。
// 它之所以能被 org 层的入边声明查到,只因为 graph_files 是按 (company, task) 检索的,
// 而子 run 从父 run 继承同一个 task_id —— 这就是 org 层 produces 在产出侧成立的全部理由。
const rows = wdb.findFiles({ companyId, taskId, kinds: ['brd'] });
expect(rows).toHaveLength(1);
expect(orgNodes.map((n) => n.id)).not.toContain(rows[0].produced_by_node_run_id);
const child = wdb.listRunTree(runId).find((r) => r.parent_run_id === runId);
expect(rows[0].produced_by_run_id).toBe(child.id);
});
// ── ④ 模板防漂移:教的键名 / 取值域必须是落库侧真读的那个 ──────
it('④ orggraph-planning 模板里的边上声明键,normalizeContracts 必须真的认(差一个字母就是静默失效)', () => {
const dir = path.join(REPO, 'system/agents/orggraph-planning');
const text = fs.readdirSync(dir)
.filter((f) => f.endsWith('.md'))
.map((f) => fs.readFileSync(path.join(dir, f), 'utf-8'))
.join('\n');
// 两个键都得教到:少 produces 就没人产,少 needs 就表达不了非相邻依赖
expect(text).toMatch(/"produces"/);
expect(text).toMatch(/"needs"/);
const keys = [...new Set(
[...text.matchAll(/"(produces|needs|carries|consumes|inputs|outputs|provides)"/g)].map((m) => m[1])
)];
expect(keys.length).toBeGreaterThan(0);
for (const key of keys) {
const contracts = orgSvc.normalizeContracts({ from: 'x', to: 'y', [key]: ['brd'] });
expect(contracts.map((c) => c.kind), `模板教了 "${key}",但归一函数不读这个键`).toEqual(['brd']);
}
// kind 取值域逐字对齐:模板漏教一个值,规划 agent 就永远不会用它;
// 模板多教一个值,validateGraph 会以 rule=schema 把整张图打回。
for (const kind of orgSvc.ARTIFACT_KINDS) {
expect(text, `ARTIFACT_KINDS 里的 ${kind} 模板没教`).toContain(kind);
}
// backward 边上不许声明 —— 引擎解析输入只看 forward 入边(validateGraph 也拦)
expect(text).toMatch(/backward/);
});
});