graduation-criteria-reality.test.js
12.4 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
/**
* Unit Tests — 毕业闸门的判据必须与**仓库现实**对得上(28 三轮复验,问题 1)
*
* 闸门(agent-locator.missingBodyDefinition)原来要求三件齐全:
* `agent.md` + `soul.md` + `.claude/skills/main_skill/SKILL.md`。而实测:
* `system/agents/` 下带 agent.md 的本体里,带 `main_skill/SKILL.md` 的**只有个位数** ——
* 也就是说这份文件从来不是本体的组成部分,判据把「绝大多数在库本体」判成了没资格上岗。
*
* 它没有立刻炸,是因为闸门只作用于 draft / training 两个状态,大部分本体侥幸落在别的状态上。
* 但它**已经卡死了一个真本体**:`code-planner` 的 config.json 写着 status='training',
* `seedAgents()` 原样 upsert 进库,于是 hire() 阶段 A 对它恒返回 body_not_ready ——
* 现役的 p2-verify 只是靠 `company_agents` 已有行走 `already_exists` 分支绕过去了,
* **任何新建的公司都再也雇不到它**。用户马上要跑的「新建公司」E2E 正撞在这条上。
*
* 三条断言的分工(都必须**先证明样本非空**,否则空遍历会让全部断言表演式通过):
* ① 语料规模 —— 少于 40 个本体说明扫描根找错了地方,后面两条一律不算数;
* ② 判据确实放宽了 —— 「判据下齐全、盘上却没有 main_skill/SKILL.md」的本体有几十个。
* 旧判据下这个集合**恒为空**(齐全 ⊆ 带 SKILL.md),所以改回去必红。
* ③ 缺的一定是必需件 —— 独立重算一遍「哪些本体的 agent.md / soul.md 读不到、是空白、
* 或仍带 `_base` 哨兵」,与闸门的输出逐个比对。只断言比例的话,判据放成
* 「永远齐全」也能通过。
*
* ⚠️ 断言一律取**下界 / 集合相等**,不取「占总数的百分之几」:`system/agents/` 是会长的
* ——单测自己就会往里毕业出常驻本体(28 号裁决:测试建的 agent 是正式注册表条目,不删)。
* 比例式断言会随着本体变多而无缘无故变红,而它红的时候什么缺陷都没发生。
*
* 最后单独锁 `code-planner` 能不能被雇:走的是 hire() 真正用的那两个函数
* (promoteDraftIfComplete + isUngraduatedStatus),DB 指向一次性 sqlite,不碰生产库。
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
const REPO_ROOT = path.resolve(__dirname, '../../..');
const AGENTS_DIR = path.join(REPO_ROOT, 'system', 'agents');
const SKILL_REL = path.join('.claude', 'skills', 'main_skill', 'SKILL.md');
const locator = require('../../services/agent-locator');
/** 与 graph-seed-service.isAgentBodyDir 同一条判据,这里独立实现一遍(不 import,免得一起错) */
const bodyIds = fs
.readdirSync(AGENTS_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory() && !d.name.startsWith('_') && !d.name.startsWith('.'))
.filter((d) => fs.existsSync(path.join(AGENTS_DIR, d.name, 'agent.md')))
.map((d) => d.name);
/**
* ⚠️ 本文件遍历的是**真实的、别的测试文件正在写的** `system/agents/`。
*
* 28 号裁决把本体钉成了常驻的正式注册表条目,于是 `agent-body-draft-gate.test.js`
* 这类用例只能**就地改本体的定义文件**来摆出要测的形态(把 `zz-draft-role` 的三份定义
* 填满 → 把 soul.md 抹空 → 再填回去)。vitest 默认按文件并行,那个文件与本文件跑在
* 两个 worker 里,所以「判据说齐全」与「独立重算说缺」这两次读之间,同一个本体的字节
* 完全可能被改掉 —— 实测 8 次全量跑里稳定复现 1 次:
* AssertionError: expected [ 'zz-draft-role' ] to deeply equal []
* 红的时候判据一点毛病没有,纯粹是两次读看到了不同的磁盘。
*
* 「本体不再被删除、遍历型断言改成对增长不敏感」只治了**数量**这一维,治不了**内容**
* 这一维:本用例比的是两个实现对同一份字节的结论,字节在两次读之间变了,比对就失去意义。
*
* 所以这里把每个本体的读做成一个可自证的窗口:**先快照 → 跑判据 → 再快照**,
* 两次快照不一致 = 这份定义在窗口内被别的 worker 改过,本轮不参与比对(记进 churned)。
* 这不是把断言放宽:字节没变过的本体一个都不放过,且两侧仍是各自独立的实现,
* 只是都落在**同一份被证明没变过的内容**上。放过的仅限「证据本身在被改写」的那一小撮。
*/
const DEF_RELS = ['agent.md', 'soul.md', SKILL_REL];
/** 读不到用一个正文里不可能出现的哨兵表示,才能把「文件消失了」也算进字节变化 */
const ABSENT = '\u0000ABSENT';
const readDefs = (id) => DEF_RELS.map((rel) => {
try { return fs.readFileSync(path.join(AGENTS_DIR, id, rel), 'utf-8'); } catch { return ABSENT; }
});
const CHURNED = [];
/** 每项 { id, missing, defs }:defs 是被证明在判据执行期间没变过的三份定义原文 */
const BODIES = [];
for (const id of bodyIds) {
const before = readDefs(id).join('\u0001');
const missing = locator.missingBodyDefinition(id);
const defs = readDefs(id);
if (before !== defs.join('\u0001')) { CHURNED.push(id); continue; }
BODIES.push({ id, missing, defs });
}
/** SKILL.md 在不在盘上 —— 取自同一份快照,不再单独 existsSync(那又是一次新的读) */
const hasSkill = (b) => b.defs[2] !== ABSENT;
/** 必需件出问题:读不到 / 空白 / 仍带 `_base` 哨兵。与闸门各写各的,只共用被证明稳定的字节 */
const requiredBroken = (b) =>
[0, 1].some((i) => b.defs[i] === ABSENT || b.defs[i].trim() === '' || b.defs[i].includes(locator.BASE_SKELETON_SENTINEL));
describe('毕业闸门 × 仓库实测现状', () => {
it('① 语料非空:system/agents/ 下至少 40 个本体(否则下面两条是空跑)', () => {
expect(bodyIds.length).toBeGreaterThanOrEqual(40);
// 跳过的必须是极少数:并行写只碰得到几个 zz-* 测试本体。要是 churned 大到把语料吃空,
// 下面两条会退化成空跑而照样绿 —— 那正是本文件开头警告过的失效形态。
expect(BODIES.length).toBeGreaterThanOrEqual(40);
expect(CHURNED.length).toBeLessThan(5);
});
it('② 判据下齐全、却没有 SKILL.md 的本体有几十个 —— 判据放宽的直接证据', () => {
const withSkill = BODIES.filter(hasSkill);
const complete = BODIES.filter((b) => b.missing.length === 0);
const completeWithoutSkill = complete.filter((b) => !hasSkill(b));
// 把 SKILL.md 当必需件时,这个集合必然是空的 —— 改回去当场红。
// 取下界而不是比例:新增本体只会让它变大,永远不会因为「仓库长大了」而红。
// 实测(2026-08-18):74 个本体里 61 个判据下齐全,其中 56 个盘上没有 SKILL.md。
expect(completeWithoutSkill.length).toBeGreaterThanOrEqual(40);
expect(complete.length).toBeGreaterThan(withSkill.length);
});
it('③ 判成「缺」的本体,没有一个是「只是盘上没有 SKILL.md」', () => {
const incomplete = BODIES.filter((b) => b.missing.length > 0);
// 被放宽掉的正是这一条:SKILL.md **不存在**不算缺。所以「缺的全部理由都是它、
// 而它压根不在盘上」的本体必须一个都没有 —— 把 SKILL.md 算回必需件,
// 上面那 56 个本体会一起落进这个集合。
const blamedOnAbsentSkill = incomplete
.filter((b) => !hasSkill(b) && b.missing.every((rel) => rel === SKILL_REL))
.map((b) => b.id);
expect(blamedOnAbsentSkill).toEqual([]);
// 反向:必需件在盘上出问题(读不到 / 空白 / 仍带 `_base` 哨兵)的本体,闸门一个都不许放过。
// 独立重算,不复用闸门的实现;哨兵这一支不能省 —— 从 `_base` 毕业出来、agent-creator
// 还没填过的骨架本体,三份文件都在且远不止一行,只看「存不存在」会把它算成齐全。
const missingRequiredOnDisk = BODIES.filter(requiredBroken).map((b) => b.id);
const incompleteIds = incomplete.map((b) => b.id);
expect(missingRequiredOnDisk.filter((id) => !incompleteIds.includes(id))).toEqual([]);
// 样本非空:实测有一批本体缺 soul.md(6 个 devloop-* + org-coder / org-writer)
// 或仍是骨架(单测毕业出来的)。这条要是变成 0,说明判据被放成了「永远齐全」,
// 上面两条断言也就不作数了。这 8 个是没有任何测试会去改的常驻本体,
// 所以它们绝不会被 churned 过滤掉 —— 样本非空这一条不依赖并发时序。
expect(missingRequiredOnDisk.length).toBeGreaterThan(0);
});
});
/**
* `code-planner` 是本轮真正被卡死的那个本体(config.json: status='training',
* 定义只有 agent.md + soul.md,没有 SKILL.md)。它是未入库的运行时产物(git 未跟踪),
* 干净 clone 上不存在 —— 所以按存在与否跳过,而不是假装它一定在。
* 上面 ② 的不等式在任何 clone 上都会红,不依赖这一条。
*/
const CODE_PLANNER_DIR = path.join(AGENTS_DIR, 'code-planner');
describe.skipIf(!fs.existsSync(CODE_PLANNER_DIR))('code-planner 能被新公司雇到', () => {
const TEST_DB_PATH = '/tmp/test-graduation-criteria-reality.sqlite';
const MODULES = ['../../services/graph-db', '../../services/graph-service', '../../services/graph-seed-service'];
let graphService;
let seed;
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 MODULES) delete require.cache[require.resolve(m)];
seed = require('../../services/graph-seed-service');
graphService = require('../../services/graph-service');
// 扫真实的 system/agents/(GRAPH_SEED_ROOT 不设)→ 一次性 sqlite。
// 生产库不受影响,但入库的是真实的 config.json,包括 code-planner 那句 "training"。
seed.runStartupSeed();
});
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;
for (const m of MODULES) delete require.cache[require.resolve(m)];
});
it('本体定义按新判据是齐全的(缺的只可能是 SKILL.md,而它压根不存在)', () => {
expect(fs.existsSync(path.join(CODE_PLANNER_DIR, SKILL_REL))).toBe(false);
expect(locator.missingBodyDefinition('code-planner')).toEqual([]);
});
it('hire() 用的那道闸门放行:status 不再停在 training', () => {
// hire() 阶段 A 的两行原样复刻:先现场校验触发转正,再问「是不是未毕业状态」。
const gate = graphService.promoteDraftIfComplete('code-planner');
expect(gate.missing).toEqual([]);
expect(graphService.isUngraduatedStatus(gate.status)).toBe(false);
});
it('存量对账(reconcile 的 E 分支)自己就能把它转正,且转正不会被 seedAgents 打回去', () => {
const db = require('../../services/graph-db').getDb();
const statusOf = () =>
db.prepare(`SELECT status FROM system_agents WHERE id = 'code-planner'`).get().status;
// 打回「上一轮遗留」的形态:库里 training、磁盘上定义齐全
db.prepare(`UPDATE system_agents SET status = 'training' WHERE id = 'code-planner'`).run();
expect(statusOf()).toBe('training');
const r = seed.reconcileDanglingRows();
expect(r.graduated).toContain('code-planner');
expect(statusOf()).toBe('active');
// seedAgents 会把 config.json 的 status 原样 upsert。「只写不降级」守的是「不许被
// config.json 推回 draft/training」,**不**保证转正后的值原样不变——config.json 当前
// 写的是什么已毕业状态("enabled" 也好、别的也好,只要不是 draft/training),upsert 就会
// 用那个值覆盖,这是设计如此,不是本用例要测的东西(2026-08-30 复核:config.json 已经从
// 写死的 "training" 改成了 "enabled",不再硬编码具体值,避免这条测试再次因为 config.json
// 内容变化而假红)。
seed.seedAgents();
expect(graphService.isUngraduatedStatus(statusOf())).toBe(false);
// 幂等:第二遍没有可转正的行
expect(seed.reconcileDanglingRows().graduated).toEqual([]);
});
});