/** * 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 —— 它写着 "training"。 // 不加「只写不降级」的话,这里会把刚转正的行打回去,每次进程重启都重演一遍。 seed.seedAgents(); expect(statusOf()).toBe('active'); // 幂等:第二遍没有可转正的行 expect(seed.reconcileDanglingRows().graduated).toEqual([]); }); });