327 lines
17 KiB
Markdown
327 lines
17 KiB
Markdown
---
|
||
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]]
|