/** * 28 号改造的**终验闸门** —— 「新建公司」主线用**真本体**从头到尾走一遍。 * * ── 为什么还要这一份(三条修复各自都已经有单测了)──────────────────────── * 已有的那些单测一律用**临时合成本体**(`fs.writeFileSync(BODY_DIR/agent.md, '# 临时本体')`): * 隔离得干净,但也因此把「真本体长什么样」这一维整个抹掉了。三条修复恰好全都 * 只在真本体上才显形: * ① 毕业闸门(SKILL.md 改成可选)—— 合成本体想让它齐全就让它齐全, * 而真的卡死过的是 `code-planner`:config.json 写着 `status:'training'`、 * 磁盘上有 agent.md + soul.md、**没有** `.claude/skills/main_skill/SKILL.md`。 * 把 SKILL.md 算回必需件,这一条当场红,且症状正是 E2E 主线上那句 * 「新公司再也雇不到 code-planner」。 * ② 边界守卫的豁免树 —— 合成本体也能造出 `?? system/agents/xxx/`, * 但只有走真的 `hireFromBaseMetaAgent` 才知道 porcelain 里冒出来的是 * **一整条目录项**(而不是五个文件名)。 * ③ 交付物归宿 —— 合成本体的正文是一行标题,**永远数不出第二条写入指令**。 * 真本体(ceo)正文里带着一句对坐标声明的**引用**(「你的交付物必须写到」那一行), * 于是「指令」和「引用」必须分开数;而引用一旦指向一个提示词里不存在的段, * agent 就是找不到落点且不报错 —— 这条悬空只有拿真本体拼真提示词才看得见。 * * ── 隔离手法 ──────────────────────────────────────────────────────────── * GRAPH_DB_PATH 指临时库(先设再 require),仓库 data/graph.sqlite 全程不被碰。 * 公司目录与公司态目录用固定 id,afterAll 删干净;毕业出来的那份新本体**留着** * (28 号裁决:测试建出来的 agent 是正式注册表条目)——所以 id 不能带时间戳, * 否则每跑一次就往注册表里多堆一个没人认领的僵尸。 * 读的既有本体是仓库里真的那几份,**只读不写**。 */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; import path from 'path'; const locator = require('../../services/agent-locator'); const AgentManager = require('../../src/core/agent-manager'); const { GRAPH_WRITE_BOUNDARY_HEADER } = require('../../src/core/prompt-builder'); const REPO = locator.REPO_ROOT; const TEST_DB_PATH = '/tmp/test-company-create-mainline.sqlite'; const CID = 'zz-mainline-co'; /** 主线上必须能雇进来的真本体。code-planner 是 §2 那条 training 死锁的当事人。 */ const REAL_BODIES = ['ceo', 'code-planner']; /** 第一次跑时注册表里没有的角色 → 走 create-agent 毕业路径,产物此后常驻 */ const NEW_ROLE = 'zz-mainline-role'; /** * 「你必须把交付物写到 X」这类**归宿指令**。两条链路各有各的措辞: * 前者是坐标声明(agent-chat 链路),后者是 describeWriteBoundary(graph 链路)。 * 必须锚在行首的列表项上 —— 本体正文里那句「…里『你的交付物必须写到』那一行」是 * **引用**不是指令,不锚行首就会把它一起数进来,把健康的提示词误判成两个归宿。 */ const WRITE_MANDATE = /(?:^|\n)-?\s*(?:你的交付物必须写到:|一切代码 \/ 测试 \/ 配置 \/ 交付物都写在)/g; /** 本体正文对坐标声明那一行的引用(带书名号的才是引用) */ const MANDATE_QUOTE = /「你的交付物必须写到」/g; const countOf = (re, s) => (s.match(re) || []).length; describe('28 终验 · 「新建公司」主线(真本体)', () => { let svc; let listCompanyAgents; let engine; let db; let manager; let hires; let newHire; 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 [ '../../services/graph-db', '../../services/graph-service', '../../services/org-graph-service', '../../services/work-graph-db', '../../services/graph-run-sse', '../../services/graph-engine', '../../services/company-service', ]) { delete require.cache[require.resolve(m)]; } ({ getDb: db } = require('../../services/graph-db')); engine = require('../../services/graph-engine'); ({ CompanyService: svc, listCompanyAgents } = require('../../services/company-service')); svc = new svc(); // 库里先照 `seedAgents()` 的做法把两个真本体的 config.json.status 原样入库 —— // 临时库是空的,不 seed 的话 promoteDraftIfComplete 返回 status='unknown', // 闸门根本不参与判断,①那条就等于没测(`code-planner` 的 'training' 正是闸门的入口)。 const now = new Date().toISOString(); const ins = db().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', ?, ?, ?, ?) `); for (const id of REAL_BODIES) { const cfg = JSON.parse(fs.readFileSync(path.join(locator.bodyDir(id), 'config.json'), 'utf-8')); ins.run(id, id, `system/agents/${id}`, cfg.status || 'active', now, now); } // 固定 id 的代价:上一次跑要是崩在中途,公司目录会留在盘上,而 create() 撞见已存在的 // 目录会直接抛 —— 整个文件连一条用例都跑不了。所以建之前先清一次(清的是公司,不是本体)。 fs.rmSync(path.join(REPO, 'data', 'companies', CID), { recursive: true, force: true }); svc.create({ id: CID, name: '主线终验公司', goal: '验证建司主线', description: 'throwaway' }); hires = Object.fromEntries(REAL_BODIES.map((r) => [r, svc.hire(CID, r)])); // 直接调毕业分支:本体常驻之后 hire() 第二次跑会(正确地)走复用分支, // 而②锁的是「刚拷完 `_base` 骨架的新本体仍然不许上岗」。毕业幂等(_copySkeleton 覆写)。 newHire = svc.hireFromBaseMetaAgent(CID, NEW_ROLE, { type: 'skill', name: NEW_ROLE }); manager = new AgentManager({}, REPO); }); afterAll(() => { fs.rmSync(path.join(REPO, 'data', 'companies', CID), { recursive: true, force: true }); // applyBoundaryGuard 里的 timelineNote 认不出这个捏造的 runId,按 graph-run-sse 的 // 兜底把 timeline 落到 `data/companies/_unknown/runs/{runId}/`。留着的话它会出现在 // 控制台的公司列表里(svc.list() 扫的就是 data/companies/ 下的目录)。 fs.rmSync(path.join(REPO, 'data', 'companies', '_unknown', 'runs', 'mainline-run'), { recursive: true, force: true }); // 本体不删(正式注册表条目),只清它下面这次跑写出来的公司态 fs.rmSync(locator.companyDir(NEW_ROLE, CID), { recursive: true, force: true }); for (const id of REAL_BODIES) fs.rmSync(locator.companyDir(id, CID), { recursive: true, force: true }); 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 ['../../services/graph-run-sse', '../../services/graph-engine', '../../services/company-service']) { delete require.cache[require.resolve(m)]; } }); // ① 毕业闸门 —— 把 SKILL.md 算回必需件,这三条一起红 it('真本体(含 config.json 写着 training 的 code-planner)都雇得进来', () => { // 前提坐实:code-planner 磁盘上确实没有 SKILL.md,所以这条不是「碰巧齐全」 expect(fs.existsSync(path.join(locator.bodyDir('code-planner'), '.claude/skills/main_skill/SKILL.md'))).toBe(false); for (const r of REAL_BODIES) { expect(hires[r].status, `${r} 被 body_not_ready 挡住了:缺 ${JSON.stringify(hires[r].missing)}`) .not.toBe('body_not_ready'); } }); // ② 哨兵闸门不许被①顺手放宽掉:刚拷完 `_base` 骨架的新本体仍然不许上岗 it('注册表里没有的角色走 create-agent 路径:骨架齐全但停在 draft', () => { const body = locator.bodyDir(NEW_ROLE); expect(fs.existsSync(path.join(body, 'agent.md'))).toBe(true); expect(fs.existsSync(path.join(body, 'config.json'))).toBe(true); expect(newHire.needsTraining).toBe(true); expect(db().prepare('SELECT status FROM system_agents WHERE id = ?').get(NEW_ROLE).status).toBe('draft'); }); // ③ 边界守卫:招聘窗口内新建的整条本体目录不算越界 it('新建本体的 porcelain 目录项进 unattributable,节点不被降级成 fail', () => { const before = ''; const after = [ `?? system/agents/${NEW_ROLE}/`, // hireFromBaseMetaAgent 造的整条目录项 `M system/agents/ceo/todo.md`, // agent 改自己的运行态文件(修改类,两字符状态位长度不同) ].join('\n'); const { escapes, unattributable } = engine.splitUnattributable(engine.diffRepoStatus(before, after)); expect(escapes).toEqual([]); expect(unattributable).toHaveLength(2); const ctx = { nodeRunId: 'mainline-node', attempt: 1, projectRoot: null, runId: 'mainline-run' }; const outcome = engine.applyBoundaryGuard(ctx, before, { status: 'success', output: 'ok' }, after); expect(outcome.status).toBe('success'); expect(outcome.failReason).toBeUndefined(); }); it('落点:公司不持有 agent 实体,公司态在本体目录下', () => { expect(fs.existsSync(path.join(REPO, 'data', 'companies', CID, 'agents'))).toBe(false); const ceoState = locator.companyDir('ceo', CID); for (const f of ['company-context.md', 'user.md', 'memory']) { expect(fs.existsSync(path.join(ceoState, f)), `公司态缺 ${f}`).toBe(true); } expect(listCompanyAgents(CID).map((r) => r.system_agent_id).sort()) .toEqual([...REAL_BODIES, NEW_ROLE].sort()); // 引用制:除了新造的那一个,不给这家公司新增任何私有本体 expect(db().prepare('SELECT id FROM system_agents WHERE owner_company_id = ?').all(CID).map((r) => r.id)) .toEqual([NEW_ROLE]); }); // ④ 交付物归宿:一段提示词里只准有一个家,且本体正文引用的那一行必须真的在 it('agent-chat 链路:一条写入指令,本体正文的引用有着落', () => { const addr = `${CID}:ceo`; const { agent } = manager.getAgentConfig(addr, { metadata: { taskId: 'mainline-conv-1' } }); const prompt = manager.buildFullPrompt(addr, '给公司写一份第一周的启动计划', agent); expect(prompt).toContain('# 运行时坐标(绝对路径,以此为准)'); expect(countOf(WRITE_MANDATE, prompt)).toBe(1); // ceo 的 agent.md 正文引用了坐标声明那一行;引用存在 = 那一行必须也存在, // 否则 agent 照着「去找那一行」找不到,写哪儿全凭它自己猜,且没有任何报错。 expect(countOf(MANDATE_QUOTE, prompt)).toBeGreaterThan(0); expect(prompt).toMatch(/- 你的交付物必须写到:\/.+\/workspace\//); // 本体正文与公司语境都真的装进来了(少装一段不报错,只是 agent 变成通用 Base Agent) expect(prompt).toContain('# CEO Agent'); expect(prompt).toContain(CID); }); it('graph 节点链路:归宿由写入边界段说了算,不悬空引用 chat 链路那一行', () => { const addr = `${CID}:code-planner`; const task = `${GRAPH_WRITE_BOUNDARY_HEADER}\n一切代码 / 测试 / 配置 / 交付物都写在项目根 /tmp/fake-project 内。\n\n把登录接口实现了。`; const { agent } = manager.getAgentConfig(addr, { metadata: { taskId: 'mainline-node-1' } }); const prompt = manager.buildFullPrompt(addr, task, agent); expect(countOf(WRITE_MANDATE, prompt)).toBe(1); expect(prompt).toContain('本节不另给归宿'); // graph 链路的坐标声明**不发**「你的交付物必须写到」那一行,所以本体正文里 // 但凡留着对它的引用就是悬空的 —— 这正是 40 份本体改写时按链路分叉的理由。 expect(countOf(MANDATE_QUOTE, prompt)).toBe(0); }); it('运行态坐标:cwd 是任务态目录,本体在 ../..,产物不落 system/', () => { const addr = `${CID}:code-planner`; const { agent } = manager.getAgentConfig(addr, { metadata: { taskId: 'mainline-node-2' } }); expect(agent.workDir).toBe(locator.taskDir('code-planner', CID, 'mainline-node-2')); expect(path.relative(agent.workDir, agent.bodyDir)).toBe(path.join('..', '..')); }); });