agent-runtime-state.test.js
6.47 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
/**
* 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 那几行删掉,这个测试就红。
*
* 同类问题还有 `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);
});
});