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>
This commit is contained in:
wangjia
2026-06-13 15:02:23 +08:00
parent f020e15137
commit a365aaf06a
5 changed files with 396 additions and 1 deletions
+176
View File
@@ -0,0 +1,176 @@
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);
});