/** * 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 那几行删掉,这个测试就红。 * * 同类问题还有 `_registry.json`:那是人工维护的模板清单, * 但 `company-service._updateTemplateUsedCount()` 每次 hire 都原地 +1 改写它, * 于是任何一次真跑都会留一条 `M _registry.json`。现在计数搬到 * `data/template-usage.json`(已 gitignore),registry 只留冻结的人工基线。 * * ── 28 批次 1 之后这里改了什么 ────────────────────────────────────────── * `system/templates/` 整体并入 `system/agents/`,本体只剩一个注册表。 * 原来「模板本体的 todo.md 被跟踪、agent 本体的 todo.md 不被跟踪」这条按目录切的 * 分界线随之消失:合并后两者是**同一个路径**,而这个路径上跑着 agent。 * 所以从模板搬过来的 44 份 todo.md / plan.md 一并 `git rm --cached` 退出跟踪 * (文件仍留在磁盘上,`_copyTemplate` 读的是磁盘不是 git), * 否则 agent 一跑就重写它们 → 整仓 git status 变脏 → W13 那个 bug 原样复发。 * RUNTIME_GLOBS 因此加 `:(glob)` 前缀:git 的默认 pathspec 通配是跨 `/` 的, * 不加就会连 `youtube-crawler/workspace/todo.md` 这种子目录里的一起扫进来, * 而本条防线只管**本体根上**那两个文件。 */ 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 = [ ':(glob)system/agents/*/todo.md', ':(glob)system/agents/*/plan.md', ':(glob)system/agents/*/.claude/scheduled_tasks.json', ':(glob)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/ 下已无本体,拷贝源都在 system/agents/ 下(28 批次 1)', () => { // 断言「里面一个本体都没有」而不是「目录不存在」:批次 1 之前 // `company-service.rebuildTemplateRegistry()` 会把这个空壳目录 mkdir 回来 // (TEMPLATES_DIR 已改指 system/agents,现在不会了);留着宽一档的判据, // 别让一个空目录把这条防线变成噪声。 const legacy = path.join(REPO_ROOT, 'system/templates'); const leftovers = fs.existsSync(legacy) ? fs.readdirSync(legacy) : []; expect(leftovers).toEqual([]); expect(git(['ls-files', '--', 'system/templates']).trim()).toBe(''); // hire 的拷贝源(agent.md / config.json 这类真源码)仍被跟踪 —— 退出跟踪的只有 todo/plan const bodies = git(['ls-files', '--', ':(glob)system/agents/*/agent.md']).split('\n').filter(Boolean); expect(bodies.length).toBeGreaterThan(30); }); 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/agents/_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'); }); // 载体换成 qa-judge:28 批次 1 把 base 改名成保留 id `_base`,而 listTemplates() // 按约定过滤掉 `_` 开头的目录(它不是可雇的角色)。计数链路本身与用哪个模板无关。 const TPL = 'qa-judge'; 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 === TPL)?.usedCount || 0; const readBack0 = svc.listTemplates().find((t) => t.id === TPL); expect(readBack0).toBeTruthy(); const shown0 = readBack0.usedCount; svc._updateTemplateUsedCount(TPL); // ① 产生(hire() 命中模板分支时调的就是这个) svc._updateTemplateUsedCount(TPL); // ② 落库:落在 data/ 下的运行态文件,不是人工清单 expect(fs.readFileSync(REGISTRY_PATH, 'utf-8')).toBe(before); expect(JSON.parse(fs.readFileSync(USAGE_PATH, 'utf-8'))[TPL]).toBe( (JSON.parse(usageSnapshot || '{}')[TPL] || 0) + 2, ); // ③ 读回:listTemplates = 人工基线 + 运行态增量,前端 badge 的数字不回退 const readBack = svc.listTemplates().find((t) => t.id === TPL); 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 === TPL)?.usedCount || 0; expect(svc.listTemplates().find((t) => t.id === TPL).usedCount).toBe(baseline); fs.rmSync(USAGE_PATH, { force: true }); expect(svc._readTemplateUsage()).toEqual({}); expect(svc.listTemplates().find((t) => t.id === TPL).usedCount).toBe(baseline); }); });