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