agent-runtime-state.test.js
8.37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
/**
* 27-P3B §四 W13 —— agent 运行态文件不再进 git;模板使用计数不再改写人工清单。
*
* ── 这个测试锁的是哪个回归 ────────────────────────────────────────────────
* system agent 的 Claude CLI workDir **就是它自己的源码目录** `system/agents/<id>/`。
* 它每跑一次就重写自己的 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);
});
});