/** * 27-P3B §四 W13 —— agent 运行态文件不再进 git;模板使用计数不再改写人工清单。 * * ── 这个测试锁的是哪个回归 ──────────────────────────────────────────────── * system agent 的 Claude CLI workDir **就是它自己的源码目录** `system/agents//`。 * 它每跑一次就重写自己的 todo.md / plan.md,Claude CLI 还会改 * `.claude/scheduled_tasks.json`。这些文件过去是被 git 跟踪的,于是: * * ① 每次含 hired_from_base 的招聘都会让整仓 `git status` 冒出几条改动; * ② graph-engine 的边界守卫扫的就是整仓 git status,把这些改动归属到 * 「当时正在跑的那个 work 节点」头上 —— 一个真正成功的节点被判越界 fail。 * * `graph-engine.splitUnattributable()` 是**缓解**(把这类路径摘出去不判罚), * 本轮做的是**根治**:把它们移出 git 索引 + 写进 .gitignore。 * 两道防线都留着,所以这里锁根治那道 —— 只要有人把 todo.md 重新 `git add -f` * 回来,或者把 .gitignore 那几行删掉,这个测试就红。 * * 同类问题还有 `system/templates/_registry.json`:那是人工维护的模板清单, * 但 `company-service._updateTemplateUsedCount()` 每次 hire 都原地 +1 改写它, * 于是任何一次真跑都会留一条 `M _registry.json`。现在计数搬到 * `data/template-usage.json`(已 gitignore),registry 只留冻结的人工基线。 */ import { describe, it, expect, afterAll } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; const __dirname_ = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname_, '../../..'); const { CompanyService } = createRequire(import.meta.url)('../../services/company-service'); /** 这些路径下的文件是 agent 自己写的运行态,绝不能再被 git 跟踪 */ const RUNTIME_GLOBS = [ 'system/agents/*/todo.md', 'system/agents/*/plan.md', 'system/agents/*/.claude/scheduled_tasks.json', 'system/agents/*/tasks/*.json', ]; function git(args) { return execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf-8', maxBuffer: 8 * 1024 * 1024 }); } describe('W13 · agent 运行态文件已移出 git 跟踪', () => { it('git ls-files 里不再有 todo.md / plan.md / scheduled_tasks.json / tasks/', () => { const tracked = git(['ls-files', '--', ...RUNTIME_GLOBS]).split('\n').filter(Boolean); expect(tracked).toEqual([]); }); it('.gitignore 真的挡住这些路径(不是只 rm --cached 了事)', () => { // check-ignore 不要求文件存在,所以拿一个固定的假 agent 名探路即可。 const probes = [ 'system/agents/probe-agent/todo.md', 'system/agents/probe-agent/plan.md', 'system/agents/probe-agent/.claude/scheduled_tasks.json', 'system/agents/probe-agent/tasks/task_x.json', 'data/template-usage.json', ]; for (const p of probes) { let ignored = true; try { git(['check-ignore', '-q', '--', p]); } catch { ignored = false; // exit code 1 = 没被忽略 } expect(ignored, `${p} 应被 .gitignore 忽略`).toBe(true); } }); it('模板本体(system/templates/*/todo.md)仍然被跟踪 —— 那是 hire 的拷贝源,是源码', () => { const tracked = git(['ls-files', '--', 'system/templates/*/todo.md']).split('\n').filter(Boolean); expect(tracked.length).toBeGreaterThan(0); }); it('读侧对缺失容忍:loadFile 缺 todo.md 只返回空串,不抛', () => { // agent-chat.js:loadFile 的等价语义(readFileSync 包 try/catch)。这里直接验真实文件系统: // 一个不存在的 todo.md 读出来必须是「文件不存在」而不是别的异常形态。 const missing = path.join(REPO_ROOT, 'system/agents/__no_such_agent__/todo.md'); expect(fs.existsSync(missing)).toBe(false); let code = null; try { fs.readFileSync(missing, 'utf-8'); } catch (e) { code = e.code; } expect(code).toBe('ENOENT'); }); }); describe('W13 · 模板 usedCount 搬出 _registry.json(产生→落库→读回)', () => { const REGISTRY_PATH = path.join(REPO_ROOT, 'system/templates/_registry.json'); const USAGE_PATH = path.join(REPO_ROOT, 'data/template-usage.json'); const usageSnapshot = fs.existsSync(USAGE_PATH) ? fs.readFileSync(USAGE_PATH, 'utf-8') : null; afterAll(() => { if (usageSnapshot === null) fs.rmSync(USAGE_PATH, { force: true }); else fs.writeFileSync(USAGE_PATH, usageSnapshot, 'utf-8'); }); it('计数 +1 只写运行态文件,_registry.json 一个字节都不变', () => { const svc = new CompanyService(); const before = fs.readFileSync(REGISTRY_PATH, 'utf-8'); const baseline = JSON.parse(before).find((t) => t.id === 'base')?.usedCount || 0; const readBack0 = svc.listTemplates().find((t) => t.id === 'base'); expect(readBack0).toBeTruthy(); const shown0 = readBack0.usedCount; svc._updateTemplateUsedCount('base'); // ① 产生(hire() 命中模板分支时调的就是这个) svc._updateTemplateUsedCount('base'); // ② 落库:落在 data/ 下的运行态文件,不是人工清单 expect(fs.readFileSync(REGISTRY_PATH, 'utf-8')).toBe(before); expect(JSON.parse(fs.readFileSync(USAGE_PATH, 'utf-8')).base).toBe( (JSON.parse(usageSnapshot || '{}').base || 0) + 2, ); // ③ 读回:listTemplates = 人工基线 + 运行态增量,前端 badge 的数字不回退 const readBack = svc.listTemplates().find((t) => t.id === 'base'); expect(readBack.usedCount).toBe(shown0 + 2); expect(readBack.usedCount).toBeGreaterThanOrEqual(baseline); }); it('运行态文件缺失 / 损坏时退化成只用 registry 基线,不崩', () => { const svc = new CompanyService(); fs.writeFileSync(USAGE_PATH, '{ this is not json', 'utf-8'); expect(svc._readTemplateUsage()).toEqual({}); const baseline = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf-8')).find((t) => t.id === 'base')?.usedCount || 0; expect(svc.listTemplates().find((t) => t.id === 'base').usedCount).toBe(baseline); fs.rmSync(USAGE_PATH, { force: true }); expect(svc._readTemplateUsage()).toEqual({}); expect(svc.listTemplates().find((t) => t.id === 'base').usedCount).toBe(baseline); }); });