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>
130 lines
5.7 KiB
TypeScript
130 lines
5.7 KiB
TypeScript
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(秒)控制,默认 3600,0=关闭;保留策略见 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-adopt(daemon 续 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);
|
||
});
|