Files
maestro/test/cleanup.test.ts
T
wangjia a365aaf06a feat(cleanup): exec_review 合并后回收 runs/<runId>/ 工作目录 + 定期扫尾 [tsk_6uziW1Dg-ftQ]
每个 run 在 ~/.maestro/runs/<runId>/ 留下 job.json/outbox.ndjson/heartbeat,
合并(exec_review accept)后不再需要,需回收避免无限堆积。

- protocol.ts:新增 removeRunDir / isPlainRunId(幂等删 runDir,runId 白名单防越界)
- cleanup.ts(新):
  - cleanupTaskRunArtifacts:合并/接受后即时回收某任务全部 run 工作目录
  - sweepRunArtifacts:周期扫尾终态/孤儿 runDir 与(可选)转录,按保留天数
  - isRunCleanable:仅终态(done/cancelled)/孤儿可回收,活跃/在途一律保留
  - 双保险:任务状态判定 + heartbeat 新鲜则跳过,绝不误删活跃 run
  - 保留策略可配:MAESTRO_RUN_RETENTION_DAYS(默认3) /
    MAESTRO_TRANSCRIPT_RETENTION_DAYS(默认0=永久保留,便于排查)
- server.ts:exec_review accept 合并成功后即时清 runDir(与 removeWorktree 同处)
- index.ts:startCleanupLoop 定时扫尾(MAESTRO_CLEANUP_INTERVAL 秒,默认3600,0=关闭)
- test/cleanup.test.ts:终态清/在途留/孤儿清/保留期/heartbeat 兜底/转录开关/配置

测试:npm test → 164 passed;npm run typecheck → clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:02:23 +08:00

177 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync, utimesSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store/index.js';
import { writeJobSpec, runDir, touchHeartbeat, type JobSpec } from '../src/executor/protocol.js';
import { transcriptDir } from '../src/executor/cc.js';
import {
cleanupTaskRunArtifacts, sweepRunArtifacts, isRunCleanable, loadCleanupConfig,
type CleanupConfig,
} from '../src/executor/cleanup.js';
import type { Project, Task } from '../src/model/types.js';
const noopLog = { info: (): void => undefined, error: (): void => undefined };
const DAY_MS = 24 * 60 * 60 * 1000;
/** 隔离 MAESTRO_DATA_DIRrunDir/transcriptDir 调用时读 env),跑回调后清理。 */
function withTmpDataDir(cb: (dataDir: string) => void): void {
const dir = mkdtempSync(join(tmpdir(), 'maestro-cleanup-'));
const prev = process.env.MAESTRO_DATA_DIR;
process.env.MAESTRO_DATA_DIR = dir;
try {
cb(dir);
} finally {
if (prev === undefined) delete process.env.MAESTRO_DATA_DIR;
else process.env.MAESTRO_DATA_DIR = prev;
rmSync(dir, { recursive: true, force: true });
}
}
/** 建项目 + executor run,落 job.json/outbox/heartbeat 到磁盘;返回 store/ids。 */
function setupRun(): { store: Store; taskId: string; runId: string } {
const store = new Store(':memory:');
const p = store.createProject({ name: 'cl', repoPath: '/tmp/cl-' + Math.random(), autonomy: 'auto-easy' });
const t = store.createTask({ projectId: p.id, title: 'tweak', complexity: 'easy' });
store.setOperations(t.id, 'x');
store.transition(t.id, 'queued', { by: 'test' });
store.transition(t.id, 'executing', { by: 'test' });
const run = store.startRun(t.id, 'executor', { worktree: '/tmp/wt', branch: 'b' });
// 落产物
const job: JobSpec = {
runId: run.id,
task: t as Task,
project: p as Project,
worktreeDir: '/tmp/wt',
branch: 'b',
};
writeJobSpec(job);
return { store, taskId: t.id, runId: run.id };
}
/** 把 runDir 的 mtime 拨老 days 天(绕过保留期)。 */
function ageDir(runId: string, days: number): void {
const when = new Date(Date.now() - days * DAY_MS);
utimesSync(runDir(runId), when, when);
}
test('isRunCleanable:终态/孤儿可回收,在途不可', () => {
withTmpDataDir(() => {
const { store, taskId, runId } = setupRun();
assert.equal(isRunCleanable(store, runId), false); // executing → 否
store.transition(taskId, 'exec_review', { by: 'test' });
assert.equal(isRunCleanable(store, runId), false); // exec_review → 否
store.transition(taskId, 'done', { by: 'test' });
assert.equal(isRunCleanable(store, runId), true); // done → 是
assert.equal(isRunCleanable(store, 'run_does_not_exist'), true); // 孤儿 → 是
});
});
test('cleanupTaskRunArtifacts:终态任务的 runDir 被即时清,转录保留', () => {
withTmpDataDir(() => {
const { store, taskId, runId } = setupRun();
// 造一份转录
mkdirSync(transcriptDir(), { recursive: true });
const tref = join(transcriptDir(), `${runId}.jsonl`);
writeFileSync(tref, '{}\n');
store.transition(taskId, 'exec_review', { by: 'test' });
store.transition(taskId, 'done', { by: 'test' });
assert.ok(existsSync(runDir(runId)));
const n = cleanupTaskRunArtifacts(store, taskId, noopLog);
assert.equal(n, 1);
assert.equal(existsSync(runDir(runId)), false); // runDir 清
assert.ok(existsSync(tref)); // 转录保留
});
});
test('cleanupTaskRunArtifactsheartbeat 新鲜则跳过(不误删活跃 run)', () => {
withTmpDataDir(() => {
const { store, taskId, runId } = setupRun();
touchHeartbeat(runId); // 新鲜心跳
store.transition(taskId, 'exec_review', { by: 'test' });
store.transition(taskId, 'done', { by: 'test' });
const n = cleanupTaskRunArtifacts(store, taskId, noopLog);
assert.equal(n, 0);
assert.ok(existsSync(runDir(runId)));
});
});
const cfg: CleanupConfig = { runRetentionDays: 1, transcriptRetentionDays: 0 };
test('sweepRunArtifactsdone 任务过保留期 → 清;在途 → 留', () => {
withTmpDataDir(() => {
const a = setupRun();
const store = a.store;
// 第二个 run(同 store):保持 executing
const t2 = store.createTask({ projectId: store.listProjects()[0].id, title: 't2', complexity: 'easy' });
store.setOperations(t2.id, 'y');
store.transition(t2.id, 'queued', { by: 'test' });
store.transition(t2.id, 'executing', { by: 'test' });
const run2 = store.startRun(t2.id, 'executor', { worktree: '/tmp/wt2', branch: 'b2' });
writeJobSpec({ runId: run2.id, task: t2 as Task, project: store.listProjects()[0] as Project, worktreeDir: '/tmp/wt2', branch: 'b2' });
// a → done 且拨老;run2 留在 executing
store.transition(a.taskId, 'exec_review', { by: 'test' });
store.transition(a.taskId, 'done', { by: 'test' });
ageDir(a.runId, 2);
ageDir(run2.id, 2); // 即便老,executing 也不该删
const res = sweepRunArtifacts(store, cfg, noopLog);
assert.equal(res.runDirs, 1);
assert.equal(existsSync(runDir(a.runId)), false); // done → 清
assert.ok(existsSync(runDir(run2.id))); // executing → 留
});
});
test('sweepRunArtifacts:未过保留期不清;孤儿过期清', () => {
withTmpDataDir(() => {
const { store, taskId, runId } = setupRun();
store.transition(taskId, 'cancelled', { by: 'test' });
// 刚创建(mtime≈now),未过 1d 保留期 → 不清
let res = sweepRunArtifacts(store, cfg, noopLog);
assert.equal(res.runDirs, 0);
assert.ok(existsSync(runDir(runId)));
// 拨老 → 清
ageDir(runId, 2);
res = sweepRunArtifacts(store, cfg, noopLog);
assert.equal(res.runDirs, 1);
assert.equal(existsSync(runDir(runId)), false);
});
});
test('sweepRunArtifacts:转录开关(>0 时清终态过期,活跃永留)', () => {
withTmpDataDir(() => {
const { store, taskId, runId } = setupRun();
mkdirSync(transcriptDir(), { recursive: true });
const tref = join(transcriptDir(), `${runId}.jsonl`);
writeFileSync(tref, '{}\n');
const old = new Date(Date.now() - 5 * DAY_MS);
utimesSync(tref, old, old);
// 活跃(executing):转录永不删,即便过期
let res = sweepRunArtifacts(store, { runRetentionDays: 1, transcriptRetentionDays: 2 }, noopLog);
assert.equal(res.transcripts, 0);
assert.ok(existsSync(tref));
// 转为终态 → 过期转录被清
store.transition(taskId, 'cancelled', { by: 'test' });
res = sweepRunArtifacts(store, { runRetentionDays: 1, transcriptRetentionDays: 2 }, noopLog);
assert.equal(res.transcripts, 1);
assert.equal(existsSync(tref), false);
});
});
test('loadCleanupConfigenv 覆盖与非法回退', () => {
const base = loadCleanupConfig({} as NodeJS.ProcessEnv);
assert.equal(base.runRetentionDays, 3);
assert.equal(base.transcriptRetentionDays, 0);
const over = loadCleanupConfig({ MAESTRO_RUN_RETENTION_DAYS: '7', MAESTRO_TRANSCRIPT_RETENTION_DAYS: '30' } as unknown as NodeJS.ProcessEnv);
assert.equal(over.runRetentionDays, 7);
assert.equal(over.transcriptRetentionDays, 30);
const bad = loadCleanupConfig({ MAESTRO_RUN_RETENTION_DAYS: 'abc' } as unknown as NodeJS.ProcessEnv);
assert.equal(bad.runRetentionDays, 3);
});