17 KiB
tags, create time
| tags | 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。
实际行为(修复前)
useScheduledTasks和 headless streaming 路径在每个 tick 上无条件调用createAutonomyQueuedPromptcommitAutonomyQueuedPrompt在 run record 持久化之前就调用了commitPreparedAutonomyTurn,所以即使是应该被丢弃的重复触发也已经修改了心跳任务的 last-run 状态AutonomyRunRecord没有 owner 标识,所以由已死进程启动的运行永远留在running。后续同一sourceId的运行无法检测到其前身已经消失- 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 的 stalerunning记录在下次启动时继续阻塞去重逻辑。
系统边界
范围内
- 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.tsrunHeadlessStreaming) 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.tsflow 状态机- 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 过期回收新路径"]
新不变量
-
同源互斥:任意时刻最多一条
(trigger, sourceId, ownerKey, status in active)的非 stale 记录。在persistAutonomyRunRecord的withAutonomyPersistenceLock内强制执行。 -
活跃转换时打 owner 印记:任何将 run 设置为
queued或running的路径都必须打印ownerProcessId = process.pid和ownerSessionId = getSessionId()。markAutonomyRunRunning已更新以在 running 转换时执行此操作。 -
两阶段提交排序:心跳任务 last-run 状态只能在 run record 成功插入后才能推进。等价于"prompt commit => run row exists"。
-
延迟完成契约:如果 slash 命令的 result 带有
deferAutonomyCompletion=true,harness(handlePromptSubmit)不得 finalize run;命令实现拥有 finalize 调用。通过deferredAutonomyRunIds集合跟踪。
并发 / 重试风险
- 两个共享同一项目根目录的进程可以竞争
runs.json。由withAutonomyPersistenceLock(文件锁)缓解。 - 同一进程内同一定时任务的两个 tick 在同一把锁上串行;只有第一个获胜,其余看到活跃记录并返回
null。 - 进程在持久化记录和提交 prompt 之间被杀死会留下带死 PID 的
queued记录。同一 source 的下一个 tick 的过期回收将其转为failed,释放 source。
两阶段提交崩溃窗口(已知限制)
在 commitAutonomyQueuedPromptInternal 内,顺序是:
createAutonomyRunCore→persistAutonomyRunRecord→ run row 在锁下写入commitPreparedAutonomyTurn(prepared)→ 内存heartbeatTaskLastRunByKeyMap 推进
这两步不是原子的。如果进程在 (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,保持阻塞语义。升级时磁盘上有 stalerunning记录的运维人员仍需在首次手动cancel这些 run。 - 遗留阻塞的可观察性缺口:当无 owner 的活跃记录阻塞去重时,当前代码路径是静默的。
implement步骤必须在persistAutonomyRunRecord的阻塞分支添加一行 warn 日志。 - 无 on-disk schema 版本升级。
回滚计划
- 将工作树 revert 到
main版本的所有 8 个文件。runs.jsonschema 增量被旧代码容忍(额外字段被忽略)。 - 如果 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.md30s 任务的会话,运行 30 分钟以上;观察runs.json活跃状态条目数保持有界 - 在
running记录期间强杀 Bun 进程。重启。验证同一 source 的下一个 tick 回收了记录(标记为failed)并启动新 run - 在测试 harness 下运行 KAIROS 门控的 detached slash 命令路径,验证
handlePromptSubmit在后台工作仍在活跃时不 finalize run
可观察性检查
[ScheduledTasks] skipping <id>: previous run still queued or runningdebug 日志在去重触发时出现runs.json中 statusfailed且 error 以"Recovered stale active autonomy run"开头的记录表明过期回收实际触发了
未决问题
-
已关闭(2026-04-28 验证)。markAutonomyRunRunning是否在所有转换 autonomy run 到running的路径中被调用?markAutonomyRunRunning是唯一将AutonomyRunRecord.status转换为'running'的函数,无调用方绕过印记。 -
已关闭(2026-04-28 验证)。 无风险:反向依赖为空,getSessionId()导入是否引入循环依赖?getSessionId()永不undefined,永不抛出。 -
200 条上限在过期回收将 stale run 转为
failed后是否仍然合适?活跃记录会更快轮转;上限可能更早滚掉合法完成记录。不是正确性问题,但值得记录。