graph-cross-layer-address.test.js
9.77 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
/**
* Unit Tests — R-17.1 跨层地址「哪一半是必填的」(27-P3B §四 W10)
*
* 起因:W10 原文(以及 validator-triager/agent.md 顶部粒度块的旧措辞)说
* 「`target_org_node` + `target_node` **两半都写**才合法,只写一半会静默退化成整张图重跑」。
* 本文件把这句话拿到引擎上实跑一遍,结论是**反的**:
*
* - `normalizeNodeRef` 只在 `workNode` 为空时返回 null(`orgNode` 缺省合法,写死成 null);
* - `resolveCrossLayerTarget` 的第 ④ 关是 `if (ref.orgNode && ref.orgNode !== orgNodeName)`
* —— `orgNode` 缺省时这一关**根本不进**,改以回退边的目标为准。
*
* 所以:**`target_node` 是唯一必填的那一半**,`target_org_node` 是可选的一致性校验。
* 把「两半都要写」写进模板会诱导 agent 去编 org node 名 —— 恰是 agent.md 自己那条红线。
*
* 隔离手法照抄 graph-engine.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,
* agent 调用与子图规划全部注入假实现,全程不起 Claude CLI。
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
describe('R-17.1 跨层地址 · 两半的必填性(W10 事实核实)', () => {
let wdb;
let engine;
let runsDir;
const TEST_DB_PATH = '/tmp/test-graph-cross-layer-address.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-xlayer-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');
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 org_graph_edges;
DELETE FROM org_graph_nodes;
DELETE FROM system_agents;
`);
db.pragma('foreign_keys = ON');
engine.setAgentExecutor(null);
});
let seq = 0;
const nextCompany = () => `xl-${++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, source_kind, legacy_path, status, created_at, updated_at)
VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?)
`);
for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, ts, ts);
}
/** org 层形状:coder / validator 都是 subgraph,validator 判失败沿 backward 边退回 coder */
function buildTwoLayerRun(companyId) {
ensureAgents(['coder', 'validator']);
const g = wdb.createWorkGraph({
companyId,
nodes: [
{ nodeName: 'coder', nodeKind: 'subgraph' },
{ nodeName: 'validator', nodeKind: 'subgraph' },
],
edges: [
{ from: 'coder', to: 'validator' },
{ from: 'validator', to: 'coder', condition: 'fail', edgeKind: 'backward' },
],
});
expect(g.errors || []).toEqual([]);
const run = wdb.createRun({
graphKind: 'work', graphId: g.id, companyId, executionMode: 'auto',
});
engine.markRunReady(run.id);
engine.setPlanWorkGraph(async (ctx) => {
ensureAgents(['impl-a', 'impl-b', 'validator-triager']);
const sub = ctx.nodeName === 'coder'
? wdb.createWorkGraph({
companyId, nodes: ['impl-a', 'impl-b'], edges: [{ from: 'impl-a', to: 'impl-b' }],
})
: wdb.createWorkGraph({ companyId, nodes: ['validator-triager'], edges: [] });
expect(sub.errors || []).toEqual([]);
return { workGraphId: sub.id };
});
return run.id;
}
function fakeExecutor(behaviors) {
const calls = [];
engine.setAgentExecutor(async (ctx) => {
calls.push({ node: ctx.nodeName, attempt: ctx.attempt });
return behaviors[ctx.nodeName] || { status: 'success' };
});
return calls;
}
const nodeByName = (runId, name) =>
wdb.getRunState(runId).nodes.find((n) => n.name === name);
// ────────────────────────────────────────────────────────────
it('只写 target_node(不写 target_org_node)→ 跨层定点重置照样生效,org 那一半以回退边为准', async () => {
const companyId = nextCompany();
const runId = buildTwoLayerRun(companyId);
const calls = fakeExecutor({
'impl-a': { status: 'success' },
'impl-b': { status: 'success' },
// ← W10 的核心:**只有 workNode**,没有 orgNode
'validator-triager': {
status: 'fail',
failReason: 'impl_bug',
failNodeRef: { workNode: 'impl-a' },
remediationNote: 'impl-a 少处理了空输入',
},
});
await engine.startRun(runId, { mode: 'auto' }).driver;
const coder = nodeByName(runId, 'coder');
const inner = Object.fromEntries(
wdb.getRunState(coder.childRunId).nodes.map((n) => [n.name, n])
);
// ① 地址被判为合法并落库(orgNode 显式为 null,不是把整条地址丢掉)
const triager = wdb.getRunState(coder.childRunId === null ? runId : runId).nodes;
expect(triager).toBeTruthy();
expect(JSON.parse(nodeByName(runId, 'validator').failNodeRef || 'null'))
.toEqual({ orgNode: null, workNode: 'impl-a' });
// ② 真的做了定点重置:只有 impl-a 重跑,contract 未变的 impl-b 一次都没重跑
expect(inner['impl-a'].attempt).toBeGreaterThan(0);
expect(calls.filter((c) => c.node === 'impl-a').length).toBeGreaterThan(1);
expect(inner['impl-b'].attempt).toBe(0);
expect(calls.filter((c) => c.node === 'impl-b')).toHaveLength(1);
// ③ 账本上 target_node_ref 非空 = 这一次退的是「某一个节点」,
// 且 orgNode 被引擎按回退边的目标补齐成 'coder'(agent 没写、引擎自己解析出来的)
const records = wdb.listLoopRecords(runId);
expect(records.length).toBeGreaterThan(0);
expect(JSON.parse(records[0].target_node_ref)).toMatchObject({
orgNode: 'coder', workNode: 'impl-a',
childRunId: coder.childRunId, nodeRunId: inner['impl-a'].id,
});
});
it('只写 target_org_node(不写 target_node)→ 整条地址作废,退化成整张 work graph 重跑', async () => {
const companyId = nextCompany();
const runId = buildTwoLayerRun(companyId);
const calls = fakeExecutor({
'impl-a': { status: 'success' },
'impl-b': { status: 'success' },
// ← 反向:只有 orgNode,没有 workNode。normalizeNodeRef 返回 null
'validator-triager': {
status: 'fail',
failReason: 'impl_bug',
failNodeRef: { orgNode: 'coder' },
},
});
await engine.startRun(runId, { mode: 'auto' }).driver;
const coder = nodeByName(runId, 'coder');
// 地址整条没落库
expect(nodeByName(runId, 'validator').failNodeRef).toBeNull();
// 整张子图重跑:两个节点都重跑了;子 run 仍然复用同一张
expect(wdb.listRunTree(runId).filter((r) => r.parent_node_run_id === coder.id)).toHaveLength(1);
expect(calls.filter((c) => c.node === 'impl-a').length).toBeGreaterThan(1);
expect(calls.filter((c) => c.node === 'impl-b').length).toBeGreaterThan(1);
expect(wdb.listLoopRecords(runId).every((r) => r.target_node_ref === null)).toBe(true);
});
// ────────────────────────────────────────────────────────────
// 模板侧:键名逐字对齐(「差一个字母就是静默失效」的防线)
// ────────────────────────────────────────────────────────────
it('validator-triager 模板里的地址键名与引擎读的逐字一致,且没有近似拼写的变体', () => {
const root = path.resolve(__dirname, '../../../system/agents/validator-triager');
const text = ['agent.md', 'tools.md'].map((f) => fs.readFileSync(path.join(root, f), 'utf8')).join('\n');
// 引擎 readNodeProduct 读的就是这两个顶层键(graph-engine.js:doc.target_org_node / doc.target_node)
expect(text).toContain('target_org_node');
expect(text).toContain('target_node');
// 近似拼写一律不许出现:这些写法引擎一个都读不到,且失效是静默的
for (const bad of ['targetNode', 'targetOrgNode', 'target_orgnode', 'org_node_target', 'target_work_node']) {
expect(text).not.toContain(bad);
}
// 必填性表述:模板不许再说「两半都要写才生效」(引擎侧 orgNode 缺省合法,见本文件第一条用例)
expect(text).not.toContain('两个都给全');
expect(text).not.toContain('两半都写');
});
});