company-create-mainline.test.js
12.7 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
/**
* 28 号改造的**终验闸门** —— 「新建公司」主线用**真本体**从头到尾走一遍。
*
* ── 为什么还要这一份(三条修复各自都已经有单测了)────────────────────────
* 已有的那些单测一律用**临时合成本体**(`fs.writeFileSync(BODY_DIR/agent.md, '# 临时本体')`):
* 隔离得干净,但也因此把「真本体长什么样」这一维整个抹掉了。三条修复恰好全都
* 只在真本体上才显形:
* ① 毕业闸门(SKILL.md 改成可选)—— 合成本体想让它齐全就让它齐全,
* 而真的卡死过的是 `code-planner`:config.json 写着 `status:'training'`、
* 磁盘上有 agent.md + soul.md、**没有** `.claude/skills/main_skill/SKILL.md`。
* 把 SKILL.md 算回必需件,这一条当场红,且症状正是 E2E 主线上那句
* 「新公司再也雇不到 code-planner」。
* ② 边界守卫的豁免树 —— 合成本体也能造出 `?? system/agents/xxx/`,
* 但只有走真的 `hireFromBaseMetaAgent` 才知道 porcelain 里冒出来的是
* **一整条目录项**(而不是五个文件名)。
* ③ 交付物归宿 —— 合成本体的正文是一行标题,**永远数不出第二条写入指令**。
* 真本体(ceo)正文里带着一句对坐标声明的**引用**(「你的交付物必须写到」那一行),
* 于是「指令」和「引用」必须分开数;而引用一旦指向一个提示词里不存在的段,
* agent 就是找不到落点且不报错 —— 这条悬空只有拿真本体拼真提示词才看得见。
*
* ── 隔离手法 ────────────────────────────────────────────────────────────
* GRAPH_DB_PATH 指临时库(先设再 require),仓库 data/graph.sqlite 全程不被碰。
* 公司目录与公司态目录用固定 id,afterAll 删干净;毕业出来的那份新本体**留着**
* (28 号裁决:测试建出来的 agent 是正式注册表条目)——所以 id 不能带时间戳,
* 否则每跑一次就往注册表里多堆一个没人认领的僵尸。
* 读的既有本体是仓库里真的那几份,**只读不写**。
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
const locator = require('../../services/agent-locator');
const AgentManager = require('../../src/core/agent-manager');
const { GRAPH_WRITE_BOUNDARY_HEADER } = require('../../src/core/prompt-builder');
const REPO = locator.REPO_ROOT;
const TEST_DB_PATH = '/tmp/test-company-create-mainline.sqlite';
const CID = 'zz-mainline-co';
/** 主线上必须能雇进来的真本体。code-planner 是 §2 那条 training 死锁的当事人。 */
const REAL_BODIES = ['ceo', 'code-planner'];
/** 第一次跑时注册表里没有的角色 → 走 create-agent 毕业路径,产物此后常驻 */
const NEW_ROLE = 'zz-mainline-role';
/**
* 「你必须把交付物写到 X」这类**归宿指令**。两条链路各有各的措辞:
* 前者是坐标声明(agent-chat 链路),后者是 describeWriteBoundary(graph 链路)。
* 必须锚在行首的列表项上 —— 本体正文里那句「…里『你的交付物必须写到』那一行」是
* **引用**不是指令,不锚行首就会把它一起数进来,把健康的提示词误判成两个归宿。
*/
const WRITE_MANDATE = /(?:^|\n)-?\s*(?:你的交付物必须写到:|一切代码 \/ 测试 \/ 配置 \/ 交付物都写在)/g;
/** 本体正文对坐标声明那一行的引用(带书名号的才是引用) */
const MANDATE_QUOTE = /「你的交付物必须写到」/g;
const countOf = (re, s) => (s.match(re) || []).length;
describe('28 终验 · 「新建公司」主线(真本体)', () => {
let svc;
let listCompanyAgents;
let engine;
let db;
let manager;
let hires;
let newHire;
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;
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',
'../../services/company-service',
]) {
delete require.cache[require.resolve(m)];
}
({ getDb: db } = require('../../services/graph-db'));
engine = require('../../services/graph-engine');
({ CompanyService: svc, listCompanyAgents } = require('../../services/company-service'));
svc = new svc();
// 库里先照 `seedAgents()` 的做法把两个真本体的 config.json.status 原样入库 ——
// 临时库是空的,不 seed 的话 promoteDraftIfComplete 返回 status='unknown',
// 闸门根本不参与判断,①那条就等于没测(`code-planner` 的 'training' 正是闸门的入口)。
const now = new Date().toISOString();
const ins = db().prepare(`
INSERT OR REPLACE INTO system_agents
(id, name, scope, owner_company_id, source_kind, origin, legacy_path, status, created_at, updated_at)
VALUES (?, ?, 'public', NULL, 'global_agent', 'builtin', ?, ?, ?, ?)
`);
for (const id of REAL_BODIES) {
const cfg = JSON.parse(fs.readFileSync(path.join(locator.bodyDir(id), 'config.json'), 'utf-8'));
ins.run(id, id, `system/agents/${id}`, cfg.status || 'active', now, now);
}
// 固定 id 的代价:上一次跑要是崩在中途,公司目录会留在盘上,而 create() 撞见已存在的
// 目录会直接抛 —— 整个文件连一条用例都跑不了。所以建之前先清一次(清的是公司,不是本体)。
fs.rmSync(path.join(REPO, 'data', 'companies', CID), { recursive: true, force: true });
svc.create({ id: CID, name: '主线终验公司', goal: '验证建司主线', description: 'throwaway' });
hires = Object.fromEntries(REAL_BODIES.map((r) => [r, svc.hire(CID, r)]));
// 直接调毕业分支:本体常驻之后 hire() 第二次跑会(正确地)走复用分支,
// 而②锁的是「刚拷完 `_base` 骨架的新本体仍然不许上岗」。毕业幂等(_copySkeleton 覆写)。
newHire = svc.hireFromBaseMetaAgent(CID, NEW_ROLE, { type: 'skill', name: NEW_ROLE });
manager = new AgentManager({}, REPO);
});
afterAll(() => {
fs.rmSync(path.join(REPO, 'data', 'companies', CID), { recursive: true, force: true });
// applyBoundaryGuard 里的 timelineNote 认不出这个捏造的 runId,按 graph-run-sse 的
// 兜底把 timeline 落到 `data/companies/_unknown/runs/{runId}/`。留着的话它会出现在
// 控制台的公司列表里(svc.list() 扫的就是 data/companies/ 下的目录)。
fs.rmSync(path.join(REPO, 'data', 'companies', '_unknown', 'runs', 'mainline-run'), { recursive: true, force: true });
// 本体不删(正式注册表条目),只清它下面这次跑写出来的公司态
fs.rmSync(locator.companyDir(NEW_ROLE, CID), { recursive: true, force: true });
for (const id of REAL_BODIES) fs.rmSync(locator.companyDir(id, CID), { recursive: true, force: true });
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;
for (const m of ['../../services/graph-run-sse', '../../services/graph-engine', '../../services/company-service']) {
delete require.cache[require.resolve(m)];
}
});
// ① 毕业闸门 —— 把 SKILL.md 算回必需件,这三条一起红
it('真本体(含 config.json 写着 training 的 code-planner)都雇得进来', () => {
// 前提坐实:code-planner 磁盘上确实没有 SKILL.md,所以这条不是「碰巧齐全」
expect(fs.existsSync(path.join(locator.bodyDir('code-planner'), '.claude/skills/main_skill/SKILL.md'))).toBe(false);
for (const r of REAL_BODIES) {
expect(hires[r].status, `${r} 被 body_not_ready 挡住了:缺 ${JSON.stringify(hires[r].missing)}`)
.not.toBe('body_not_ready');
}
});
// ② 哨兵闸门不许被①顺手放宽掉:刚拷完 `_base` 骨架的新本体仍然不许上岗
it('注册表里没有的角色走 create-agent 路径:骨架齐全但停在 draft', () => {
const body = locator.bodyDir(NEW_ROLE);
expect(fs.existsSync(path.join(body, 'agent.md'))).toBe(true);
expect(fs.existsSync(path.join(body, 'config.json'))).toBe(true);
expect(newHire.needsTraining).toBe(true);
expect(db().prepare('SELECT status FROM system_agents WHERE id = ?').get(NEW_ROLE).status).toBe('draft');
});
// ③ 边界守卫:招聘窗口内新建的整条本体目录不算越界
it('新建本体的 porcelain 目录项进 unattributable,节点不被降级成 fail', () => {
const before = '';
const after = [
`?? system/agents/${NEW_ROLE}/`, // hireFromBaseMetaAgent 造的整条目录项
`M system/agents/ceo/todo.md`, // agent 改自己的运行态文件(修改类,两字符状态位长度不同)
].join('\n');
const { escapes, unattributable } = engine.splitUnattributable(engine.diffRepoStatus(before, after));
expect(escapes).toEqual([]);
expect(unattributable).toHaveLength(2);
const ctx = { nodeRunId: 'mainline-node', attempt: 1, projectRoot: null, runId: 'mainline-run' };
const outcome = engine.applyBoundaryGuard(ctx, before, { status: 'success', output: 'ok' }, after);
expect(outcome.status).toBe('success');
expect(outcome.failReason).toBeUndefined();
});
it('落点:公司不持有 agent 实体,公司态在本体目录下', () => {
expect(fs.existsSync(path.join(REPO, 'data', 'companies', CID, 'agents'))).toBe(false);
const ceoState = locator.companyDir('ceo', CID);
for (const f of ['company-context.md', 'user.md', 'memory']) {
expect(fs.existsSync(path.join(ceoState, f)), `公司态缺 ${f}`).toBe(true);
}
expect(listCompanyAgents(CID).map((r) => r.system_agent_id).sort())
.toEqual([...REAL_BODIES, NEW_ROLE].sort());
// 引用制:除了新造的那一个,不给这家公司新增任何私有本体
expect(db().prepare('SELECT id FROM system_agents WHERE owner_company_id = ?').all(CID).map((r) => r.id))
.toEqual([NEW_ROLE]);
});
// ④ 交付物归宿:一段提示词里只准有一个家,且本体正文引用的那一行必须真的在
it('agent-chat 链路:一条写入指令,本体正文的引用有着落', () => {
const addr = `${CID}:ceo`;
const { agent } = manager.getAgentConfig(addr, { metadata: { taskId: 'mainline-conv-1' } });
const prompt = manager.buildFullPrompt(addr, '给公司写一份第一周的启动计划', agent);
expect(prompt).toContain('# 运行时坐标(绝对路径,以此为准)');
expect(countOf(WRITE_MANDATE, prompt)).toBe(1);
// ceo 的 agent.md 正文引用了坐标声明那一行;引用存在 = 那一行必须也存在,
// 否则 agent 照着「去找那一行」找不到,写哪儿全凭它自己猜,且没有任何报错。
expect(countOf(MANDATE_QUOTE, prompt)).toBeGreaterThan(0);
expect(prompt).toMatch(/- 你的交付物必须写到:\/.+\/workspace\//);
// 本体正文与公司语境都真的装进来了(少装一段不报错,只是 agent 变成通用 Base Agent)
expect(prompt).toContain('# CEO Agent');
expect(prompt).toContain(CID);
});
it('graph 节点链路:归宿由写入边界段说了算,不悬空引用 chat 链路那一行', () => {
const addr = `${CID}:code-planner`;
const task = `${GRAPH_WRITE_BOUNDARY_HEADER}\n一切代码 / 测试 / 配置 / 交付物都写在项目根 /tmp/fake-project 内。\n\n把登录接口实现了。`;
const { agent } = manager.getAgentConfig(addr, { metadata: { taskId: 'mainline-node-1' } });
const prompt = manager.buildFullPrompt(addr, task, agent);
expect(countOf(WRITE_MANDATE, prompt)).toBe(1);
expect(prompt).toContain('本节不另给归宿');
// graph 链路的坐标声明**不发**「你的交付物必须写到」那一行,所以本体正文里
// 但凡留着对它的引用就是悬空的 —— 这正是 40 份本体改写时按链路分叉的理由。
expect(countOf(MANDATE_QUOTE, prompt)).toBe(0);
});
it('运行态坐标:cwd 是任务态目录,本体在 ../..,产物不落 system/', () => {
const addr = `${CID}:code-planner`;
const { agent } = manager.getAgentConfig(addr, { metadata: { taskId: 'mainline-node-2' } });
expect(agent.workDir).toBe(locator.taskDir('code-planner', CID, 'mainline-node-2'));
expect(path.relative(agent.workDir, agent.bodyDir)).toBe(path.join('..', '..'));
});
});