--- tags: - OOM - 调度任务 - 内存溢出 - autonomy - 两阶段提交 create time: 2026-06-09 22:30 --- # Loop / Scheduled Autonomy OOM 修复报告 ## 概述 长时间运行的会话在活跃的定时任务(cron)和心跳驱动的主动循环下内存持续增长,最终导致 Bun 进程 OOM。根因是三个独立不足的缺陷在负载下交织:定时 tick 无同源去重、后台 fork 的 slash 命令提前报告成功、死进程记录永久阻塞去重。修复方案采用同源去重 + 进程印记 + 过期回收 + 延迟完成握手 + 两阶段提交排序。 ## 正文 ### 基本信息 - **Flow id**: `recurring-bug-loop-oom`(autonomy 与 deep-debug 绑定的先导 flow) - **分支**: `fix/loop-scheduled-autonomy-oom` - **状态**: `report`(本文档)——等待人工批准后推进到 `regression-test` ### 问题现象 #### 症状 长时间运行的会话在活跃的定时任务(cron)和/或 HEARTBEAT 驱动的主动循环下内存持续增长,最终 OOM 杀死 Bun 进程。可见特征: - `.claude/autonomy/` 下的 `runs.json` 趋向 200 条上限,大多数条目卡在 `queued` 或 `running` - REPL / headless 模式下的内部命令队列消耗速度慢于定时触发速度 - 每次新触发都调用 `prepareAutonomyTurnPrompt`,加载 `AGENTS.md` + `HEARTBEAT.md` 文本并合并 due-task 列表到新字符串,每个 pending command 持有更多闭包状态 #### 期望行为 当定时任务在先前运行仍在 `queued` 或 `running` 时触发,新触发应该被**跳过**而不是排队。当启动运行的进程死亡时,运行应该被回收,而不是永远留在 `running`。slash 命令生成的后台工作应该只在后台工作本身完成时才完成 originating autonomy run。 #### 实际行为(修复前) 1. `useScheduledTasks` 和 headless streaming 路径在每个 tick 上无条件调用 `createAutonomyQueuedPrompt` 2. `commitAutonomyQueuedPrompt` 在 run record 持久化**之前**就调用了 `commitPreparedAutonomyTurn`,所以即使是应该被丢弃的重复触发也已经修改了心跳任务的 last-run 状态 3. `AutonomyRunRecord` 没有 owner 标识,所以由已死进程启动的运行永远留在 `running`。后续同一 `sourceId` 的运行无法检测到其前身已经消失 4. fork 了 detached 后台工作的 slash 命令(KAIROS / proactive 路径)立即从 `processUserInput` 返回。`handlePromptSubmit` 中的 harness 随后调用 `finalizeAutonomyRunCompleted`,将运行标记为 `succeeded`——但实际工作还在后台继续,同一 source 的下一个定时 tick 可能与该 detached 工作竞争 #### 复现方式 不是单一确定性复现——负载诱发。大致配方: - 配置两个 `HEARTBEAT.md` 任务,间隔 `every 30s` - 添加三个 cron 任务,间隔 `every 1m` - 让会话运行超过 1 小时,尤其跨过后台 slash 命令(如 KAIROS `/sleep` 风格的 detached fork) - 观察 `.claude/autonomy/runs.json` 活跃状态条目数和 Bun heap RSS #### 用户影响 > [!warning] > 长期运行 autonomy/cron 用例的会话不安全。OOM 会杀死整个 CLI,丢失未刷新的消息、MCP 连接和 bridge 状态。因为 `.claude/autonomy/` 持久化,重启无法治愈——死 PID 的 stale `running` 记录在下次启动时继续阻塞去重逻辑。 ### 系统边界 #### 范围内 - Autonomy run 生命周期:create → running → succeeded / failed / cancelled(`src/utils/autonomyRuns.ts`) - 定时任务触发路径:cron scheduler → REPL command queue(`src/hooks/useScheduledTasks.ts`) - 同路径的 headless streaming 变体(`src/cli/print.ts` `runHeadlessStreaming`) - `processUserInput` 返回后 finalize runs 的 prompt-submit 管道(`src/utils/handlePromptSubmit.ts`) - 可能将完成延迟到后台工作的 slash 命令处理(`src/utils/processUserInput/processUserInput.ts`、`processSlashCommand.tsx`) - `ToolUseContext` 扩展,让非打包 harness 可以使用 KAIROS 门控的后台 fork 路径(`src/Tool.ts`) #### 范围外 - cron 调度器本身(`src/utils/cronScheduler.ts`) - `autonomyFlows.ts` flow 状态机 - HEARTBEAT.md 调度语义 - `prepareAutonomyTurnPrompt` 内容形状 - 任何 provider 级行为 ### 关键文件 | 文件 | 变更行数 | 重要性 | |---|---|---| | `src/utils/autonomyRuns.ts` | +260 | 拥有新的 identity + dedup + stale-recovery 逻辑;引入 `createAutonomyRunIfNoActiveSource`、`hasActiveAutonomyRunForSource`、`recoverStaleActiveAutonomyRun`、`commitAutonomyQueuedPromptIfNoActiveSource`、两阶段提交 | | `src/utils/processUserInput/processSlashCommand.tsx` | +707 / -454 | 重写 slash 命令派发,使 detached 后台工作可以 signal `deferAutonomyCompletion` | | `src/hooks/useScheduledTasks.ts` | +47 | 迁移两个 scheduler 调用点到 dedup helper | | `src/cli/print.ts` | +19 / -27 | headless 变体的相同迁移 | | `src/utils/handlePromptSubmit.ts` | +12 | 跟踪 `deferredAutonomyRunIds`,跳过 finalize | | `src/utils/processUserInput/processUserInput.ts` | +10 | 穿透 `autonomy` 上下文 | | `src/Tool.ts` | +6 | 添加 `allowBackgroundForkedSlashCommands` 测试逃生口 | ### 调用流(修复后) #### 定时任务路径 ```mermaid graph TD A["cron tick useScheduledTasks"] --> B["createScheduledTaskQueuedCommand(task)"] B --> C["createAutonomyQueuedPromptIfNoActiveSource"] C --> D["prepareAutonomyTurnPrompt"] C --> E{"shouldCreate?"} E -->|否| F["RETURN null 无副作用"] E -->|是| G["commitAutonomyQueuedPromptIfNoActiveSource"] G --> H["commitAutonomyQueuedPromptInternal(skipWhenActiveSource=true)"] H --> I["createAutonomyRunIfNoActiveSource"] I --> J["buildAutonomyRunRecord 打印 ownerProcessId, ownerSessionId"] I --> K["persistAutonomyRunRecord(skip=true)"] K --> L{"withAutonomyPersistenceLock"} L --> M{"同 trigger+sourceId+ownerKey 的活跃运行?"} M -->|是-过期| N["recoverStaleActiveAutonomyRun 标记 failed"] M -->|是-未过期| O["hasBlockingActiveRun = true"] M -->|否| P["unshift record, write file"] O --> Q["RETURN created=false"] P --> R["commitPreparedAutonomyTurn 心跳状态才更新"] ``` 两个结构性改动:(a) 准备 prompt 不再提交心跳状态;只有成功插入 run 才提交。(b) 同源阻塞活跃运行在触及队列之前就短路。 #### Slash 命令路径 ```mermaid graph TD A["processUserInput"] --> B["processUserInputBase"] B --> C["processSlashCommand(autonomy=cmd.autonomy)"] C --> D{"命令实现"} D -->|同步完成| E["返回正常结果"] D -->|生成 detached 后台工作| F["返回 result + deferAutonomyCompletion=true"] F --> G["自行处理 finalize 调用"] H["handlePromptSubmit"] --> I["记录 cmd.autonomy.runId"] I --> J{"deferAutonomyCompletion=true?"} J -->|是| K["添加 runId 到 deferredAutonomyRunIds"] J -->|否| L["正常 finalize"] K --> M["finalize 循环: 跳过 deferred ids"] ``` ### 数据流 #### runs.json 记录 schema(增量) ```ts type AutonomyRunRecord = { // 已有 runId: string status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' trigger: AutonomyTriggerKind sourceId?: string ownerKey?: string // 新增 ownerProcessId?: number // 创建时和 markRunning 时的 process.pid ownerSessionId?: string // 同一时机的 getSessionId() } ``` > [!info] > 向后兼容:两个字段都缺失的旧记录被视为"owner 未知"——它们永远不满足 `isStaleActiveAutonomyRun`(要求 `typeof ownerProcessId === 'number'`),所以保持阻塞直到正常完成或手动取消。这是有意的:我们无法证明它们是 stale 的。 #### 过期回收规则 ```text isStaleActiveAutonomyRun(run) <=> run.status in {queued, running} && typeof run.ownerProcessId === 'number' && !isProcessRunning(run.ownerProcessId) ``` 回收在持久化锁内修改内存列表并写回,将 stale run 标记为 `failed`,error 前缀为 `"Recovered stale active autonomy run"`。 #### 心跳 last-run 状态变更点 - **修复前**:`commitAutonomyQueuedPrompt` **先**调用 `commitPreparedAutonomyTurn(prepared)`,然后创建 run。被跳过的重复触发已经推进了心跳 last-run 时间戳。 - **修复后**:`commitPreparedAutonomyTurn` 只在 `createAutonomyRunIfNoActiveSource` 返回非 null 记录后才调用。被跳过的重复触发不影响心跳状态,所以下一个合格窗口仍在原始调度点。 ### 状态模型 #### Run 状态生命周期 ```mermaid graph TD A["queued"] --> B["running"] B --> C["succeeded"] B --> D["failed"] A --> E["cancelled"] A --> F["failed 过期回收新路径"] ``` #### 新不变量 1. **同源互斥**:任意时刻最多一条 `(trigger, sourceId, ownerKey, status in active)` 的非 stale 记录。在 `persistAutonomyRunRecord` 的 `withAutonomyPersistenceLock` 内强制执行。 2. **活跃转换时打 owner 印记**:任何将 run 设置为 `queued` 或 `running` 的路径都必须打印 `ownerProcessId = process.pid` 和 `ownerSessionId = getSessionId()`。`markAutonomyRunRunning` 已更新以在 running 转换时执行此操作。 3. **两阶段提交排序**:心跳任务 last-run 状态只能在 run record 成功插入后才能推进。等价于"prompt commit => run row exists"。 4. **延迟完成契约**:如果 slash 命令的 result 带有 `deferAutonomyCompletion=true`,harness(`handlePromptSubmit`)**不得** finalize run;命令实现**拥有** finalize 调用。通过 `deferredAutonomyRunIds` 集合跟踪。 #### 并发 / 重试风险 - 两个共享同一项目根目录的进程可以竞争 `runs.json`。由 `withAutonomyPersistenceLock`(文件锁)缓解。 - 同一进程内同一定时任务的两个 tick 在同一把锁上串行;只有第一个获胜,其余看到活跃记录并返回 `null`。 - 进程在持久化记录和提交 prompt 之间被杀死会留下带死 PID 的 `queued` 记录。同一 source 的下一个 tick 的过期回收将其转为 `failed`,释放 source。 #### 两阶段提交崩溃窗口(已知限制) 在 `commitAutonomyQueuedPromptInternal` 内,顺序是: 1. `createAutonomyRunCore` → `persistAutonomyRunRecord` → run row 在锁下写入 2. `commitPreparedAutonomyTurn(prepared)` → 内存 `heartbeatTaskLastRunByKey` Map 推进 这两步**不是原子的**。如果进程在 (1) 和 (2) 之间被杀死: - `runs.json` 有一条带死 PID 的新鲜 `queued` 记录 - `heartbeatTaskLastRunByKey` 是内存 Map;其状态随进程消失 - 重启后 Map 为空,所有心跳任务在首次 tick 时立即触发 > [!info] > **严重性**:低。Map 是运行时缓存,不是持久化调度契约;"重启后立即触发"是可恢复行为,不是数据损坏。死 PID 记录阻塞 source 直到过期回收,所以重复触发不会堆积。 > > **为什么现在不修复**:在同一个锁内持久化心跳 last-run 状态会耦合两个不相关的状态机,成本超过罕见边界情况。已跟踪以便未来 flow 处理。 ### 根因分析 #### H1 — "Prompt 大小是 OOM 来源" **主张**:每个定时 tick 重建长 prompt 字符串;队列中这些字符串的累积保留导致堆压力。 **支持证据**:`prepareAutonomyTurnPrompt` 确实每次 tick 构建多段字符串;`AGENTS.md` 有 220 行。 **反对证据**:diff 没有缩小任何 prompt 内容。如果 H1 是真正原因,修复应该把字符串组装放到缓存或 LRU 后面。 **结论**:最多是贡献因素。作为主因被拒绝。 #### H2 — "后台 fork 的 slash 命令泄漏 runs" **主张**:KAIROS 风格的 slash 命令 fork detached 工作后立即返回;harness 随后将 run finalize 为 `succeeded`。后台工作中的任何错误都无法归属,且同一 source 的下一个定时触发发现没有活跃 run,多个后台 worker 在同一 source 后堆积。 **支持证据**:diff 显式添加了 `deferAutonomyCompletion`,将 `autonomy` 上下文穿透到 `processUserInputBase`,并更改 `handlePromptSubmit` 跳过延迟 run 的 finalize。 **结论**:真实且承重。由针对性代码确认。 #### H3 — "定时任务 tick 对先前运行无去重" **主张**:cron tick / heartbeat tick 无条件触发;如果先前 tick 的 run 仍在 `queued` / `running`,队列每个 interval 增长一条。跨多个 source 复合后,队列 + `runs.json` 活跃子集永不缩小。 **支持证据**:修复前 `useScheduledTasks` 和 `runHeadlessStreaming` 都调用 `createAutonomyQueuedPrompt`(无去重)。diff 用 `createAutonomyQueuedPromptIfNoActiveSource` 替换了两个调用点。 **结论**:真实且承重。由针对性代码确认。 #### H4 — "死进程 run 永久毒化去重" **主张**:即使 H3 修复了,进程在 run 期间被杀死会在磁盘上留下没有 owner 存活检查的 `running` 记录;下次加载 `runs.json` 的进程会将其视为阻塞,永远不再调度该 source。 **支持证据**:diff 打印 `ownerProcessId` 并添加 `isStaleActiveAutonomyRun` 检查。没有 H4,H3 的修复会创建新的失败模式(静默永久抑制)。 **结论**:真实但是次要的。它存在是因为 H3 的修复引入了它。必须一起发布。 > [!question] 为什么之前的本地补丁可能失败? > 这三个缺陷中的任何一个单独看起来都可以作为小 guard 修复,但只修复一个会将 OOM 转换为不同的错误行为(崩溃后静默抑制,或重复 detached worker)。最小正确修复需要所有三个原语:**同源去重**、**owner 印记 + 过期回收**、**延迟完成握手**,加上确保心跳状态在跳过的重复触发上永不推进的**两阶段提交排序**。 ### 修复计划 #### 最小修复面 | 模块 | 变更 | 原因 | |---|---|---| | `autonomyRuns.ts` | Owner 印记;`createAutonomyRunIfNoActiveSource`;`commitAutonomyQueuedPromptIfNoActiveSource`;两阶段提交;过期回收 | 结构性原语 | | `useScheduledTasks.ts` | 用 dedup helper 替换两个调用点 | 在 REPL scheduler 应用去重 | | `cli/print.ts` | headless streaming 路径的相同迁移 | 在 headless 模式应用去重 | | `handlePromptSubmit.ts` | 跟踪 `deferredAutonomyRunIds`;在 success 和 error finalize 循环中跳过它们 | 连接延迟完成契约 | | `processUserInput.ts` | 穿透 `autonomy` ctx;暴露 `deferAutonomyCompletion` | 契约的 plumbing | | `processSlashCommand.tsx` | 后台 fork 命令设置 `deferAutonomyCompletion`;拥有 finalize 调用 | 契约的实现 | | `Tool.ts` | `allowBackgroundForkedSlashCommands` 标志 | 使路径可从非打包 harness 测试 | #### 添加的测试 - `autonomyRuns.test.ts`:去重、过期回收(mock 死 PID)、owner 印记、两阶段提交不变量 - `useScheduledTasks.test.ts`:scheduler 跳过重复触发,finalize 后恢复 - `processSlashCommand.test.ts`:延迟完成握手正确传播到 `handlePromptSubmit` #### 兼容性 / 迁移风险 - 缺少 `ownerProcessId` 的旧 `runs.json` 记录被容忍——永远不被识别为 stale,保持阻塞语义。升级时磁盘上有 stale `running` 记录的运维人员仍需在**首次**手动 `cancel` 这些 run。 - **遗留阻塞的可观察性缺口**:当无 owner 的活跃记录阻塞去重时,当前代码路径是静默的。`implement` 步骤**必须**在 `persistAutonomyRunRecord` 的阻塞分支添加一行 warn 日志。 - 无 on-disk schema 版本升级。 #### 回滚计划 - 将工作树 revert 到 `main` 版本的所有 8 个文件。`runs.json` schema 增量被旧代码容忍(额外字段被忽略)。 - 如果 stale record 在回滚后阻止调度,手动编辑 `runs.json`(status → `cancelled`)。 - 无依赖、无构建标志、无 settings 文件更改。 ### 验证 #### 命令 ```bash bun run typecheck bun test src/utils/__tests__/autonomyRuns.test.ts bun test src/hooks/__tests__/useScheduledTasks.test.ts bun test src/utils/processUserInput/__tests__/processSlashCommand.test.ts bun test # full unit suite bun run lint bun run build ``` #### 手动检查 - 启动带有两个 `HEARTBEAT.md` 30s 任务的会话,运行 30 分钟以上;观察 `runs.json` 活跃状态条目数保持有界 - 在 `running` 记录期间强杀 Bun 进程。重启。验证同一 source 的下一个 tick 回收了记录(标记为 `failed`)并启动新 run - 在测试 harness 下运行 KAIROS 门控的 detached slash 命令路径,验证 `handlePromptSubmit` 在后台工作仍在活跃时不 finalize run #### 可观察性检查 - `[ScheduledTasks] skipping : previous run still queued or running` debug 日志在去重触发时出现 - `runs.json` 中 status `failed` 且 error 以 `"Recovered stale active autonomy run"` 开头的记录表明过期回收实际触发了 ### 未决问题 1. ~~`markAutonomyRunRunning` 是否在所有转换 autonomy run 到 `running` 的路径中被调用?~~ **已关闭(2026-04-28 验证)。** `markAutonomyRunRunning` 是**唯一**将 `AutonomyRunRecord.status` 转换为 `'running'` 的函数,无调用方绕过印记。 2. ~~`getSessionId()` 导入是否引入循环依赖?~~ **已关闭(2026-04-28 验证)。** 无风险:反向依赖为空,`getSessionId()` 永不 `undefined`,永不抛出。 3. 200 条上限在过期回收将 stale run 转为 `failed` 后是否仍然合适?活跃记录会更快轮转;上限可能更早滚掉合法完成记录。不是正确性问题,但值得记录。 ## 关联笔记 - [[sur-skill-overflow-bugs]]