boundary-guard-new-body.test.js
7.85 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
/**
* Unit Tests — 边界守卫不再冤杀「运行期新建一份 agent 本体」的节点(28 号 §九 风险表第一行)
*
* ── 锁的是哪个回归 ────────────────────────────────────────────────────
* 28 号改造把新本体的落点从 `data/companies/{cid}/agents/`(整个目录已 gitignore)
* 搬到了被 git 跟踪的 `system/agents/{id}/`。于是招聘一个模板库里没有的角色时:
* hireFromBaseMetaAgent 从 `_base` 复制骨架 → 新建整个 `system/agents/{newId}/`
* → 整仓 `git status --porcelain` 冒出 `?? system/agents/{newId}/` **一整条目录项**
* → 边界守卫把它归属到「当时正在跑的那个 work 节点」头上 → 成功的节点被判 fail。
* 症状就是设计 §九 点名的那条:**agent 明明成功却 fail,且归因指向错误的节点**,
* 挡在「新建公司」E2E 主线上。
*
* 判据一旦退回「深度 1 的五个固定文件名」(旧 AGENT_RUNTIME_STATE_RE),下面第一条就红。
*
* ── 为什么用注入的 after 快照 ────────────────────────────────────────
* 守卫只在 `agentExecutor === defaultAgentExecutor` 时才开,而单测一律注入假 executor,
* 所以「越界 → 判 fail」这条分支真跑不到。`applyBoundaryGuard` 的第四个参数
* `injectedAfter` 就是为此留的测试缝(见它的 JSDoc):before/after 两张快照由用例给定,
* 不起 git 子进程、不起 Claude CLI,而生产路径一个字符都没变。
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
describe('边界守卫 · 新建 agent 本体不判越界(28 §九)', () => {
let wdb;
let engine;
let runsDir;
const TEST_DB_PATH = '/tmp/test-boundary-guard-new-body.sqlite';
beforeAll(() => {
// 隔离手法同 graph-engine-fail-reason.test.js:先设环境变量再 require,
// 仓库里的 data/graph.sqlite 与 data/companies/ 全程不被碰到。
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(), 'boundary-new-body-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;
// 同一个进程里后续文件再 require 时要拿回没有 override 的那份
for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) {
delete require.cache[require.resolve(m)];
}
});
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 system_agents;
`);
db.pragma('foreign_keys = ON');
});
let seq = 0;
/** 建一个只有一个 work 节点的 run,返回它的 ctx(守卫要的就这几个字段) */
function newNodeCtx() {
const companyId = `bg-co-${++seq}`;
const ts = new Date().toISOString();
wdb.getDb().prepare(`
INSERT OR IGNORE INTO system_agents
(id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at)
VALUES ('hr', 'hr', 'public', NULL, 'global_agent', 'system/agents/hr', 'active', ?, ?)
`).run(ts, ts);
const g = wdb.createWorkGraph({ companyId, nodes: ['hr'], edges: [] });
expect(g.errors || []).toEqual([]);
const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' });
engine.markRunReady(run.id);
engine.startRun(run.id);
const node = wdb.getRunState(run.id).nodes.find((n) => n.name === 'hr');
return {
runId: run.id, run: wdb.getRun(run.id), nodeRunId: node.id, attempt: 0, projectRoot: '/tmp/p',
};
}
/** timeline = 落盘的 SSE 流;读它才算验到「证据没丢」这一环 */
const timeline = (runId) =>
fs.readFileSync(path.join(runsDir, runId, 'stream.log'), 'utf8')
.split('\n\n').filter(Boolean)
.map((block) => ({
event: (/^event: (.+)$/m.exec(block) || [])[1],
data: JSON.parse(/^data: (.+)$/m.exec(block)[1]),
}));
it('`?? system/agents/{newId}/` 不降级成 fail,且证据进了 timeline 的 unattributable', () => {
const ctx = newNodeCtx();
// 招聘走 hired_from_base 时 porcelain 里就是这一条:未跟踪的**整个目录**,带尾部斜杠。
const settled = engine.applyBoundaryGuard(
ctx, '', { status: 'success', output: '已为你招到 brand-new-role' },
'?? system/agents/brand-new-role/\n',
);
expect(settled.status).toBe('success');
expect(settled.failReason).toBeUndefined();
expect(settled.output).not.toContain('[边界守卫]');
const boundary = timeline(ctx.runId).filter((e) => e.event === 'node.boundary');
expect(boundary).toHaveLength(1);
expect(boundary[0].data.violation).toBeNull(); // 不判罚
expect(boundary[0].data.unattributable).toEqual(['?? system/agents/brand-new-role/']); // 证据留着
});
it('agent-creator 往新本体里补的定义文件(深度 ≥1 的任意路径)同样不判罚', () => {
const ctx = newNodeCtx();
const settled = engine.applyBoundaryGuard(
ctx, '', { status: 'success' },
[
'?? system/agents/brand-new-role/agent.md',
'?? system/agents/brand-new-role/.claude/skills/x/SKILL.md', // 深度 3,旧名单接不住
'M system/agents/agent-creator/todo.md', // 修改类(1 字符状态位)
'?? "system/agents/新 角色/"', // 含空格 → git 会整条加引号
].join('\n') + '\n',
);
expect(settled.status).toBe('success');
const boundary = timeline(ctx.runId).filter((e) => e.event === 'node.boundary');
expect(boundary[0].data.unattributable).toHaveLength(4);
});
it('反面:写进纯源码目录仍然判 fail(别把守卫改瞎了)', () => {
const ctx = newNodeCtx();
const settled = engine.applyBoundaryGuard(
ctx, '', { status: 'success', output: 'done' }, 'M server/index.js\n',
);
expect(settled.status).toBe('fail');
expect(settled.failReason).toBe('boundary_escape');
expect(settled.output).toContain('[边界守卫]');
const boundary = timeline(ctx.runId).filter((e) => e.event === 'node.boundary');
expect(boundary).toHaveLength(1);
expect(boundary[0].data.violation).toBe('boundary_escape');
expect(boundary[0].data.entries).toEqual(['M server/index.js']);
});
it('反面:注册表旁边的同前缀目录不蹭豁免(system/agents-backup/ 仍判 fail)', () => {
const ctx = newNodeCtx();
const settled = engine.applyBoundaryGuard(
ctx, '', { status: 'success' }, '?? system/agents-backup/x.md\n',
);
expect(settled.status).toBe('fail');
expect(settled.failReason).toBe('boundary_escape');
});
});