a365aaf06a
每个 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>
177 lines
7.4 KiB
TypeScript
177 lines
7.4 KiB
TypeScript
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_DIR(runDir/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('cleanupTaskRunArtifacts:heartbeat 新鲜则跳过(不误删活跃 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('sweepRunArtifacts:done 任务过保留期 → 清;在途 → 留', () => {
|
||
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('loadCleanupConfig:env 覆盖与非法回退', () => {
|
||
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);
|
||
});
|