Files

17 KiB
Raw Permalink Blame History

tags, create time
tags create time
OOM
调度任务
内存溢出
autonomy
两阶段提交
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 测试逃生口

调用流(修复后)

定时任务路径

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 命令路径

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(增量)

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 的。

过期回收规则

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 状态生命周期

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 文件更改。

验证

命令

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 <id>: 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 后是否仍然合适?活跃记录会更快轮转;上限可能更早滚掉合法完成记录。不是正确性问题,但值得记录。

关联笔记