agent-body-draft-gate.test.js
13.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
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
/**
* Unit Tests — 新造本体的 draft 闸门(28 号设计 §六 批次 5-22/23,验收 6)
*
* 锁的是一条因果链,链上每一环单独看都「没报错」,所以只能整条锁:
* 雇一个仓库里没有的角色 → 从 `_base` 毕业出**本体**(不是公司私有副本)
* → 定义缺一份就停在 status='draft' → 别家公司雇不走它(body_not_ready)
* → 三个定义文件填满 → 下一个来问的人触发转正 → status='active'、可复用
* → 全程只有**一份**本体目录
*
* ⚠️ 28 二轮修复补齐 `_base` 之后**又**在三份定义里埋了哨兵:文件齐全 ≠ 定义填过。
* 所以新本体一出生仍是 draft(第一条用例锁的就是这个 —— 「复制完通用模板就上岗」
* 正是 §2.4 要根治的形态,且会经由第二家公司放大到全系统)。
* 后面两条用例**显式**先把三份填成真内容、再清空 soul.md 打回 draft,
* 模拟 agent-creator 那次 fire-and-forget 只填了一半的真实故障形态 ——
* 这样 missing 恰好是 ['soul.md'],断的是「缺哪一份说得出来」,
* 而不是顺带蹭 `_base` 骨架的缺失(那是另一条独立失效链,见 base-skeleton-complete.test.js)。
*
* 为什么闸门非有不可:填定义的 agent-creator 是一次 fire-and-forget 的 HTTP 自调用,
* 填没填、填全没填全,改造前没有任何一处会问。§2.4 的 org-coder 就是这么变成空定义的
* ——目录在、库里有行、跑起来只剩通用 Base Agent 文本,静默跑偏且不报错。
*
* 隔离手法与 company-hire-org-layer.test.js 同源:GRAPH_DB_PATH 指一次性 sqlite,
* 公司目录与本体下的公司态子树在 afterAll 里删掉;**本体本身留着**
* (28 号裁决:测试建出来的 agent 是正式注册表条目,不删、进版本库)。
* id 因此必须固定,否则「不删」= 每跑一次多一个僵尸本体。
*
* 本体常驻之后,第一条用例的两条分支是同一件事的两种形态:
* 首次 本体不存在 → hire() 路由到 `_base` 毕业分支 → hired_from_base + 库里 draft
* 再跑 本体已在盘上(仍是骨架、哨兵还在)→ 闸门拦下 → body_not_ready + 库里 training
* 断言落在两条分支共同的不变式上:**缺哪三份说得出来 + 绝不当场上岗**。
* 「再跑」这条分支同时也是决策想要的形态 —— 本体被复用,而不是每次新造一份。
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
describe('新造本体的 draft 闸门(28 批次 5)', () => {
const REPO_ROOT = path.resolve(__dirname, '../../..');
const TEST_DB_PATH = '/tmp/test-agent-body-draft-gate.sqlite';
const AGENTS_DIR = path.join(REPO_ROOT, 'system/agents');
const COMPANIES_DIR = path.join(REPO_ROOT, 'data/companies');
const ROLE = 'zz-draft-role';
const BLANK_ROLE = 'zz-draft-blankrole'; // 第 5 条用例的载体;名字不带 ROLE 前缀,免得互相误伤
const CO_A = 'zz-draft-co-a';
const CO_B = 'zz-draft-co-b';
const BODY = path.join(AGENTS_DIR, ROLE);
let graphDb;
let graphService;
let companyService;
const agentRow = () =>
graphDb.getDb().prepare('SELECT * FROM system_agents WHERE id = ?').get(ROLE);
const hiredCompanies = () =>
graphDb.getDb()
.prepare(`SELECT company_id FROM company_agents WHERE role_name = ? AND status = 'active'`)
.all(ROLE).map((r) => r.company_id).sort();
const DEF_FILES = ['agent.md', 'soul.md', '.claude/skills/main_skill/SKILL.md'];
/** agent-creator 按角色重写三份定义的效果:`_base` 哨兵随之消失 */
const fillWithRealContent = (roleId = ROLE) => {
for (const rel of DEF_FILES) {
fs.writeFileSync(path.join(AGENTS_DIR, roleId, rel), `# ${roleId}\n按角色填过的 ${rel}。\n`, 'utf-8');
}
};
/** 把本体打回「agent-creator 只填了一半」的形态:定义缺一份 + 库里退回 draft */
const demoteToDraft = (roleId = ROLE) => {
fillWithRealContent(roleId); // 先都填上,再单独挖掉 soul.md —— 缺的只有它一份
fs.writeFileSync(path.join(AGENTS_DIR, roleId, 'soul.md'), '', 'utf-8');
graphDb.getDb()
.prepare(`UPDATE system_agents SET status = 'draft' WHERE id = ?`)
.run(roleId);
};
/**
* 把本体的三份定义打回 `_base` 骨架(哨兵回位)。
*
* 本用例的方法论就是**改本体的定义文件**(填满 / 挖空),而本体现在是常驻的
* ——不复位的话第二次跑一开始就是「定义已填满」,第一条用例当场退化成
* 「转正了所以通过」。只复位这三份:`config.json` 得留着上一次毕业写下的
* status='training'(`_base` 那份是 'idle',盖过去等于把闸门的入口也一起抹了)。
*/
const restoreSkeleton = (roleId) => {
if (!fs.existsSync(path.join(AGENTS_DIR, roleId))) return;
for (const rel of DEF_FILES) {
fs.copyFileSync(path.join(AGENTS_DIR, '_base', rel), path.join(AGENTS_DIR, roleId, rel));
}
};
/**
* 把盘上已有的本体按 `config.json.status` 补一行进一次性库 —— 生产里 seedAgents()
* 每次进程启动都这么做。本体常驻之后第二次跑起,ROLE 的本体已经在盘上,
* 没有这一行 hire() 拿到的是 status='unknown':闸门整个不参与判断,用例空跑。
*/
const seedRowFromDisk = (id) => {
const cfgPath = path.join(AGENTS_DIR, id, 'config.json');
if (!fs.existsSync(cfgPath)) return;
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
const now = new Date().toISOString();
graphDb.getDb().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', ?, ?, ?, ?)
`).run(id, id, `system/agents/${id}`, cfg.status || 'active', now, now);
};
beforeAll(() => {
// `_base` 骨架齐全是本测试的前提(缺文件时第一条用例的 active 断言会红,
// 而不是悄悄退化成「因为缺文件所以 draft」的另一种通过)
for (const f of ['agent.md', 'soul.md', '.claude/skills/main_skill/SKILL.md']) {
expect(fs.existsSync(path.join(AGENTS_DIR, '_base', f)), `_base/${f}`).toBe(true);
}
if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
process.env.GRAPH_DB_PATH = TEST_DB_PATH;
for (const m of ['../../services/graph-db', '../../services/graph-service',
'../../services/company-service']) {
delete require.cache[require.resolve(m)];
}
graphDb = require('../../services/graph-db');
graphService = require('../../services/graph-service');
const { CompanyService } = require('../../services/company-service');
companyService = new CompanyService();
restoreSkeleton(ROLE);
seedRowFromDisk(ROLE);
// 固定 id 的代价:上一次跑要是崩在中途,公司目录会留在盘上,而 create() 撞见已存在的
// 目录会直接抛 —— 整个文件连一条用例都跑不了。所以建之前先清一次(清的是公司,不是本体)。
for (const id of [CO_A, CO_B]) fs.rmSync(path.join(COMPANIES_DIR, id), { recursive: true, force: true });
companyService.create({ id: CO_A, name: '闸门甲公司', description: '批次 5 闸门测试' });
companyService.create({ id: CO_B, name: '闸门乙公司', description: '批次 5 闸门测试' });
});
afterAll(() => {
// 本体不删(正式注册表条目);删的是公司侧目录 + 散在各本体下的公司态子树
for (const id of [CO_A, CO_B]) {
try { fs.rmSync(path.join(COMPANIES_DIR, id), { recursive: true, force: true }); } catch {}
for (const body of fs.readdirSync(AGENTS_DIR, { withFileTypes: true })) {
if (!body.isDirectory()) continue;
try { fs.rmSync(path.join(AGENTS_DIR, body.name, id), { recursive: true, force: true }); } catch {}
}
}
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;
});
it('骨架 ≠ 定义:本体三份文件齐全也不许上岗,缺哪份说得出来(验收 6)', () => {
const result = companyService.hire(CO_A, ROLE, { type: 'skill', name: ROLE });
// 首次跑是「刚毕业」,之后是「闸门拦下已有骨架」——两条都必须给出同一个结论
expect(['hired_from_base', 'body_not_ready']).toContain(result.status);
// 三份文件都在盘上,但内容还是 `_base` 骨架(带哨兵)→ 一份都不算填过,
// 必须等 agent-creator 按角色重写才能上岗
expect(result.missing).toEqual(DEF_FILES);
expect(result.needsTraining).toBe(true);
for (const f of DEF_FILES) {
expect(fs.existsSync(path.join(BODY, f)), f).toBe(true);
}
const row = agentRow();
expect(graphService.isUngraduatedStatus(row.status)).toBe(true);
// origin 只在「本次真的造了它」时才是这条链路的证据:再跑时这一行是 seedRowFromDisk
// (= 生产的 seedAgents)按盘上目录补的,origin='builtin',说的是另一件事。
if (result.status === 'hired_from_base') {
expect(row.origin).toBe('created');
expect(row.status).toBe('draft');
}
});
it('draft 本体不许被第二家公司雇走 —— 污染面锁死在发起方那一家(§九)', () => {
demoteToDraft(); // agent-creator 只填了一半的形态
const result = companyService.hire(CO_B, ROLE, {});
expect(result.status).toBe('body_not_ready');
expect(result.needsTraining).toBe(true);
// 缺什么要说得出来,否则调用方只知道「不行」不知道「差哪」
expect(result.missing).toEqual(['soul.md']);
// 乙公司没有落下雇佣关系(甲有没有落,取决于上一条走的是毕业还是拦下,与本条无关)
expect(hiredCompanies()).not.toContain(CO_B);
});
it('三个定义文件填满后自动转正,第二家公司复用同一份本体(验收 6)', () => {
fs.writeFileSync(path.join(BODY, 'soul.md'), '# 灵魂\n认真干活。\n', 'utf-8');
const result = companyService.hire(CO_B, ROLE, {});
expect(result.status).toBe('hired_from_template');
expect(agentRow().status).toBe('active');
expect(hiredCompanies()).toContain(CO_B);
// 复用 = 一份本体 + 两个公司态目录,而不是第二份本体。
// 判据取「哪些本体下面挂着本用例这两家公司的公司态目录」——只准有 ROLE 这一份;
// 数目录名的老写法会被 ROLE 的同前缀邻居(以及别的常驻 zz-* 本体)带偏。
const hosting = fs.readdirSync(AGENTS_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.filter((d) => [CO_A, CO_B].some((cid) => fs.existsSync(path.join(AGENTS_DIR, d.name, cid))))
.map((d) => d.name);
expect(hosting).toEqual([ROLE]);
expect(fs.existsSync(path.join(BODY, CO_B, 'company-context.md'))).toBe(true);
});
it('闸门只挡 draft/training —— enabled/idle/ready/trained 是合法在岗状态,不许被误判', () => {
// `system_agents.status` 不是封闭取值域:seedAgents() 把 `config.json.status` 原样
// upsert 进库,生产库里 56 个 builtin 本体有 36 个是 enabled / idle / ready / trained
// (含 workgraph-planning / master / agent-creator / qa-judge)。
// 闸门一旦写成「!== 'active' 就拦」,这 36 个全部 hire 不动,而且报的理由是
// 「定义还没填满」+ missing 为空数组 —— 理由错、现象也不像 bug。本用例锁死这条。
// 用真实存在的本体 qa-judge(三件套齐全),只改库里的 status。
const db = graphDb.getDb();
const now = new Date().toISOString();
for (const status of ['enabled', 'idle', 'ready', 'trained', 'active']) {
const id = `qa-judge`;
db.prepare(`INSERT OR REPLACE INTO system_agents
(id,name,scope,owner_company_id,source_kind,origin,legacy_path,status,benchmark_score,created_at,updated_at)
VALUES (?,?,'public',NULL,'global_agent','builtin',?, ?,NULL,?,?)`)
.run(id, id, `system/agents/${id}`, status, now, now);
db.prepare(`DELETE FROM company_agents WHERE role_name = ?`).run(id);
const r = companyService.hire(CO_A, id, {});
expect([status, r.status]).toEqual([status, 'hired_from_template']);
}
// 收尾:把这次借用 qa-judge 留下的公司态目录删掉,别在源码树里留运行态
for (const cid of [CO_A]) {
try { fs.rmSync(path.join(AGENTS_DIR, 'qa-judge', cid), { recursive: true, force: true }); } catch {}
}
});
it('空白文件不算填过:SKILL.md 只剩空白时闸门必须重新落下', () => {
const other = BLANK_ROLE;
const otherBody = path.join(AGENTS_DIR, other);
// 本体常驻,所以这里不靠「刚建出来」,而是每次显式把它摆成要测的形态:
// 三份定义按角色填满 → 只把 SKILL.md 抹成空白 → 库里打回 draft。
companyService.hire(CO_A, other, {});
fillWithRealContent(other); // agent.md / soul.md 都按角色填过了,只留 SKILL.md 出问题
fs.writeFileSync(path.join(otherBody, '.claude/skills/main_skill/SKILL.md'), ' \n\n', 'utf-8');
graphDb.getDb().prepare(`UPDATE system_agents SET status = 'draft' WHERE id = ?`).run(other);
// 三个文件都「存在」了,但 SKILL.md 是空白 —— 存在性判据会在这里放行,非空判据不会
const blocked = companyService.hire(CO_B, other, {});
expect(blocked.status).toBe('body_not_ready');
expect(blocked.missing).toEqual(['.claude/skills/main_skill/SKILL.md']);
});
});