Skip to content

Commit 2031602

Browse files
authored
Merge pull request #3 from pingp76/codex/eval-artifact-cleanup
add eval artifact cleanup
2 parents d3c7ba9 + 66c46df commit 2031602

11 files changed

Lines changed: 724 additions & 7 deletions

File tree

‎doc/summary.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ GitHub: https://github.com/pingp76/swoopcode
1010

1111
## 当前状态
1212

13-
**已完成阶段**: 基础 REPL + LLM 对话 + bash 工具调用 + 文件操作工具 + 消息标准化 + TODO 任务管理 + 子智能体(SubAgent)+ Skill(技能)系统 + LLM 通信日志 + 上下文压缩 + 权限管理 + Hook 机制 + Memory(长期记忆)+ **Prompt Cache 友好的请求布局** + LLM 错误恢复 + ProjectContext + Session/Transcript 原始事件流 + 持久化 Task 任务系统 + Async Run 非阻塞运行实例 + **Schedule 定时运行系统** + OutputStore 输出句柄 + 安全精确编辑 + 时间语义收口 + Runtime Hardening Round A(原子写与日志轮转)+ 教学注释增强(实现路径注释补齐)+ **PDD-16:模型适配与 Agent Runtime Policy 抽象层**(Provider Profile + Foundation Model Profile + Runtime Policy + LLM Adapter + Context Budget + Stable Context Manager + ContextRanker + RepoClassifier + TaskIntentClassifier)+ **PDD-17:Eval Harness 基础框架**(Eval Core、deterministic suite、real core tools、CLI driver)+ **PDD-18:Replay/Live/Judge/Full-tools Eval**(replay、live smoke、judge/report、live regression、full-tools live E2E)+ **PDD-19:MCP 与 Agent Team Eval Harness Prototype**(prototype suites 默认 skipped,避免误读为生产能力)+ **网页版教程雏形**(`tutorial/` 静态站点 + 第 00/01 章 + `web/temp/2/` 风格三栏阅读布局)+ **公开版 PDD 整理**(`doc/pdd-01-*.md` 到 `doc/pdd-19-*.md`,保留原始 PDD 深度,旧 refactor 工作记录已合并回对应 PDD)
13+
**已完成阶段**: 基础 REPL + LLM 对话 + bash 工具调用 + 文件操作工具 + 消息标准化 + TODO 任务管理 + 子智能体(SubAgent)+ Skill(技能)系统 + LLM 通信日志 + 上下文压缩 + 权限管理 + Hook 机制 + Memory(长期记忆)+ **Prompt Cache 友好的请求布局** + LLM 错误恢复 + ProjectContext + Session/Transcript 原始事件流 + 持久化 Task 任务系统 + Async Run 非阻塞运行实例 + **Schedule 定时运行系统** + OutputStore 输出句柄 + 安全精确编辑 + 时间语义收口 + Runtime Hardening Round A(原子写与日志轮转)+ 教学注释增强(实现路径注释补齐)+ **PDD-16:模型适配与 Agent Runtime Policy 抽象层**(Provider Profile + Foundation Model Profile + Runtime Policy + LLM Adapter + Context Budget + Stable Context Manager + ContextRanker + RepoClassifier + TaskIntentClassifier)+ **PDD-17:Eval Harness 基础框架**(Eval Core、deterministic suite、real core tools、CLI driver)+ **PDD-18:Replay/Live/Judge/Full-tools Eval**(replay、live smoke、judge/report、live regression、full-tools live E2E)+ **PDD-19:MCP 与 Agent Team Eval Harness Prototype**(prototype suites 默认 skipped,避免误读为生产能力)+ **Eval 临时产物 TTL 清理**(失败保留 manifest + 白名单 GC CLI + trace 文件清理)+ **网页版教程雏形**(`tutorial/` 静态站点 + 第 00/01 章 + `web/temp/2/` 风格三栏阅读布局)+ **公开版 PDD 整理**(`doc/pdd-01-*.md` 到 `doc/pdd-19-*.md`,保留原始 PDD 深度,旧 refactor 工作记录已合并回对应 PDD)
1414

1515
- **PDD-17 Eval Harness 基础能力**:Eval Core + Deterministic Suite + Real Core Tools + CLI Driver。
1616
- **PDD-18 Eval 回归能力**:Replay + Live Smoke + Judge/Report;Live Regression — Core Tools;Full-tools Live E2E。
1717
- **PDD-19 Eval Prototype 边界**:MCP fixture server + MCP runtime adapter + MCP trace/assertions;顺序 supervisor Team driver + Team trace/assertions;由于项目尚未实现生产级 MCP runtime / 真实 Agent Team runtime,相关 MCP/Team 测试当前全部 `describe.skip`。
18+
- **Eval 临时产物清理**:默认通过 case 结束即删除 workspace;`keepOnFailure` 保留失败 workspace / agentHome 时写入 `.eval-artifact.json`;`npm run eval:cleanup` 按白名单前缀和 TTL 清理 OS tmpdir 中的 eval 残留,并清理过期 `*.trace.json`。
1819

1920
## 网页版教程站点雏形
2021

@@ -150,7 +151,8 @@ src/
150151
│ │ ├── trace.ts # TraceRecorder、RuntimeEvent
151152
│ │ ├── assertions.ts # portable + instrumented assertion 执行器
152153
│ │ ├── runner.ts # runEvalCase/runEvalSuite 核心 runner
153-
│ │ └── trace-writer.ts # JSON trace 输出
154+
│ │ ├── trace-writer.ts # JSON trace 输出
155+
│ │ └── temp-cleanup.ts # Eval 临时产物 TTL manifest 与白名单清理
154156
│ ├── drivers/
155157
│ │ ├── learn-claude-code/
156158
│ │ │ ├── in-process-driver.ts # 当前项目 createAgent() driver
@@ -195,6 +197,7 @@ src/
195197
│ ├── replay/
196198
│ │ └── replay-llm.ts # Replay LLM client
197199
│ ├── runner.test.ts # core + in-process driver 集成测试
200+
│ ├── cleanup-cli.ts # Eval 临时产物清理 CLI
198201
│ └── README.md # Eval 系统使用文档
199202
skills/
200203
├── code-review/

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"test:eval:live:team": "vitest run src/eval/live/live-team-suite.test.ts",
4242
"test:eval:live:team:mcp": "vitest run src/eval/live/live-team-suite.test.ts",
4343
"test:eval:judge": "vitest run src/eval/judge/judge-suite.test.ts",
44+
"eval:cleanup": "tsx src/eval/cleanup-cli.ts",
4445
"typecheck": "tsc --noEmit",
4546
"lint": "eslint src/",
4647
"format": "prettier --write \"src/**/*.ts\"",

‎src/eval/README.md‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,20 @@ npm run test:eval
1010

1111
# 运行所有 eval 相关测试(含 runner 集成测试)
1212
npx vitest run src/eval/
13+
14+
# 清理过期 eval 临时产物(默认 7 天)
15+
npm run eval:cleanup
16+
17+
# 先预览会删除什么
18+
npm run eval:cleanup -- --dry-run
1319
```
1420

1521
## 设计原则
1622

1723
- **确定性**:所有 case 使用 scripted LLM,不依赖真实模型,确保任何环境都能稳定通过
1824
- **可移植**:Eval Core 不直接依赖当前项目内部模块(agent.ts、llm.ts 等),只认识 `CodingAgentDriver` 接口
1925
- **可观测**:通过 instrumented assertions 验证工具调用、权限确认等内部行为
20-
- **隔离性**:每个 case 在独立临时 workspace 中运行,自动清理
26+
- **隔离性**:每个 case 在独立临时 workspace 中运行,默认自动清理;失败调试产物通过 TTL manifest 和 `npm run eval:cleanup` 定期回收
2127

2228
## Case 结构
2329

@@ -200,6 +206,31 @@ EVAL_TRACE_DIR=./eval-traces npm run test:eval
200206

201207
Trace 文件包含:case 信息、步骤痕迹、runtime events、断言结果。
202208

209+
## 临时产物清理
210+
211+
Eval 会创建三类临时产物:
212+
213+
1. **workspace**:每个 case 的隔离工作目录,默认通过后立即删除
214+
2. **agentHome**:full-tools / team eval 的临时持久化根目录,保存 Memory、Skill、Task、Schedule、Output 等状态
215+
3. **trace**:开启 `trace.enabled` 或 `EVAL_TRACE_DIR` 后写出的 `*.trace.json`
216+
217+
当 case 设置 `workspace.keepOnFailure: true` 且运行失败时,runner 会保留 workspace;full-tools / team driver 也会同步保留自己的临时 `agentHome`。这些目录会写入 `.eval-artifact.json`,记录 `caseId`、`createdAt` 和 `expiresAt`,便于后续清理。
218+
219+
定期清理命令:
220+
221+
```bash
222+
# 删除默认 OS tmpdir 下超过 7 天的 eval 产物
223+
npm run eval:cleanup
224+
225+
# CI 中常用:删除超过 24 小时的残留
226+
npm run eval:cleanup -- --older-than 24h
227+
228+
# 本地接入前先预览
229+
npm run eval:cleanup -- --dry-run
230+
```
231+
232+
清理器只扫描白名单前缀(如 `eval-`、`learn-claude-eval-home-`、`learn-claude-team-home-` 等),不会递归扫描任意临时目录。`eval-traces` 目录中只删除过期的 `*.trace.json` 文件,避免误删手工放入的其他说明文件。
233+
203234
## 编写 Core Tool Case 的注意事项
204235

205236
1. **Scripted LLM Responses**:每个 tool call 需要至少 2 个 responses

‎src/eval/cleanup-cli.ts‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* cleanup-cli.ts — Eval 临时产物清理命令
3+
*
4+
* 职责:把 temp-cleanup.ts 暴露成 npm 脚本入口,便于本地和 CI 定期执行。
5+
*
6+
* 用法:
7+
* - npm run eval:cleanup
8+
* - npm run eval:cleanup -- --older-than 24h
9+
* - npm run eval:cleanup -- --dry-run
10+
*/
11+
12+
import { fileURLToPath } from "node:url";
13+
import { tmpdir } from "node:os";
14+
import {
15+
cleanupEvalArtifacts,
16+
DEFAULT_EVAL_ARTIFACT_TTL_MS,
17+
parseEvalCleanupDuration,
18+
} from "./core/temp-cleanup.js";
19+
20+
interface CleanupCliOptions {
21+
rootDir: string;
22+
olderThanMs: number;
23+
dryRun: boolean;
24+
}
25+
26+
async function main(argv: string[]): Promise<void> {
27+
const options = parseArgs(argv);
28+
const result = await cleanupEvalArtifacts(options);
29+
30+
console.log(
31+
[
32+
`Eval cleanup root: ${result.rootDir}`,
33+
`Mode: ${result.dryRun ? "dry-run" : "delete"}`,
34+
`Scanned: ${result.scanned}`,
35+
`Deleted: ${result.deleted.length}`,
36+
`Kept: ${result.kept.length}`,
37+
`Errors: ${result.errors.length}`,
38+
].join("\n"),
39+
);
40+
41+
for (const entry of result.deleted) {
42+
console.log(`[deleted] ${entry.path} (${entry.reason})`);
43+
}
44+
for (const error of result.errors) {
45+
console.error(`[error] ${error.path}: ${error.message}`);
46+
}
47+
48+
if (result.errors.length > 0) {
49+
process.exitCode = 1;
50+
}
51+
}
52+
53+
function parseArgs(argv: string[]): CleanupCliOptions {
54+
const options: CleanupCliOptions = {
55+
rootDir: process.env["EVAL_TEMP_ROOT"] ?? tmpdir(),
56+
olderThanMs: DEFAULT_EVAL_ARTIFACT_TTL_MS,
57+
dryRun: false,
58+
};
59+
60+
for (let i = 0; i < argv.length; i++) {
61+
const arg = argv[i];
62+
if (arg === undefined) {
63+
continue;
64+
}
65+
if (arg === "--dry-run") {
66+
options.dryRun = true;
67+
continue;
68+
}
69+
if (arg === "--root") {
70+
options.rootDir = readNextArg(argv, i, "--root");
71+
i++;
72+
continue;
73+
}
74+
if (arg.startsWith("--root=")) {
75+
options.rootDir = arg.slice("--root=".length);
76+
continue;
77+
}
78+
if (arg === "--older-than") {
79+
options.olderThanMs = parseEvalCleanupDuration(
80+
readNextArg(argv, i, "--older-than"),
81+
);
82+
i++;
83+
continue;
84+
}
85+
if (arg.startsWith("--older-than=")) {
86+
options.olderThanMs = parseEvalCleanupDuration(
87+
arg.slice("--older-than=".length),
88+
);
89+
continue;
90+
}
91+
if (arg === "--help" || arg === "-h") {
92+
printHelp();
93+
process.exit(0);
94+
}
95+
throw new Error(`Unknown argument: ${arg}`);
96+
}
97+
98+
return options;
99+
}
100+
101+
function readNextArg(argv: string[], index: number, flag: string): string {
102+
const value = argv[index + 1];
103+
if (value === undefined || value.startsWith("--")) {
104+
throw new Error(`${flag} requires a value.`);
105+
}
106+
return value;
107+
}
108+
109+
function printHelp(): void {
110+
console.log(`Usage: npm run eval:cleanup -- [options]
111+
112+
Options:
113+
--older-than <duration> Delete artifacts older than this duration. Default: 7d.
114+
Supported units: ms, s, m, h, d.
115+
--dry-run Print what would be deleted without deleting.
116+
--root <path> Scan this directory instead of EVAL_TEMP_ROOT or OS tmpdir.
117+
-h, --help Show this help.
118+
`);
119+
}
120+
121+
const currentFile = fileURLToPath(import.meta.url);
122+
if (process.argv[1] === currentFile) {
123+
main(process.argv.slice(2)).catch((err: unknown) => {
124+
console.error(err instanceof Error ? err.message : String(err));
125+
process.exitCode = 1;
126+
});
127+
}

‎src/eval/core/runner.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { createTraceRecorder } from "./trace.js";
4040
import { runAssertions } from "./assertions.js";
4141
import { writeEvalTrace } from "./trace-writer.js";
4242
import { runJudge } from "../judge/judge.js";
43+
import { writeEvalArtifactManifest } from "./temp-cleanup.js";
4344

4445
/**
4546
* runEvalCase — 执行单个 eval case
@@ -269,7 +270,18 @@ export async function runEvalCase(
269270
// 12. 清理 workspace(如果 case 失败且设置了 keepOnFailure,则保留)
270271
const shouldKeep =
271272
evalCase.workspace?.keepOnFailure === true && status !== "passed";
272-
if (!shouldKeep) {
273+
if (shouldKeep) {
274+
try {
275+
// 失败调试时保留 workspace 很有用,但必须给跨运行 GC 留下过期信息,
276+
// 否则长期打开 keepOnFailure 后,系统临时目录会无限膨胀。
277+
await writeEvalArtifactManifest(workspace.root, {
278+
caseId: evalCase.id,
279+
kind: "workspace",
280+
});
281+
} catch {
282+
// manifest 写入失败不改变 eval 结果;后续 cleanup 仍可用 mtime 兜底。
283+
}
284+
} else {
273285
try {
274286
await workspace.cleanup();
275287
} catch {

‎src/eval/core/temp-cleanup.test.ts‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* temp-cleanup.test.ts — Eval 临时产物清理器测试
3+
*
4+
* 这些测试的重点不是“rm 能不能工作”,而是验证清理边界:
5+
* - 只删除白名单 eval 目录
6+
* - manifest 优先于 mtime
7+
* - trace 只删除旧 trace JSON
8+
* - dry-run 不产生真实删除
9+
*/
10+
11+
import { existsSync } from "node:fs";
12+
import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
13+
import { tmpdir } from "node:os";
14+
import { join } from "node:path";
15+
import { afterEach, describe, expect, it } from "vitest";
16+
import {
17+
cleanupEvalArtifacts,
18+
parseEvalCleanupDuration,
19+
writeEvalArtifactManifest,
20+
} from "./temp-cleanup.js";
21+
22+
describe("cleanupEvalArtifacts", () => {
23+
const roots: string[] = [];
24+
const baseNow = new Date("2026-06-12T00:00:00.000Z");
25+
const sevenDays = parseEvalCleanupDuration("7d");
26+
27+
afterEach(async () => {
28+
const pending = roots.splice(0);
29+
await Promise.all(
30+
pending.map((root) => rm(root, { recursive: true, force: true })),
31+
);
32+
});
33+
34+
it("removes only old directories with eval whitelist prefixes", async () => {
35+
const root = await createRoot();
36+
const oldEval = join(root, "eval-old-case");
37+
const youngEval = join(root, "eval-young-case");
38+
const unrelated = join(root, "project-cache-old");
39+
await mkdir(oldEval);
40+
await mkdir(youngEval);
41+
await mkdir(unrelated);
42+
await touchMtime(oldEval, new Date(baseNow.getTime() - sevenDays - 1000));
43+
await touchMtime(youngEval, baseNow);
44+
await touchMtime(unrelated, new Date(baseNow.getTime() - sevenDays - 1000));
45+
46+
const result = await cleanupEvalArtifacts({
47+
rootDir: root,
48+
olderThanMs: sevenDays,
49+
now: baseNow,
50+
});
51+
52+
expect(result.errors).toHaveLength(0);
53+
expect(result.deleted.map((entry) => entry.path)).toContain(oldEval);
54+
expect(existsSync(oldEval)).toBe(false);
55+
expect(existsSync(youngEval)).toBe(true);
56+
expect(existsSync(unrelated)).toBe(true);
57+
});
58+
59+
it("uses manifest expiresAt before falling back to mtime", async () => {
60+
const root = await createRoot();
61+
const expiredHome = join(root, "learn-claude-eval-home-expired");
62+
const activeHome = join(root, "learn-claude-eval-home-active");
63+
await mkdir(expiredHome);
64+
await mkdir(activeHome);
65+
await writeEvalArtifactManifest(expiredHome, {
66+
caseId: "expired-case",
67+
kind: "agentHome",
68+
now: new Date(baseNow.getTime() - 2 * sevenDays),
69+
ttlMs: sevenDays,
70+
});
71+
await writeEvalArtifactManifest(activeHome, {
72+
caseId: "active-case",
73+
kind: "agentHome",
74+
now: baseNow,
75+
ttlMs: sevenDays,
76+
});
77+
78+
const result = await cleanupEvalArtifacts({
79+
rootDir: root,
80+
olderThanMs: sevenDays,
81+
now: baseNow,
82+
});
83+
84+
expect(result.errors).toHaveLength(0);
85+
expect(existsSync(expiredHome)).toBe(false);
86+
expect(existsSync(activeHome)).toBe(true);
87+
});
88+
89+
it("removes old trace JSON files without touching unrelated trace files", async () => {
90+
const root = await createRoot();
91+
const traceDir = join(root, "eval-traces");
92+
const oldTrace = join(traceDir, "case-a.trace.json");
93+
const youngTrace = join(traceDir, "case-b.trace.json");
94+
const note = join(traceDir, "notes.txt");
95+
await mkdir(traceDir);
96+
await writeFile(oldTrace, "{}", "utf-8");
97+
await writeFile(youngTrace, "{}", "utf-8");
98+
await writeFile(note, "keep", "utf-8");
99+
await touchMtime(oldTrace, new Date(baseNow.getTime() - sevenDays - 1000));
100+
await touchMtime(youngTrace, baseNow);
101+
await touchMtime(note, new Date(baseNow.getTime() - sevenDays - 1000));
102+
103+
const result = await cleanupEvalArtifacts({
104+
rootDir: root,
105+
olderThanMs: sevenDays,
106+
now: baseNow,
107+
});
108+
109+
expect(result.errors).toHaveLength(0);
110+
expect(result.deleted.map((entry) => entry.path)).toContain(oldTrace);
111+
expect(existsSync(oldTrace)).toBe(false);
112+
expect(existsSync(youngTrace)).toBe(true);
113+
expect(existsSync(note)).toBe(true);
114+
});
115+
116+
it("reports deletions in dry-run mode without removing files", async () => {
117+
const root = await createRoot();
118+
const oldEval = join(root, "eval-dry-run-case");
119+
await mkdir(oldEval);
120+
await touchMtime(oldEval, new Date(baseNow.getTime() - sevenDays - 1000));
121+
122+
const result = await cleanupEvalArtifacts({
123+
rootDir: root,
124+
olderThanMs: sevenDays,
125+
now: baseNow,
126+
dryRun: true,
127+
});
128+
129+
expect(result.dryRun).toBe(true);
130+
expect(result.deleted.map((entry) => entry.path)).toContain(oldEval);
131+
expect(existsSync(oldEval)).toBe(true);
132+
});
133+
134+
async function createRoot(): Promise<string> {
135+
const root = await mkdtemp(join(tmpdir(), "eval-cleanup-test-"));
136+
roots.push(root);
137+
return root;
138+
}
139+
140+
async function touchMtime(path: string, time: Date): Promise<void> {
141+
await utimes(path, time, time);
142+
}
143+
});

0 commit comments

Comments
 (0)