Files
cs-note/claude-code-best/docs/agent/sur-loop-scheduled-oom.md
T

327 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 <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` 后是否仍然合适?活跃记录会更快轮转;上限可能更早滚掉合法完成记录。不是正确性问题,但值得记录。
## 关联笔记
- [[sur-skill-overflow-bugs]]