/** * 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, origin, legacy_path, status, created_at, updated_at) VALUES (?, ?, 'public', NULL, '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,origin,legacy_path,status,benchmark_score,created_at,updated_at) VALUES (?,?,'public',NULL,'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']); }); });