graph-files-index.test.js
34.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
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
/**
* Unit Tests — R-19 产物索引(27-P4 §4.6)
*
* 锁的是第四次真 CLI 冒烟那个断点的正解。当时 org-planner-prd 的归因原话是
* 「不是上游没产出,是它的产出**结构上到不了我手上**」—— 边只承载控制流
* (condition / edge_kind / fail_reason),上游 brd.md 躺在节点私有目录,
* 下游提示词里没有任何可读路径。
*
* 每条用例都按「产生 → 传递 → 落库 → 读回」四环走真入口:
* ① 版本链 同路径二次产出标 superseded_by,不删行(落库)
* ② 部分唯一索引 「同一键只有一个活的 + 留历史」这个选择本身
* ③a 沿边传递 出边声明 kind → 引擎登记实例 → 下游提示词里是绝对路径(全四环)
* ③b 任务级共享 acceptance 对**非相邻**节点也可见(R-9.3 planner→(coder)→validator)
* ④ 缺输入告警 声明了却查不到实例时提示词里出现 ⚠️,不静默省略(R-19.4 读回端)
* ⑤ 产出侧过滤 attempt-*.json 不登记(引擎自读的归因文件);节点 fail 不登记
* ⑥ 索引重建 删掉 graph_files 后能从 graph_run_nodes 重建
*
* describeInputs / indexNodeOutputs / companyRootDir **故意不导出**(graph-engine.js
* module.exports 那段注释写明了口径:不为测试新开公开面),所以这里全部通过 advanceOne
* 这个真入口验 —— 输入侧读 executor 收到的 ctx.task,产出侧读 graph_files 落库行。
*
* 隔离手法同 graph-boundary.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,
* 仓库 data/ 全程不被碰;executor 全用注入的假货,不起 Claude CLI。
* (注入 executor 还顺带关掉了边界守卫 —— boundaryGuardApplicable 只在
* agentExecutor === defaultAgentExecutor 时成立,所以这些用例不扫宿主仓库。)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
describe('R-19 产物索引:graph_files / graph_edge_contracts', () => {
let wdb;
let engine;
let runsDir;
const TEST_DB_PATH = '/tmp/test-graph-files-index.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-files-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;
for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) {
delete require.cache[require.resolve(m)];
}
});
// ── 测试脚手架 ────────────────────────────────────────────────
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);
}
/** 建图 + 建 run + 置 ready + start,并给出「按节点名拿 defNodeId / 边 id」的取值器 */
function buildRun({ companyId, taskId, nodes, edges = [] }) {
ensureAgents(nodes.map((n) => n.systemAgentId || n.nodeName));
const g = wdb.createWorkGraph({ companyId, taskId, nodes, edges });
expect(g.errors || []).toEqual([]);
const def = wdb.readDefGraph('work', g.id);
const byName = new Map(def.nodes.map((n) => [n.name, n.defNodeId]));
const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, taskId });
engine.markRunReady(run.id);
engine.startRun(run.id, { mode: 'manual' });
return {
def,
runId: run.id,
defNodeId: (name) => byName.get(name),
edgeId: (from, to) =>
def.edges.find((e) => e.from === byName.get(from) && e.to === byName.get(to)).id,
};
}
/** 节点私有产物目录 —— 与 graph-engine.nodeArtifactDir 同源(GRAPH_RUNS_DIR 覆盖分支) */
const nodeDir = (rootRunId, nodeRunId) => path.join(runsDir, rootRunId, 'nodes', nodeRunId);
/** 公司根 —— 与 graph-engine.companyRootDir 同源(同一个 override 分支) */
const companyRoot = (companyId) => path.join(runsDir, '_companies', companyId);
/**
* 跑 n 步,收集每个节点看到的提示词。writes[nodeName] 给出这个节点要落盘的文件,
* 落在它自己的私有目录里、outputRef 指向该目录 —— 与 defaultAgentExecutor 的形状一致。
*/
async function advance(runId, steps, { writes = {}, status = () => 'success' } = {}) {
const tasks = new Map();
engine.setAgentExecutor(async (ctx) => {
tasks.set(ctx.nodeName, ctx.task);
const files = writes[ctx.nodeName];
if (!files) return { status: status(ctx.nodeName) };
const dir = nodeDir(ctx.run.root_run_id, ctx.nodeRunId);
fs.mkdirSync(dir, { recursive: true });
for (const [name, body] of Object.entries(files)) {
fs.writeFileSync(path.join(dir, name), body);
}
return { status: status(ctx.nodeName), outputRef: dir };
});
try {
for (let i = 0; i < steps; i++) await engine.advanceOne(runId);
} finally {
engine.setAgentExecutor(null);
}
return tasks;
}
const allFiles = (companyId) =>
wdb.getDb()
.prepare(`SELECT * FROM graph_files WHERE company_id = ? ORDER BY created_at, id`)
.all(companyId);
// ── ① 版本链 ─────────────────────────────────────────────────
it('① 同一 (company, task, relPath) 二次产出:旧行标 superseded_by 而不是被删,findFiles 只给最新那份', () => {
const companyId = 'co-files-ver';
const taskId = 'task-ver';
const rel = 'tasks/task-ver/brd.md';
const v1 = wdb.recordFile({ companyId, taskId, relPath: rel, kind: 'brd' });
const v2 = wdb.recordFile({ companyId, taskId, relPath: rel, kind: 'brd' });
expect(v2).not.toBe(v1);
const rows = allFiles(companyId);
// 历史不被删:两行都在
expect(rows.map((r) => r.id).sort()).toEqual([v1, v2].sort());
// 旧行指向新行
expect(rows.find((r) => r.id === v1).superseded_by).toBe(v2);
// 当前版本恰好一行
expect(rows.filter((r) => r.superseded_by === null).map((r) => r.id)).toEqual([v2]);
// 读回:findFiles 只返回最新那份
const found = wdb.findFiles({ companyId, taskId, kinds: ['brd'] });
expect(found.map((f) => f.id)).toEqual([v2]);
});
// ── ② 部分唯一索引 ───────────────────────────────────────────
it('② 部分唯一索引:两行同路径且都 superseded_by IS NULL 被拒;历史行同路径可共存', () => {
const db = wdb.getDb();
const companyId = 'co-files-idx';
const rel = 'tasks/task-idx/prd.md';
const ins = db.prepare(`
INSERT INTO graph_files (id, company_id, task_id, rel_path, kind, created_at, superseded_by)
VALUES (?, ?, 'task-idx', ?, 'prd', ?, ?)
`);
const now = new Date().toISOString();
ins.run('gf-live-1', companyId, rel, now, null);
// 第二个「当前版本」必须被索引挡住 —— 否则 findFiles 会同时返回两份,
// 下游拿不准该读哪个(这条锁的是「用部分唯一索引」这个选择本身)
expect(() => ins.run('gf-live-2', companyId, rel, now, null)).toThrow(/UNIQUE/i);
// 而历史行(superseded_by 非空)同路径可以随便共存 —— 索引是**部分**的,不是全量的
expect(() => ins.run('gf-old-1', companyId, rel, now, 'gf-live-1')).not.toThrow();
expect(() => ins.run('gf-old-2', companyId, rel, now, 'gf-live-1')).not.toThrow();
expect(allFiles(companyId).filter((r) => r.superseded_by === null)).toHaveLength(1);
});
// ── ③a 沿边传递(全四环)+ ⑤ attempt-*.json 不登记 ──────────
it('③a 沿边传递:出边声明 brd → 引擎登记实例 → 下游提示词里是绝对路径;attempt-*.json 不登记', async () => {
const companyId = 'co-files-edge';
const taskId = 'task-edge';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'brd-writer' }, { nodeName: 'prd-writer' }],
edges: [{ from: 'brd-writer', to: 'prd-writer', condition: 'success' }],
});
// 产生:规划时就在边上声明「这条边传一份 brd」
wdb.declareEdgeContract({
edgeLayer: 'work', edgeId: b.edgeId('brd-writer', 'prd-writer'), kind: 'brd',
});
const tasks = await advance(b.runId, 2, {
writes: {
'brd-writer': {
'brd.md': '# BRD',
// 引擎自读的归因文件,不是给下游的产物
'attempt-1.json': JSON.stringify({ fail_reason: null }),
},
},
});
// 落库:只登记 brd.md,kind 取出边声明的 brd(不是兜底的 artifact)
const rows = allFiles(companyId);
expect(rows).toHaveLength(1);
expect(rows[0].kind).toBe('brd');
expect(path.basename(rows[0].rel_path)).toBe('brd.md');
// attempt-*.json 一行都不该有
expect(rows.filter((r) => /attempt-/.test(r.rel_path))).toEqual([]);
// 传递:入边契约查得到
const inbound = wdb.inboundContracts('work', b.def.edges, b.defNodeId('prd-writer'));
expect(inbound.map((c) => c.kind)).toEqual(['brd']);
// 读回:下游提示词里是能直接 open 的绝对路径
const abs = path.join(companyRoot(companyId), rows[0].rel_path);
expect(fs.existsSync(abs)).toBe(true);
const prompt = tasks.get('prd-writer');
expect(prompt).toContain('[上游输入');
expect(prompt).toContain(`- brd:${abs}`);
// 查得到实例就不该出现缺输入告警(段尾那句「标 ⚠️ 的表示…」是固定说明,不算告警)
expect(prompt).not.toContain('⚠️ 上游声明会产出');
// 上游自己那一步没有入边,不该凭空多一段
expect(tasks.get('brd-writer')).not.toContain('[上游输入');
});
// ── ③b 任务级共享:非相邻可见 ────────────────────────────────
it('③b 任务级共享:planner 产的 acceptance,隔着 coder 的 validator 也查得到(R-9.3 的形状)', async () => {
const companyId = 'co-files-shared';
const taskId = 'task-shared';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'planner' }, { nodeName: 'coder' }, { nodeName: 'validator' }],
edges: [
{ from: 'planner', to: 'coder', condition: 'success' },
{ from: 'coder', to: 'validator', condition: 'success' },
],
});
// 刻意**不**声明任何边契约:planner→validator 根本没有边,纯边绑定表达不了这条依赖
const tasks = await advance(b.runId, 3, {
writes: { planner: { 'acceptance.json': JSON.stringify({ checks: ['a'] }) } },
});
const rows = allFiles(companyId);
expect(rows).toHaveLength(1);
expect(rows[0].kind).toBe('acceptance'); // 无声明时按文件名定 kind
const abs = path.join(companyRoot(companyId), rows[0].rel_path);
// 非相邻的 validator 拿到了路径 —— 这才是「两类数据流都要」的理由
const prompt = tasks.get('validator');
expect(prompt).toContain('[上游输入');
expect(prompt).toContain(`- acceptance(任务级共享):${abs}`);
// 相邻的 coder 同样看得到(任务级共享不受相邻性限制)
expect(tasks.get('coder')).toContain(abs);
// planner 跑的时候索引里还没有实例,不该有段落
expect(tasks.get('planner')).not.toContain('[上游输入');
});
// ── ④ 缺输入告警(R-19.4 读回端)────────────────────────────
it('④a 声明的 produces 一个候选文件都没落盘:节点本身按 fail 结算(produces_missing),不点亮下游', async () => {
// T-mt25ecbd 复现原样:brd/prd/sdd 三个 planner 节点声明了 produces 却真的什么都没写,
// executor 没抛异常就被引擎当 success——这道闸门堵在这里,不再等下游发现读不到东西。
const companyId = 'co-files-missing';
const taskId = 'task-missing';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'sdd-writer' }, { nodeName: 'impl' }],
edges: [{ from: 'sdd-writer', to: 'impl', condition: 'success' }],
});
wdb.declareEdgeContract({
edgeLayer: 'work', edgeId: b.edgeId('sdd-writer', 'impl'), kind: 'sdd', required: true,
});
// 上游什么都不写 → 声明在、连候选文件都没有
const tasks = await advance(b.runId, 2);
expect(allFiles(companyId)).toEqual([]);
const state = wdb.getRunState(b.runId);
const sddWriter = state.nodes.find((n) => n.name === 'sdd-writer');
expect(sddWriter.status).toBe('blocked'); // fail → 没有 fail 边 → 终态 blocked(FSM)
expect(sddWriter.failReason).toBe('produces_missing');
// 上游都没能成功,下游压根不会被点亮,更谈不上跑
expect(tasks.has('impl')).toBe(false);
});
it('④b 声明了 required 输入却查不到实例:提示词里出现 ⚠️,agent 判自己成功也不被引擎采信(missing_required_input)', async () => {
const companyId = 'co-files-missing-needs';
const taskId = 'task-missing-needs';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'sdd-writer' }, { nodeName: 'impl' }],
edges: [{ from: 'sdd-writer', to: 'impl', condition: 'success' }],
});
wdb.declareEdgeContract({
edgeLayer: 'work', edgeId: b.edgeId('sdd-writer', 'impl'), kind: 'sdd', required: true,
});
// 上游写了两份跟声明的 sdd 对不上、彼此也无歧义可猜的文件(同 ⑦ 的「有歧义时不猜」口径,
// 两份都落成 kind=artifact)——产出闸门只看「有没有候选文件」,不判分类对不对,
// 所以 sdd-writer 正常 success,impl 照样被点亮。但 graph_files 里查不到 kind=sdd 的
// 实例——这正是 R-19.4 要报的那种「声明了却查不到」。
const tasks = await advance(b.runId, 2, {
writes: { 'sdd-writer': { '文档一.md': '跟 sdd 无关', '文档二.md': '跟 sdd 也无关' } },
});
const prompt = tasks.get('impl');
expect(prompt).toContain('[上游输入');
expect(prompt).toContain('- sdd:⚠️');
expect(prompt).toContain('索引里查不到实例');
// impl 自己的 executor 判了 success(advance 默认 status: () => 'success'),
// 但引擎不采信——它自己也声明了要吃 required 的 sdd,查不到实例就不算数。
const state = wdb.getRunState(b.runId);
const impl = state.nodes.find((n) => n.name === 'impl');
expect(impl.status).toBe('blocked');
expect(impl.failReason).toBe('missing_required_input');
});
// ── ⑤ 节点 fail 不登记 ──────────────────────────────────────
it('⑤ 节点 fail 时落盘的半成品不进索引(下游不该把它当输入读)', async () => {
const companyId = 'co-files-fail';
const taskId = 'task-fail';
const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'flaky' }] });
await advance(b.runId, 1, {
writes: { flaky: { 'brd.md': '# 半成品' } },
status: () => 'fail',
});
// 文件真的落盘了(不是没写),但索引里一行都没有
const state = wdb.getRunState(b.runId);
// 图上没有任何 fail 边,失败节点最终落 blocked(fail → blocked 是 FSM 的终态转换)
expect(state.nodes[0].status).toBe('blocked');
expect(fs.existsSync(path.join(state.nodes[0].outputRef, 'brd.md'))).toBe(true);
expect(allFiles(companyId)).toEqual([]);
});
// ── ⑥ 索引重建 ─────────────────────────────────────────────
it('⑥ graph_files 被清掉后能从 graph_run_nodes 重建(有损:kind 退成 artifact)', async () => {
const companyId = 'co-files-rebuild';
const taskId = 'task-rebuild';
const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'brd-writer' }] });
await advance(b.runId, 1, { writes: { 'brd-writer': { 'brd.md': '# BRD' } } });
expect(allFiles(companyId)).toHaveLength(1);
// 索引坏了 / 被清空
wdb.getDb().prepare(`DELETE FROM graph_files WHERE company_id = ?`).run(companyId);
expect(wdb.findFiles({ companyId, taskId })).toEqual([]);
// 节点行还在 → 能重建出「这个节点产出过哪个目录」
expect(wdb.rebuildFilesFromNodes(companyId)).toBe(1);
const rebuilt = wdb.findFiles({ companyId, taskId });
expect(rebuilt).toHaveLength(1);
expect(rebuilt[0].kind).toBe('artifact'); // 重建恢复不了语义,只恢复位置
expect(rebuilt[0].rel_path).toBe(wdb.getRunState(b.runId).nodes[0].outputRef);
});
// ── ⑦ 文件名认不出 kind 时靠声明认领 ────────────────────────
//
// 第五次真 CLI 冒烟里这一环成立**纯属侥幸**:brd 节点恰好把文件命名成 `brd.md`。
// agent 给产物起中文名是完全正常的行为,不该由文件名决定索引对不对。
it('⑦ 文件名认不出 kind 时按出边声明认领(一对一无歧义),下游照样拿到路径', async () => {
const companyId = 'co-files-claim';
const taskId = 'task-claim';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'brd-writer' }, { nodeName: 'prd-writer' }],
edges: [{ from: 'brd-writer', to: 'prd-writer', condition: 'success' }],
});
wdb.declareEdgeContract({
edgeLayer: 'work', edgeId: b.edgeId('brd-writer', 'prd-writer'), kind: 'brd',
});
const tasks = await advance(b.runId, 2, {
writes: {
'brd-writer': {
// 文件名里一个 "brd" 字都没有 —— 老逻辑会落成 kind=artifact
'业务需求文档.md': '# 业务需求',
// 同目录还有一个能按文件名认出来的(真跑里就是这个形状),
// 它必须先被 contract 认领掉,否则「还剩一个认不出的文件」不成立
'contract.json': JSON.stringify({ outputs: [] }),
'attempt-1.json': JSON.stringify({ fail_reason: null }),
},
},
});
const rows = allFiles(companyId);
const byKind = new Map(rows.map((r) => [r.kind, r]));
expect([...byKind.keys()].sort()).toEqual(['brd', 'contract']);
expect(path.basename(byKind.get('brd').rel_path)).toBe('业务需求文档.md');
// 真正要防的是下游拿不到输入 —— 落成 artifact 时这一行会变成 ⚠️
const prompt = tasks.get('prd-writer');
expect(prompt).toContain(`- brd:${path.join(companyRoot(companyId), byKind.get('brd').rel_path)}`);
expect(prompt).not.toContain('⚠️ 上游声明会产出');
});
it('⑦ 有歧义时不猜:两个认不出的文件配一个声明 → 都落 artifact', async () => {
const companyId = 'co-files-ambiguous';
const taskId = 'task-ambiguous';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'brd-writer' }, { nodeName: 'prd-writer' }],
edges: [{ from: 'brd-writer', to: 'prd-writer', condition: 'success' }],
});
wdb.declareEdgeContract({
edgeLayer: 'work', edgeId: b.edgeId('brd-writer', 'prd-writer'), kind: 'brd',
});
const tasks = await advance(b.runId, 2, {
writes: { 'brd-writer': { '文档一.md': 'a', '文档二.md': 'b' } },
});
// 猜错比留 artifact 更坏:错的 kind 会让下游读到一份不是它要的文件,
// 而 artifact 只是查不到 —— 后者会如实报 ⚠️,前者是静默的错。
const rows = allFiles(companyId);
expect(rows.map((r) => r.kind)).toEqual(['artifact', 'artifact']);
expect(tasks.get('prd-writer')).toContain('- brd:⚠️');
});
// ── ⑧ 任务级共享按 kind 去重 ────────────────────────────────
it('⑧ 两个节点各写一份同 kind 的任务级产物:提示词里只列最新那一份,不逐个累加', async () => {
const companyId = 'co-files-dedupe';
const taskId = 'task-dedupe';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'first' }, { nodeName: 'second' }, { nodeName: 'third' }],
edges: [
{ from: 'first', to: 'second', condition: 'success' },
{ from: 'second', to: 'third', condition: 'success' },
],
});
// 真跑里的形状:brd 与 prd 两个节点各写了一份 contract.json,都是 kind=contract
const tasks = await advance(b.runId, 3, {
writes: {
first: { 'contract.json': JSON.stringify({ v: 1 }) },
second: { 'contract.json': JSON.stringify({ v: 2 }) },
},
});
// 两份都在索引里(各自路径不同,都是当前版本)——去重只发生在拼提示词那一步
const rows = allFiles(companyId).filter((r) => r.kind === 'contract');
expect(rows).toHaveLength(2);
const prompt = tasks.get('third');
const listed = prompt.split('\n').filter((l) => l.includes('(任务级共享)'));
expect(listed).toHaveLength(1);
// 最新的赢(findFiles 是 created_at 升序,与沿边那一半的 byKind.set 同口径)
expect(listed[0]).toContain(path.join(companyRoot(companyId), rows[1].rel_path));
expect(listed[0]).not.toContain(rows[0].rel_path);
});
// ── ⑨ 出边上的 needs 不算本节点的产出 ──────────────────────────
//
// Chrome 走查里实测到的:p-prd 的出边写 produces['prd'] + needs['brd'](中继声明),
// 收集「本节点产出哪些 kind」时若不按 role 过滤,未认领 kind 数就是 2 而不是 1,
// ⑦ 那条一对一认领当场失效 —— 文件退成 artifact、下游拿不到。
it('⑨ 出边同时有 produces 与 needs:认领只看 produces,中继的 needs 不参与', async () => {
const companyId = 'co-files-role';
const taskId = 'task-role';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'w-brd' }, { nodeName: 'w-prd' }, { nodeName: 'w-val' }],
edges: [
{ from: 'w-brd', to: 'w-prd', condition: 'success' },
{ from: 'w-prd', to: 'w-val', condition: 'success' },
],
});
// w-prd 的出边:它产 prd;同时声明 w-val 还要吃 brd(产出方是 w-brd,不是紧邻上游)
const e2 = b.edgeId('w-prd', 'w-val');
wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'prd', role: 'produces' });
wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'brd', role: 'needs' });
await advance(b.runId, 2, {
writes: {
// 文件名认不出 kind → 只能靠声明认领;needs 混进来的话这里会退成 artifact
'w-prd': { '产品需求文档.md': '# 产品需求' },
},
});
const rows = allFiles(companyId);
expect(rows).toHaveLength(1);
expect(rows[0].kind).toBe('prd');
});
it('⑨ 出边只有 needs 时不认领(本节点什么都没声明要产)', async () => {
const companyId = 'co-files-only-needs';
const taskId = 'task-only-needs';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'n-a' }, { nodeName: 'n-b' }],
edges: [{ from: 'n-a', to: 'n-b', condition: 'success' }],
});
wdb.declareEdgeContract({
edgeLayer: 'work', edgeId: b.edgeId('n-a', 'n-b'), kind: 'sdd', role: 'needs',
});
await advance(b.runId, 1, { writes: { 'n-a': { '随便写的.md': 'x' } } });
// needs 不是产出声明,不该被拿来认领 —— 认领了就是把「下游要吃 sdd」
// 误读成「我产 sdd」,下游读到一份不是它要的文件
const rows = allFiles(companyId);
expect(rows).toHaveLength(1);
expect(rows[0].kind).toBe('artifact');
});
// ── ⑩ 引擎自己的簿记文件不进索引 ────────────────────────────────
it('⑩ contract.prev.json 不进索引(retryNode 的契约快照,不是节点交付物)', async () => {
const companyId = 'co-files-snapshot';
const taskId = 'task-snapshot';
const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 's-brd' }] });
await advance(b.runId, 1, {
writes: {
's-brd': {
'brd.md': '# BRD',
// 引擎为算 contract_hash 留的上一轮快照。漏掉它的话,
// 每次人工重试都往 graph_files 添一行 kind=artifact 的噪声
'contract.prev.json': JSON.stringify({ outputs: [] }),
'attempt-1.json': JSON.stringify({ fail_reason: null }),
},
},
});
const rows = allFiles(companyId);
expect(rows.map((r) => path.basename(r.rel_path))).toEqual(['brd.md']);
});
// ── ⑪ 33 号文档:produces 声明 required:false 时不拦下游 ─────────
//
// 复现 executor.json 里 test→validator 那条边的真实形状:test 出边同时声明
// produces:[{kind:'log', required:false}](自己产出的记录,写作文档但不强制)
// 与 needs:[{kind:'artifact', required:true}](中继声明,产出方是更早的 impl)。
// test 实际写的文件(_plan.md/_research.md 之类)从来认不出 kind=log ——
// 这在真实回归任务 T-mt2r6fz0-125b 里复现过:validator 被 missing_required_input
// 反复误判 blocked,而它自己判定 passed。required:false 让这条声明只留文档价值,
// 不再是硬闸门;真正必需的 needs:artifact 依旧受硬闸门保护(不因这次修复被削弱)。
it('⑪ produces 声明 required:false 时,即使这个 kind 从未被索引到,也不拦下游成功', async () => {
const companyId = 'co-files-optional-produces';
const taskId = 'task-optional-produces';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'w-impl' }, { nodeName: 'w-test' }, { nodeName: 'w-validator' }],
edges: [
{ from: 'w-impl', to: 'w-test', condition: 'success' },
{ from: 'w-test', to: 'w-validator', condition: 'success' },
],
});
wdb.declareEdgeContract({
edgeLayer: 'work', edgeId: b.edgeId('w-impl', 'w-test'), kind: 'artifact', role: 'produces',
});
const e2 = b.edgeId('w-test', 'w-validator');
// 与 executor.json 修复后的形状逐字对应
wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'log', role: 'produces', required: false });
wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'artifact', role: 'needs', required: true });
await advance(b.runId, 3, {
writes: {
'w-impl': { 'impl.md': '# 实现产物' },
// test 写的文件跟 "log" 毫无关联、彼此也无歧义可猜(同 ④b 的口径),
// 这个 kind 永远不会被索引到——正是修复要覆盖的那种情形
'w-test': { '_plan.md': '计划', '_research.md': '调研' },
},
});
const state = wdb.getRunState(b.runId);
const validator = state.nodes.find((n) => n.name === 'w-validator');
// 关键断言:log 从未被索引,但它是 required:false,不该拦下游
expect(allFiles(companyId).some((f) => f.kind === 'log')).toBe(false);
expect(validator.status).toBe('success');
expect(validator.failReason).toBeFalsy();
});
// ── ⑫⑬ R-19 升级:contract.json 的 produces 字段(39 号文档 §7.2 缺陷 5 / §7.4 追记)──
//
// 覆盖「产出写在项目根,节点私有目录文件名扫描天生看不到」这个真实事故:agent 显式声明
// kind → 绝对路径,引擎信这份声明(校验过再信),不再只靠猜文件名。
it('⑫ 显式声明 produces:文件名完全猜不出 kind,靠 contract.json 的声明照样正确索引', async () => {
const companyId = 'co-files-declared';
const taskId = 'task-declared';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'w-impl' }, { nodeName: 'w-next' }],
edges: [{ from: 'w-impl', to: 'w-next', condition: 'success' }],
});
wdb.declareEdgeContract({
edgeLayer: 'work', edgeId: b.edgeId('w-impl', 'w-next'), kind: 'artifact', required: true,
});
engine.setAgentExecutor(async (ctx) => {
if (ctx.nodeName !== 'w-impl') return { status: 'success' };
const dir = nodeDir(ctx.run.root_run_id, ctx.nodeRunId);
fs.mkdirSync(dir, { recursive: true });
// 文件名跟任何已知 kind 都不沾边,也没有任何可猜的痕迹
const weirdPath = path.join(dir, 'a-completely-unguessable-name.bin');
fs.writeFileSync(weirdPath, 'payload');
fs.writeFileSync(path.join(dir, 'contract.json'), JSON.stringify({
produces: { artifact: weirdPath },
}));
return { status: 'success', outputRef: dir };
});
try {
await engine.advanceOne(b.runId);
await engine.advanceOne(b.runId);
} finally {
engine.setAgentExecutor(null);
}
const rows = allFiles(companyId).filter((r) => /a-completely-unguessable-name/.test(r.rel_path));
expect(rows).toHaveLength(1);
expect(rows[0].kind).toBe('artifact');
const state = wdb.getRunState(b.runId);
expect(state.nodes.find((n) => n.name === 'w-impl').status).toBe('success');
expect(state.nodes.find((n) => n.name === 'w-next').status).toBe('success');
});
it('⑬ 声明的 produces 路径越界(指向别处)或文件不存在:整个 kind 判「没产出」,'
+ '不退回文件名兜底扫描,节点按 produces_missing 结算', async () => {
const companyId = 'co-files-declared-invalid';
const taskId = 'task-declared-invalid';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'w-impl' }, { nodeName: 'w-next' }],
edges: [{ from: 'w-impl', to: 'w-next', condition: 'success' }],
});
wdb.declareEdgeContract({
edgeLayer: 'work', edgeId: b.edgeId('w-impl', 'w-next'), kind: 'artifact', required: true,
});
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-files-outside-'));
const outsidePath = path.join(outsideDir, 'not-allowed.md');
fs.writeFileSync(outsidePath, '# 越界文件,真实存在但不在允许写入的两处位置内');
engine.setAgentExecutor(async (ctx) => {
if (ctx.nodeName !== 'w-impl') return { status: 'success' };
const dir = nodeDir(ctx.run.root_run_id, ctx.nodeRunId);
fs.mkdirSync(dir, { recursive: true });
// 声明本身合法存在,但指向的路径既不在本节点私有目录也不在项目根——越界
fs.writeFileSync(path.join(dir, 'contract.json'), JSON.stringify({
produces: { artifact: outsidePath },
}));
return { status: 'success', outputRef: dir };
});
try {
await engine.advanceOne(b.runId);
} finally {
engine.setAgentExecutor(null);
}
const state = wdb.getRunState(b.runId);
const impl = state.nodes.find((n) => n.name === 'w-impl');
// 关键断言:不静默放过——声明校验不过就判 produces_missing,不是悄悄采信这份越界声明。
// 终态是 blocked 不是 fail:本用例的图只画了 success 边,没有 fail 边可路由,
// 失败结算完找不到出边正是 blocked 的定义(同真实案例 org-hunt 的 failReason+blocked 组合)。
expect(impl.status).toBe('blocked');
expect(impl.failReason).toBe('produces_missing');
expect(allFiles(companyId).some((f) => f.rel_path.includes('not-allowed'))).toBe(false);
try { fs.rmSync(outsideDir, { recursive: true, force: true }); } catch {}
});
it('⑭ 44 号文档修复:声明的 produces 指向 root run 共享 artifacts/ 目录(不是节点私有目录/项目根)'
+ '——不再判越界,节点按 success 结算', async () => {
const companyId = 'co-files-shared-artifacts';
const taskId = 'task-shared-artifacts';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'w-benchmark' }, { nodeName: 'w-validator' }],
edges: [{ from: 'w-benchmark', to: 'w-validator', condition: 'success' }],
});
wdb.declareEdgeContract({
edgeLayer: 'work', edgeId: b.edgeId('w-benchmark', 'w-validator'), kind: 'acceptance', required: true,
});
engine.setAgentExecutor(async (ctx) => {
if (ctx.nodeName !== 'w-benchmark') return { status: 'success' };
// 产物写进共享区,不是节点私有目录——goal-benchmark 真实的写法
const sharedDir = path.join(runsDir, ctx.run.root_run_id, 'artifacts');
fs.mkdirSync(sharedDir, { recursive: true });
const sharedPath = path.join(sharedDir, 'goal-benchmark.json');
fs.writeFileSync(sharedPath, JSON.stringify({ dimensions: [] }));
const dir = nodeDir(ctx.run.root_run_id, ctx.nodeRunId);
fs.mkdirSync(dir, { recursive: true });
// 声明指向共享区绝对路径——44 号文档修复前,这条声明会被判「越界」
fs.writeFileSync(path.join(dir, 'contract.json'), JSON.stringify({
produces: { acceptance: sharedPath },
}));
return { status: 'success', outputRef: dir };
});
try {
await engine.advanceOne(b.runId);
await engine.advanceOne(b.runId);
} finally {
engine.setAgentExecutor(null);
}
const state = wdb.getRunState(b.runId);
const benchmark = state.nodes.find((n) => n.name === 'w-benchmark');
expect(benchmark.failReason).toBeFalsy();
expect(benchmark.status).toBe('success');
expect(state.nodes.find((n) => n.name === 'w-validator').status).toBe('success');
});
});