base-graduation-gate.test.js
9.04 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
/**
* Unit Tests — `_base` 骨架不算「定义已填满」(28 二轮修复:哨兵判据)
*
* 锁的是两条修复互相抵消出来的一个 blocker,实测复现过:
* ① 二轮修复给 `_base` 补齐了 soul.md + main_skill/SKILL.md(对的,不该回退);
* ② 闸门 `missingBodyDefinition` 的判据是「文件存在且非空」。
* 于是 ①+② = 骨架一拷完三份文件就都非空 → `promoteDraftIfComplete` 当场转正 →
* 新角色**刚复制完通用模板就上岗**,身上只有 `_base` 的通用 Base Agent 文本、零角色特化;
* 第二家公司再 hire 同一个 id 时它已经是 active,会被当成一份已就绪的可复用本体直接雇走。
* 这就是 §2.4 的 org-coder 失败形态被原样搬回来,且污染面从 1 家公司放大成全系统。
*
* 修法是把判据从「空 → 算缺」扩成「空 **或仍带哨兵** → 算缺」,
* 哨兵 `BASE_SKELETON_SENTINEL` 埋在 `_base` 的三份定义文件里,agent-creator 写真内容时
* 自然覆盖掉。本文件三条用例分别锁判据、锁转正动作、锁端到端的招聘结果。
*
* 隔离手法与 agent-body-draft-gate.test.js 同源:GRAPH_DB_PATH 指一次性 sqlite,
* 公司目录在 afterAll 删掉;**本体留下**(28 号裁决:测试建出来的 agent 是正式注册表条目,
* 不删、进版本库)。id 因此必须固定 —— 随机后缀 + 不删 = 每跑一次多一个僵尸本体。
*
* 本体常驻带来两处必须显式处理的事,否则第二次跑就变成空跑或误红:
* ① 骨架态要复位:`fs.cpSync(_base → SKELETON_ROLE)` 每次覆写一遍,把上一次
* 「填成真内容」的改动抹掉,哨兵回到原位;
* ② 库行要补种:一次性 sqlite 每次都是空的,而 HIRE_ROLE 的本体上一次跑就毕业出来了。
* 生产里这一行由 seedAgents() 在启动时按 config.json 补上,这里照做(见 seedRowFromDisk)。
* 不补的话闸门拿到 status='unknown',整道闸门不参与判断。
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
describe('_base 骨架不算定义已填满(哨兵闸门)', () => {
const REPO_ROOT = path.resolve(__dirname, '../../..');
const AGENTS_DIR = path.join(REPO_ROOT, 'system/agents');
const COMPANIES_DIR = path.join(REPO_ROOT, 'data/companies');
const TEST_DB_PATH = '/tmp/test-base-graduation-gate.sqlite';
const SKELETON_ROLE = 'zz-basegrad-copy'; // 手工拷贝的骨架,测判据本身
const HIRE_ROLE = 'zz-basegrad-hire'; // 端到端招聘出来的新本体
const CO = 'zz-basegrad-co';
const DEFINITION_FILES = [
'agent.md',
'soul.md',
path.join('.claude', 'skills', 'main_skill', 'SKILL.md'),
];
let locator;
let graphDb;
let graphService;
let companyService;
const bodyOf = (id) => path.join(AGENTS_DIR, id);
/**
* 把盘上已有的本体按 `config.json.status` 补一行进一次性库 —— 生产里 seedAgents()
* 每次进程启动都这么做。本体常驻之后,第二次跑起 HIRE_ROLE 已经在盘上,
* 没有这一行 hire() 会拿到 status='unknown',闸门整个不参与判断(用例空跑)。
*/
const seedRowFromDisk = (id) => {
if (!fs.existsSync(path.join(bodyOf(id), 'config.json'))) return;
const cfg = JSON.parse(fs.readFileSync(path.join(bodyOf(id), 'config.json'), '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);
};
const agentRow = (id) =>
graphDb.getDb().prepare('SELECT * FROM system_agents WHERE id = ?').get(id);
/** 把三份定义写成「agent-creator 真的按角色填过」的样子 —— 哨兵随之消失 */
const fillWithRealContent = (id) => {
for (const rel of DEFINITION_FILES) {
const p = path.join(bodyOf(id), rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, `# ${id} 的真实定义\n这份 ${rel} 是按角色填过的,不再是通用骨架。\n`, 'utf-8');
}
};
beforeAll(() => {
if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
process.env.GRAPH_DB_PATH = TEST_DB_PATH;
for (const m of ['../../services/agent-locator', '../../services/graph-db',
'../../services/graph-service', '../../services/company-service']) {
delete require.cache[require.resolve(m)];
}
locator = require('../../services/agent-locator');
graphDb = require('../../services/graph-db');
graphService = require('../../services/graph-service');
const { CompanyService } = require('../../services/company-service');
companyService = new CompanyService();
// 前提:`_base` 三份文件在,且都带着哨兵。缺了的话下面的断言会退化成
// 「因为文件不存在所以算缺」的另一种通过 —— 那就白测了。
for (const rel of DEFINITION_FILES) {
const text = fs.readFileSync(path.join(AGENTS_DIR, '_base', rel), 'utf-8');
expect(text.includes(locator.BASE_SKELETON_SENTINEL), `_base/${rel} 没有哨兵`).toBe(true);
expect(text.trim().length, `_base/${rel} 是空的`).toBeGreaterThan(200);
}
// 骨架复位:cpSync 覆写,把上一次跑「填成真内容」的三份定义打回带哨兵的 `_base` 骨架
fs.cpSync(path.join(AGENTS_DIR, '_base'), bodyOf(SKELETON_ROLE), { recursive: true, force: true });
seedRowFromDisk(HIRE_ROLE);
// 固定 id 的代价:上一次跑要是崩在中途,公司目录会留在盘上,而 create() 撞见已存在的
// 目录会直接抛 —— 整个文件连一条用例都跑不了。所以建之前先清一次(清的是公司,不是本体)。
fs.rmSync(path.join(COMPANIES_DIR, CO), { recursive: true, force: true });
companyService.create({ id: CO, name: '哨兵闸门公司', description: '28 二轮修复测试' });
});
afterAll(() => {
// 本体不删(正式注册表条目);删的是公司侧目录 + 本体下的公司态子树
for (const id of [SKELETON_ROLE, HIRE_ROLE]) {
try { fs.rmSync(path.join(bodyOf(id), CO), { recursive: true, force: true }); } catch {}
}
try { fs.rmSync(path.join(COMPANIES_DIR, CO), { 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('刚从 _base 拷出来的本体:三份定义全部算缺(非空骗不过闸门)', () => {
// 三份文件都存在、都远不止 200 字节 —— 「非空即齐全」的旧判据会全部放行
for (const rel of DEFINITION_FILES) {
expect(fs.readFileSync(path.join(bodyOf(SKELETON_ROLE), rel), 'utf-8').trim().length)
.toBeGreaterThan(200);
}
expect(locator.missingBodyDefinition(SKELETON_ROLE)).toEqual(DEFINITION_FILES);
});
it('骨架本体不许转正;三份填成真内容之后才转正', () => {
graphService.ensureSystemAgentRow(CO, SKELETON_ROLE, SKELETON_ROLE, { status: 'draft' });
const blocked = graphService.promoteDraftIfComplete(SKELETON_ROLE);
expect(blocked.promoted).toBe(false);
expect(blocked.status).toBe('draft');
expect(blocked.missing).toEqual(DEFINITION_FILES);
fillWithRealContent(SKELETON_ROLE);
const promoted = graphService.promoteDraftIfComplete(SKELETON_ROLE);
expect(promoted.promoted).toBe(true);
expect(promoted.status).toBe('active');
expect(agentRow(SKELETON_ROLE).status).toBe('active');
});
it('端到端:雇一个只有骨架的角色,人不能当场上岗(库里仍是未毕业状态)', () => {
// 这条直接对应实测证据:修复前返回的是 {status:'hired_from_base', needsTraining:false}
// 且库里 status='active' —— 复制完通用骨架就"毕业"。
//
// 两条分支同一个不变式(本体常驻,第一次跑走前者、以后走后者):
// 首次 本体不存在 → 从 `_base` 毕业 → hired_from_base + 库里 draft
// 再跑 本体已在盘上(仍是骨架、哨兵还在)→ 闸门拦下 → body_not_ready + 库里 training
// 不变的是「缺哪三份说得出来 + 不许当场上岗」,这才是闸门要保证的东西。
const result = companyService.hire(CO, HIRE_ROLE, { type: 'skill', name: HIRE_ROLE });
expect(['hired_from_base', 'body_not_ready']).toContain(result.status);
expect(result.needsTraining).toBe(true);
expect(result.missing).toEqual(DEFINITION_FILES);
expect(graphService.isUngraduatedStatus(agentRow(HIRE_ROLE).status)).toBe(true);
// 骨架是真拷过去了 —— 停在 draft 不是因为文件没生成
for (const rel of DEFINITION_FILES) {
expect(fs.existsSync(path.join(bodyOf(HIRE_ROLE), rel)), rel).toBe(true);
}
});
});