Files
maestro/src/daemon/index.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

130 lines
5.7 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 { statSync } from 'node:fs';
import type { FastifyInstance } from 'fastify';
import { Store } from '../store/index.js';
import { buildServer, attachWebSocket } from '../api/server.js';
import { registerStatic } from '../api/static.js';
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';
/**
* 定时同步:每轮对 active 项目检查 todo.json 的 mtime,比 last_sync_at 新才 sync。
* 间隔由 MAESTRO_SYNC_INTERVAL(秒)控制,默认 300,0=关闭。错误只记日志,不崩 daemon。
*/
function startSyncLoop(store: Store, app: FastifyInstance): NodeJS.Timeout | null {
const intervalSec = Number(process.env.MAESTRO_SYNC_INTERVAL ?? 300);
if (!Number.isFinite(intervalSec) || intervalSec <= 0) {
app.log.info('定时 todo.json 同步已关闭(MAESTRO_SYNC_INTERVAL=0');
return null;
}
const tick = (): void => {
try {
for (const p of store.listProjects()) {
if (p.status !== 'active') continue;
let mtimeIso: string;
try {
mtimeIso = statSync(todoJsonPath(p.repoPath)).mtime.toISOString();
} catch {
continue; // 无 todo.json:跳过
}
if (p.lastSyncAt && mtimeIso <= p.lastSyncAt) continue;
try {
const r = syncProject(store, p.id);
app.log.info(
`定时同步「${p.name}」:created=${r.created} doneAdvanced=${r.doneAdvanced} skipped=${r.skipped} warnings=${r.warnings.length}`,
);
} catch (e) {
app.log.error(`定时同步「${p.name}」失败:${(e as Error).message}`);
}
}
} catch (e) {
app.log.error(`定时同步轮询失败:${(e as Error).message}`);
}
};
const timer = setInterval(tick, intervalSec * 1000);
timer.unref();
app.log.info(`定时 todo.json 同步已启用:每 ${intervalSec}s 一轮`);
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();
const store = new Store(cfg.dbFile);
const rec = store.reconcileDeps(); // 启动对账:ready↔blocked 按依赖纠正存量数据
// 中断恢复(多进程执行):用 worker_pid + 心跳真判活——worker 仍活则 re-adoptdaemon 续 ingest 其 outbox),
// 死则回收(failTaskAttempt 收尾 + 重试/needs_attention)。
const itr = store.reconcileInterrupted((run) =>
run !== null && isWorkerAlive({
pid: run.workerPid,
heartbeatAgeMs: heartbeatAgeMs(run.id),
startedAgeMs: Date.now() - Date.parse(run.startedAt),
}),
);
const app = buildServer({ store, logger: true });
registerStatic(app); // Web 看板(web/ 静态文件)
attachWebSocket(app, store);
await app.listen({ host: cfg.host, port: cfg.port });
app.log.info(`maestrod 就绪 · db=${cfg.dbFile} · http://${cfg.host}:${cfg.port} · ws ${cfg.host}:${cfg.port}/ws`);
if (rec.blocked || rec.released) app.log.info(`依赖对账:转入等依赖 ${rec.blocked} · 放行可执行 ${rec.released}`);
if (itr.readopted || itr.reclaimed) app.log.info(`中断恢复:re-adopt ${itr.readopted} · 回收 ${itr.reclaimed}`);
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();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main().catch((err) => {
console.error('maestrod 启动失败:', err);
process.exit(1);
});