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
+33
View File
@@ -7,6 +7,7 @@ import { loadConfig } from './config.js';
import { startOrchestrator } from './orchestrator.js';
import { startNotifier } from './notify.js';
import { isWorkerAlive, heartbeatAgeMs } from '../executor/protocol.js';
import { sweepRunArtifacts, loadCleanupConfig } from '../executor/cleanup.js';
import { syncProject, todoJsonPath } from '../sync/todo-sync.js';
/**
@@ -49,6 +50,36 @@ function startSyncLoop(store: Store, app: FastifyInstance): NodeJS.Timeout | nul
return timer;
}
/**
* 定时回收 run 产物:周期扫尾 runs/<runId>/ 与转录(终态/孤儿、过保留期者)。
* 间隔由 MAESTRO_CLEANUP_INTERVAL(秒)控制,默认 36000=关闭;保留策略见 loadCleanupConfig。
* 错误只记日志,不崩 daemon。
*/
function startCleanupLoop(store: Store, app: FastifyInstance): NodeJS.Timeout | null {
const intervalSec = Number(process.env.MAESTRO_CLEANUP_INTERVAL ?? 3600);
if (!Number.isFinite(intervalSec) || intervalSec <= 0) {
app.log.info('定时 run 产物回收已关闭(MAESTRO_CLEANUP_INTERVAL=0');
return null;
}
const cfg = loadCleanupConfig();
const tick = (): void => {
try {
const r = sweepRunArtifacts(store, cfg, app.log);
if (r.runDirs || r.transcripts) {
app.log.info(`run 产物回收:runDir=${r.runDirs} 转录=${r.transcripts}`);
}
} catch (e) {
app.log.error(`run 产物回收轮失败:${(e as Error).message}`);
}
};
const timer = setInterval(tick, intervalSec * 1000);
timer.unref();
app.log.info(
`定时 run 产物回收已启用:每 ${intervalSec}s 一轮(runDir 保留 ${cfg.runRetentionDays}d,转录保留 ${cfg.transcriptRetentionDays > 0 ? cfg.transcriptRetentionDays + 'd' : '永久'}`,
);
return timer;
}
/** maestrod:核心 daemon。Store + REST/WS API + 定时同步 + 编排器(Phase 2:自动领取可执行任务并在 worktree 起 headless CC)。 */
async function main(): Promise<void> {
const cfg = loadConfig();
@@ -75,12 +106,14 @@ async function main(): Promise<void> {
const syncTimer = startSyncLoop(store, app);
const orchTimer = startOrchestrator(store, app); // 编排器:自动领取可执行任务(MAESTRO_ORCH_INTERVAL 秒,0=关闭)
const cleanupTimer = startCleanupLoop(store, app); // 定时回收 run 产物(MAESTRO_CLEANUP_INTERVAL 秒,0=关闭)
const stopNotifier = startNotifier(store, app); // macOS 原生通知:审核闸/需人工/合并完成(MAESTRO_NOTIFY=0 关闭)
const shutdown = async (): Promise<void> => {
app.log.info('收到退出信号,关闭中…');
if (syncTimer) clearInterval(syncTimer);
if (orchTimer) clearInterval(orchTimer);
if (cleanupTimer) clearInterval(cleanupTimer);
if (stopNotifier) stopNotifier();
await app.close();
store.close();