From a365aaf06a6048302183557c6ca8266adee1f577 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 13 Jun 2026 15:02:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(cleanup):=20exec=5Freview=20=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E5=90=8E=E5=9B=9E=E6=94=B6=20runs//=20?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E7=9B=AE=E5=BD=95=20+=20=E5=AE=9A=E6=9C=9F?= =?UTF-8?q?=E6=89=AB=E5=B0=BE=20[tsk=5F6uziW1Dg-ftQ]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每个 run 在 ~/.maestro/runs// 留下 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 --- src/api/server.ts | 5 ++ src/daemon/index.ts | 33 ++++++++ src/executor/cleanup.ts | 166 ++++++++++++++++++++++++++++++++++++ src/executor/protocol.ts | 17 +++- test/cleanup.test.ts | 176 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 src/executor/cleanup.ts create mode 100644 test/cleanup.test.ts diff --git a/src/api/server.ts b/src/api/server.ts index a09829f..319ccb0 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -10,6 +10,7 @@ import { resolvedExecutorModels } from '../executor/models.js'; import { classifyComplexity, type ClassifierFn } from '../executor/classify.js'; import { mergeBranch } from '../executor/merge.js'; import { git, removeWorktree } from '../executor/worktree.js'; +import { cleanupTaskRunArtifacts } from '../executor/cleanup.js'; import { resolveLogo, LOGO_MIME } from './logo.js'; import { readTranscript, TranscriptError } from '../executor/transcript.js'; import { createReadStream } from 'node:fs'; @@ -332,6 +333,10 @@ export function buildServer(opts: ApiOptions): FastifyInstance { store.decide(id, 'accept', b.actor ?? 'user', b.reason ?? null); // 合并产物记录:复用 prUrl 字段写 merged: store.setResult(id, { ...task.result, prUrl: `merged:${mr.mergeCommit}` }); + // 即时回收 runs//(job/outbox/heartbeat 已无价值;转录保留供排查)。 + // 任务此刻已 done(终态),cleanupTaskRunArtifacts 内部据此放行;同步删(纯本地 fs,快)。 + const reclaimed = cleanupTaskRunArtifacts(store, id, app.log); + if (reclaimed) app.log.info(`任务 ${id} 合并后回收 ${reclaimed} 个 run 工作目录`); // 异步回收:执行 worktree + 已合并的任务分支(失败只记日志,不影响响应) void (async () => { try { diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 4ad33f1..00288cf 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -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// 与转录(终态/孤儿、过保留期者)。 + * 间隔由 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 { const cfg = loadConfig(); @@ -75,12 +106,14 @@ async function main(): Promise { 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 => { app.log.info('收到退出信号,关闭中…'); if (syncTimer) clearInterval(syncTimer); if (orchTimer) clearInterval(orchTimer); + if (cleanupTimer) clearInterval(cleanupTimer); if (stopNotifier) stopNotifier(); await app.close(); store.close(); diff --git a/src/executor/cleanup.ts b/src/executor/cleanup.ts new file mode 100644 index 0000000..cab843d --- /dev/null +++ b/src/executor/cleanup.ts @@ -0,0 +1,166 @@ +// Run 产物回收(Phase 3 多进程执行的扫尾)。 +// +// 每个 run 在两处留下产物: +// - runs// ── job.json / outbox.ndjson / heartbeat(纯运行期脚手架,落库后无价值) +// - /.jsonl ── 转录(事后排查有价值,默认长保留) +// +// 终态任务(done/cancelled)的产物不再需要;orphan(任务已删,run 行被 FK 级联清掉)的产物同理。 +// 本模块提供: +// - cleanupTaskRunArtifacts:合并/接受后即时回收某任务的全部 run 工作目录(server 调)。 +// - sweepRunArtifacts :周期扫尾,按保留天数回收终态/孤儿 runDir 与(可选)转录(daemon 调)。 +// +// 安全铁律:只删【终态或孤儿】run 的产物;活跃/在途/exec_review 的一律保留。 +// 双保险:① 任务状态判定 ② heartbeat 新鲜则跳过(兜底防误删仍在写的 run)。 + +import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { Store } from '../store/index.js'; +import { TERMINAL_STATUS } from '../model/status.js'; +import { + runsBase, runDir, removeRunDir, isPlainRunId, heartbeatAgeMs, HEARTBEAT_GRACE_MS, +} from './protocol.js'; +import { transcriptDir } from './cc.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export interface CleanupLogger { + info(msg: string): void; + error(msg: string): void; +} + +export interface CleanupConfig { + /** runs// 保留天数(自目录 mtime 起算);0 = 终态后立即可回收。 */ + runRetentionDays: number; + /** /.jsonl 保留天数;<=0 = 永不自动清理(默认,便于事后排查)。 */ + transcriptRetentionDays: number; +} + +export const DEFAULT_CLEANUP_CONFIG: CleanupConfig = { + runRetentionDays: 3, + transcriptRetentionDays: 0, +}; + +/** 从环境变量读保留策略(覆盖默认)。非法值回退默认。 */ +export function loadCleanupConfig(env: NodeJS.ProcessEnv = process.env): CleanupConfig { + const num = (raw: string | undefined, fallback: number): number => { + if (raw === undefined || raw.trim() === '') return fallback; + const n = Number(raw); + return Number.isFinite(n) && n >= 0 ? n : fallback; + }; + return { + runRetentionDays: num(env.MAESTRO_RUN_RETENTION_DAYS, DEFAULT_CLEANUP_CONFIG.runRetentionDays), + transcriptRetentionDays: num(env.MAESTRO_TRANSCRIPT_RETENTION_DAYS, DEFAULT_CLEANUP_CONFIG.transcriptRetentionDays), + }; +} + +/** + * 该 run 的工作目录是否可回收:终态任务 / orphan(run 行或任务已不在 DB)→ 可;活跃/在途 → 否。 + * 不看保留天数与 heartbeat(那是调用方叠加的策略/兜底)。 + */ +export function isRunCleanable(store: Store, runId: string): boolean { + const run = store.getRun(runId); + if (!run) return true; // orphan:任务已删,run 行被 FK 级联清除 → 回收 + const task = store.getTask(run.taskId); + if (!task) return true; // 同上(防御) + return TERMINAL_STATUS.has(task.status); // done / cancelled +} + +/** heartbeat 仍新鲜(有进程在写)→ 不可删,兜底防误删活跃 run。 */ +function heartbeatFresh(runId: string, now: number): boolean { + const age = heartbeatAgeMs(runId, now); + return age !== null && age < HEARTBEAT_GRACE_MS; +} + +/** + * 即时回收某任务全部 run 的工作目录(runs//)。合并成功 / 接受后由 server 调。 + * 只删工作目录,不动转录(保留排查价值)。失败只计日志,绝不抛(不阻断主流程)。 + * 返回实际清理的目录数。 + */ +export function cleanupTaskRunArtifacts(store: Store, taskId: string, log: CleanupLogger): number { + let n = 0; + try { + for (const run of store.listRuns(taskId)) { + if (heartbeatFresh(run.id, Date.now())) continue; // 极少见:仍有进程在写则跳过 + try { + removeRunDir(run.id); + n++; + } catch (e) { + log.error(`回收 run 目录 ${run.id} 失败(不影响结果):${(e as Error).message}`); + } + } + } catch (e) { + log.error(`回收任务 ${taskId} 的 run 产物失败(不影响结果):${(e as Error).message}`); + } + return n; +} + +export interface SweepResult { + runDirs: number; // 清理的 runs// 目录数 + transcripts: number; // 清理的转录文件数 +} + +/** + * 周期扫尾:遍历 runs/ 与 transcripts/,回收终态/孤儿且超过保留期的产物。 + * - runDir:任务终态/孤儿 且 目录 mtime 早于 runRetentionDays 且 heartbeat 不新鲜 → 删。 + * - 转录:transcriptRetentionDays>0 时,任务终态/孤儿 且 文件 mtime 早于该天数 → 删(活跃任务永不删)。 + * 单条失败不阻断其余。返回清理计数。 + */ +export function sweepRunArtifacts( + store: Store, + cfg: CleanupConfig, + log: CleanupLogger, + now: number = Date.now(), +): SweepResult { + const res: SweepResult = { runDirs: 0, transcripts: 0 }; + + // ── runs// ── + const base = runsBase(); + if (existsSync(base)) { + let entries: string[] = []; + try { entries = readdirSync(base); } catch (e) { log.error(`读取 runs 目录失败:${(e as Error).message}`); } + const cutoff = now - cfg.runRetentionDays * DAY_MS; + for (const runId of entries) { + if (!isPlainRunId(runId)) continue; + const dir = runDir(runId); + try { + const st = statSync(dir); + if (!st.isDirectory()) continue; + if (!isRunCleanable(store, runId)) continue; // 活跃/在途 → 保留 + if (st.mtimeMs > cutoff) continue; // 未过保留期 + if (heartbeatFresh(runId, now)) continue; // 仍在写 → 兜底保留 + removeRunDir(runId); + res.runDirs++; + } catch (e) { + log.error(`扫尾 run 目录 ${runId} 失败:${(e as Error).message}`); + } + } + } + + // ── /.jsonl ──(默认关闭) + if (cfg.transcriptRetentionDays > 0) { + const tdir = transcriptDir(); + if (existsSync(tdir)) { + let files: string[] = []; + try { files = readdirSync(tdir); } catch (e) { log.error(`读取转录目录失败:${(e as Error).message}`); } + const cutoff = now - cfg.transcriptRetentionDays * DAY_MS; + for (const file of files) { + if (!file.endsWith('.jsonl')) continue; + const runId = file.slice(0, -'.jsonl'.length); + if (!isPlainRunId(runId)) continue; + const fp = join(tdir, file); + try { + const st = statSync(fp); + if (!st.isFile()) continue; + if (!isRunCleanable(store, runId)) continue; // 活跃任务的转录 → 永不删 + if (st.mtimeMs > cutoff) continue; // 未过保留期 + rmSync(fp, { force: true }); + res.transcripts++; + } catch (e) { + log.error(`扫尾转录 ${file} 失败:${(e as Error).message}`); + } + } + } + } + + return res; +} diff --git a/src/executor/protocol.ts b/src/executor/protocol.ts index 7878297..fb836a7 100644 --- a/src/executor/protocol.ts +++ b/src/executor/protocol.ts @@ -10,7 +10,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, - statSync, utimesSync, closeSync, openSync, + statSync, utimesSync, closeSync, openSync, rmSync, } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -33,6 +33,21 @@ export function jobPath(runId: string): string { return join(runDir(runId), 'job export function outboxPath(runId: string): string { return join(runDir(runId), 'outbox.ndjson'); } export function heartbeatPath(runId: string): string { return join(runDir(runId), 'heartbeat'); } +/** + * 删除单个 run 的工作目录 runs//(job.json + outbox.ndjson + heartbeat)。 + * 幂等(目录不存在不报错)。合并/终态后由 daemon 侧回收调用——worker 自身从不删自己的工作目录。 + * 仅接受形如 run_xxx 的纯 id(无路径分隔符),避免越界删除。 + */ +export function removeRunDir(runId: string): void { + if (!isPlainRunId(runId)) return; + rmSync(runDir(runId), { recursive: true, force: true }); +} + +/** runId 合法性:仅字母数字与 -_,杜绝 . / 等可越界字符(清理路径计算的安全前提)。 */ +export function isPlainRunId(runId: string): boolean { + return /^[A-Za-z0-9_-]+$/.test(runId); +} + // ───────────────────────── daemon → worker:JobSpec ───────────────────────── /** diff --git a/test/cleanup.test.ts b/test/cleanup.test.ts new file mode 100644 index 0000000..2777b36 --- /dev/null +++ b/test/cleanup.test.ts @@ -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_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); +});