# Spawner Monitor 设计文档

> 版本：v2.0 | 日期：2026-05-26 | 作者：庞统 | 状态：v2.0 大幅更新（P0 stdout 修复 + spawn 前检查 + counter 调用级 + 假死复活术）

## 1. 背景与问题

当前 `_monitor_process` 只看进程退出码，不读 stdout/stderr，不检查 session 状态，无法区分超时原因。且超时后直接 `proc.kill()`，可能丢失 Agent 执行进度。

`openclaw agent` 命令的实际行为：
- Gateway 有内置 timeout（默认 600s），到时间后 Gateway 会中断 Agent，进程自行退出
- 中断后上下文保留在 session 里，用同一 session-id 再次调用可继续（"续杯"机制）
- 执行过程中可能触发 auto-compaction（上下文压缩），compact 完成后自动 retrying prompt
- compact 期间进程不退出，只是执行时间变长
- 极端情况下进程可能卡住不退出（LLM 卡死、Gateway 异常等）

## 2. 核心设计原则

1. **每次 agent 调用都是独占的**：openclaw 无论成功失败都会返回，最差情况 timeout 返回。谁占用谁持有，进程退出就 release
2. **counter 生命周期是调用级**：spawn 时 acquire，进程退出就 release。不是任务级
3. **spawn 前检查所有可用信号**：counter + 冷却期 + session state（lock/processing/compact），避免注定失败的 spawn
4. **不主动 kill 进程**：进程可能还在正常执行，kill 会丢失所有进度
5. **续杯只有 Gateway timeout 才触发**：lock/compact/api_error 等不续杯，等 ticker
6. **escalate 不自动 kill**：超过重试上限后标记 failed + escalate，是否 kill 留给用户决定

## 3. Spawn 前检查（拦截无效 spawn）

spawn_full_agent 启动进程前，依次检查：

| # | 场景 | 检测方法 | 检测到后方案 |
|---|------|---------|-------------|
| L1 | moziplus 内部并发 | counter.can_acquire() | AgentBusyError → 等 ticker |
| L2 | API 429 冷却期 | counter.is_cooling_down() | AgentBusyError → 等 ticker |
| L3a | main session 被外部占用 | _check_session_state → lock_pid_alive | AgentBusyError → 等 ticker |
| L3b | main session 正在执行 | _check_session_state → status=processing | AgentBusyError → 等 ticker |
| L3c | main session 正在 compact | _check_session_state → recent_compact | AgentBusyError → 等 ticker |

L1+L3 互补：counter 防 moziplus 内部并发，session state 防外部占用（webchat/Control UI/cron）。
所有检查失败统一走 AgentBusyError → 任务保持 working → ticker 30 秒后重新调度。

## 4. 参数配置

```yaml
daemon:
  gateway_timeout: 600          # 传给 openclaw agent --timeout 的值
  agent_timeout: 630            # _monitor_process 的等待时间（比 gateway_timeout 长 30s）
  max_retries: 3                # 续杯上限（情况 A）
  max_monitor_timeouts: 3       # monitor timeout 上限（情况 B）
```

## 5. 情况 A：进程在 monitor_timeout 内退出

```
0s                    600s                630s
├──────────────────────┤──────────────────┤
│  Agent 执行中         │ Gateway timeout   │ monitor timeout
│                      │ 进程退出           │ （情况 B 才到这里）
```

进程退出后，读 stdout JSON（`openclaw agent --json` 输出）+ 查任务 DB 状态。

### JSON 输出格式

`openclaw agent --json` 输出到 stdout 的 JSON 结构：

```json
{
  "status": "ok",
  "summary": "completed",
  "result": {
    "payloads": [{ "text": "...", "mediaUrl": null }],
    "meta": {
      "durationMs": 5673,
      "executionTrace": {
        "runner": "gateway",
        "fallbackUsed": false,
        "fallbackReason": null
      },
      "aborted": false
    }
  }
}
```

### 可用字段

| 字段 | 路径 | 取值范围 | 说明 |
|------|------|---------|------|
| `status` | `data.status` | `"ok"` / `"error"` / `"timeout"` | CLI 执行结果 |
| `summary` | `data.summary` | `"completed"` / 错误信息字符串 | 辅助判断 |
| `fallbackUsed` | `data.result.meta.executionTrace.fallbackUsed` | `true` / `false` | 是否 fallback |
| `fallbackReason` | `data.result.meta.executionTrace.fallbackReason` | `"gateway_timeout"` 等 | fallback 原因 |
| `payloads` | `data.result.payloads` | `[{text, mediaUrl}]` 或空数组 | Agent 回复内容 |

### 分类原则

- **优先用 `status`**：`status` 是 Gateway 官方提供的执行结果，比推断准确
- **不解析 `meta` 的其他字段**：agentMeta、systemPromptReport 等是 OpenClaw 内部信息
- **stdout 为空 = 进程异常终止**：`openclaw agent` 正常退出一定会输出 JSON

### A0：stdout 为空（进程异常终止）

> 注意：A0 在判定顺序上位于 A4 之后。exit=0 + stdout 为空 + task_status=done/review
> 会被 A4/A1 兜住，不会走到 A0。

```
现象：
  - 进程退出，exit_code ≠ 0
  - stdout 完全为空（没有 JSON 输出）
  - status 解析为 None

原因：进程被异常终止（被 kill、崩溃等），没有走到 writeRuntimeJson

处理：
  - 记录 outcome = "process_crash"
  - counter.release()（wrapped_on_complete 可能没被调用 → ticker T1 兜底）
  - 不续杯
  - 任务保持 working → ticker T1 检测 PID 死 → release counter + 推回 pending
  - 等 ticker 重新 dispatch
```

### A0b：stdout 为空但 exit=0

```
现象：
  - 进程退出，exit_code = 0
  - stdout 完全为空（没有 JSON 输出）
  - status 解析为 None

原因：openclaw agent --json 在某些情况下不输出 JSON（已知行为）

处理：
  - 查任务 DB 状态
  - done/review → outcome = "completed"（正常完成）
  - 其他 → outcome = "agent_error"（不续杯，等 ticker）
```

### A1：status="ok" + summary="completed" + fallbackUsed=false

```
现象：
  - stdout JSON status = "ok"
  - summary = "completed"
  - executionTrace.fallbackUsed = false
  - 任务 DB status = done 或 review

原因：Agent 正常完成

处理：
  - counter.release()（由 wrapped_on_complete 保证）
  - 记录 outcome = "completed"
  - 无需其他操作
```

### A2/A3：status="timeout"

```
现象：
  - stdout JSON status = "timeout"

原因：Gateway timeout，Agent 被中断

处理：
  - counter.release()
  - 续杯次数 +1
  - 超过上限(3) → ❌ failed + escalate
  - 未超限 → 🔄 通过 spawn_full_agent 续杯
  - 续杯 message：提示 Agent 检查历史继续未完成工作
```

### A4：status="ok" + 任务 DB status=failed

```
现象：
  - stdout JSON status = "ok"
  - 但任务 DB status = failed

原因：Agent 自己判断无法完成，主动标了 failed

处理：
  - counter.release()
  - 记录 outcome = "agent_failed"
  - 尊重 Agent 的判断，不续杯
```

### A5/A6：status="ok" + fallbackUsed=true

```
现象：
  - stdout JSON status = "ok"
  - executionTrace.fallbackUsed = true
  - executionTrace.runner = "embedded"

原因：Gateway 端超时/错误，CLI fallback 到本地 embedded 执行

处理：
  - 查任务 DB 状态
  - done/review → release counter → 结束（fallback 成功完成了）
  - working/claimed → release counter → 标 failed + escalate
  - 记录 outcome = "fallback_timeout"，附带 warning
```

### A7-A12：status="error"

```
现象：
  - stdout JSON status = "error"
  - summary 含错误信息

原因：各类错误（认证/连接/API/compact/lock/未知）

处理：
  - counter.release()
  - 不续杯
  - 记录 outcome = "api_error" / "gateway_unreachable" / "auth_failed" 等
  - 等 ticker 重新调度
```

### A 兜底：status 未知值

```
现象：
  - stdout 有 JSON 但 status 不是 ok/error/timeout

原因：未预期的状态值

处理：
  - counter.release()
  - 不续杯
  - 记录 outcome = "unknown_status"
  - 等 ticker 重新调度
```
## 6. 情况 B：monitor_timeout 到了进程还没退出

```
0s                    600s                630s
├──────────────────────┤──────────────────┤
│  Agent 执行中         │ Gateway timeout   │ monitor timeout 触发
│                      │ （可能没触发）     │ 进程还没退出
```

### B1：lock PID 已死 + sessions.json status=running

```
现象：
  - monitor_timeout 触发，进程没退出
  - lock 文件中的 PID 已不存在（os.kill(pid, 0) 抛 ProcessLookupError）
  - sessions.json 中 status = "running"

原因：Gateway 异常退出/崩溃，没有清理 lock 和 session 状态
      子进程可能已经变成孤儿进程

处理（v2.0：假死复活术）：
  1. 尝试复活：
     a. 修改 sessions.json，把对应 session 的 status 从 running 改为 idle
     b. release counter
     c. ticker 下次 dispatch 时重新投递任务给 agent
  2. 如果同一任务连续假死 ≥ 2 次：
     - ❌ failed + escalate
     - 记录 outcome = "session_stuck"
     - escalate 消息中包含：PID、session key、诊断信息、假死次数
  3. 不 kill（让用户决定）
```

### B2：lock PID 存活 + sessions.json status=running + stderr 有 compact 关键字

```
现象：
  - monitor_timeout 触发，进程没退出
  - lock PID 仍然存活
  - sessions.json status = "running"
  - 已读的 stderr 含 "compaction" / "context-overflow"

原因：compact 正在进行中或 compact 后 retrying prompt 仍在执行
      compact 本身可能耗时很长（最长记录 15 分钟）

处理：
  - monitor_timeout_count +1
  - 未超限(< 3) → 不 release counter → 再启动一轮 _monitor_process 继续等
  - 超限(≥ 3，累计 31.5 分钟) → ❌ failed + escalate → counter.release()，不 kill
  - 记录 outcome = "compact_hanging"
```

### B3：lock PID 存活 + sessions.json status=running + 无 compact 关键字

```
现象：
  - monitor_timeout 触发，进程没退出
  - lock PID 存活
  - sessions.json status = "running"
  - 无 compact 相关关键字

原因（两种可能）：
  a) LLM 推理极慢/卡死（无输出）
  b) 长任务正在执行（Agent 有在输出，但整体时间超过预期）

处理：
  - monitor_timeout_count +1
  - 未超限(< 3) → 不 release counter → 再启动一轮 _monitor_process 继续等
  - 超限(≥ 3) → ❌ failed + escalate → counter.release()，不 kill
  - 记录 outcome = "process_hanging"

区别 a 和 b：
  - 无法在 monitor 层面精确区分
  - escalate 消息中列出两种可能，让用户判断
```

### B4：lock PID 存活 + sessions.json status≠running（如 idle）

```
现象：
  - monitor_timeout 触发，进程没退出
  - lock PID 存活
  - sessions.json status = "idle" 或其他非 running 状态

原因：session 状态已被其他操作改变（如 /reset、daily reset），
      但子进程还在运行（可能是 Gateway 正在清理或延迟退出）

处理：
  - 再等 60s（给 Gateway 清理时间）
  - 如果进程仍未退出 → 按 B3 处理
  - 记录 outcome = "session_state_mismatch"
```

## 7. 续杯机制

### 续杯触发条件

进程退出 + 任务 API 状态不是终态（done/failed/cancelled）。

### Session 策略

| 任务类型 | Session 策略 | 说明 |
|---------|-------------|------|
| `_mail` 项目 | **主 Agent session**（不带 `--session-id`） | Mail 投递到主 session |
| 普通任务 | 新 session（`--session-id uuid4`） | 未来可动态选择主/sub |

实现：`spawn_full_agent(use_main_session=True)` → 不传 `--session-id`，dispatcher 根据 `project_id == "_mail"` 判断。

⚠️ **已知问题**：Mail 用 main session 和 webchat/Control UI 共享同一 session。当 webchat 占用时，spawn 会等 session lock → timeout。通过 spawn 前 L3 session state 检查提前拦截。

### 续杯 message

```python
RETRY_PROMPT = """你收到一个续杯提醒。你的任务在执行过程中被中断了。

## 任务信息

- 项目: {project_id}
- 任务ID: {task_id}
- 标题: {title}
- 续杯次数: 第 {retry_count} 次（上限 {max_retries} 次）

请检查 session 历史中你之前做了什么，然后继续未完成的工作。

## 操作指令

### 查看任务当前状态
```bash
curl http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_id}?expand=all
```

### 如果已经完成，标记 review
```bash
curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_id}/status \
  -H 'Content-Type: application/json' \
  -d '{{"status": "review", "agent": "{agent_id}"}}'
```

### 写入产出（如果之前没写）
```bash
curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_id}/outputs \
  -H 'Content-Type: application/json' \
  -d '{{"agent": "{agent_id}", "type": "<类型>", "title": "<标题>", "content": "<内容>", "summary": "<摘要>"}}'
```

### 如果无法解决，标记失败
```bash
curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_id}/status \
  -H 'Content-Type: application/json' \
  -d '{{"status": "failed", "agent": "{agent_id}", "detail": "<失败原因>"}}'
```

{fallback_hint}"""
```

### 续杯 spawn

```python
# 续杯时复用 session_id
session_id = task.detail.get("retry_session_id") or original_session_id

await self.spawner.spawn_full_agent(
    agent_id=agent_id,
    message=RETRY_PROMPT.format(...),
    session_id=session_id,          # 复用！
    task_id=task.id,
    on_complete=on_complete,
)
```

## 8. 计数器设计

| 计数器 | 用途 | 上限 | 超限处理 |
|--------|------|------|---------|
| `retry_count` | 续杯次数（A2/A3/A10/A12） | 3 | failed + escalate |
| `connect_retry_count` | 连接失败次数（A8） | 3 | failed + escalate |
| `api_retry_count` | API 错误次数（A9） | 3 | failed + escalate |
| `lock_retry_count` | Lock 冲突次数（A11） | 3 | ticker 下个 tick 重试 |
| `monitor_timeout_count` | monitor timeout 次数（B2/B3） | 3 | failed + escalate |

存储在 `task_attempts.metadata` JSON 中。

### counter 生命周期（v2.0：调用级）

```
spawn_full_agent 内部 acquire
  │
  ├─ 进程退出 → wrapped_on_complete → counter.release()
  │   ├─ A1/A4 完成 → 结束
  │   ├─ A2/A3 timeout → _do_retry 手动 release → spawn_full_agent 重新 acquire
  │   └─ A7-A12 → release → 等 ticker
  │
  ├─ monitor timeout（B）→ counter 不 release（进程还在跑）
  │   └─ B1 假死 → 手动 release + 复活
  │
  └─ 进程崩溃/PM2 重启 → ticker _check_timeouts 检测 → 手动 release
```

counter 生命周期 = 调用级：spawn 时 acquire，进程退出时 release。
只有情况 B（进程不退出）counter 保持占用。

wrapped_on_complete 保证 release（try/finally），即使业务回调异常也不泄漏。

## 9. escalate 消息格式

```
⚠️ Agent {agent_id} 任务 {task_id} 执行异常

类型: {outcome}
累计时间: {elapsed}
PID: {pid}（{存活/已死}）
Session: {session_key}
续杯次数: {retry_count}/{max_retries}

诊断信息:
- sessions.json status: {status}
- lock PID: {lock_pid}
- 最后 stderr: {stderr_tail}
- compaction checkpoints: {recent_checkpoints}

建议操作:
1. 查看日志: pm2 logs sanguo-moziplus-v2
2. 检查 session: openclaw sessions --agent {agent_id} --json
3. 继续执行: 如果任务在正常执行，手动 reset 状态
4. 终止进程: kill {pid}（强制终止，会丢失进度）

请决定如何处理。
```

## 10. 改动范围

| 文件 | 改动 | 预估行数 |
|------|------|---------|
| `src/daemon/spawner.py` | `_monitor_process` 重写（情况 A/B 全部分支） | ~150 行 |
| `src/daemon/spawner.py` | `spawn_full_agent` 加 `--timeout` + session_id 复用 | ~15 行 |
| `src/daemon/spawner.py` | 新增辅助方法：`_get_task_status`、`_classify_exit`、`_read_sessions_json`、`_check_lock_pid` | ~80 行 |
| `src/daemon/spawner.py` | 新增 `RETRY_PROMPT` 模板 | ~20 行 |
| `src/daemon/ticker.py` | `_check_timeouts`：暂时性失败（A8/A9/A11）不改状态，等 ticker 自然重试 | ~15 行 |
| `config/guardrails.yaml` | 无需改动 | — |
| `config/default.yaml` | 新增 `gateway_timeout`、`max_retries`、`max_monitor_timeouts` | ~3 行 |

注：`task_attempts` 表已有 `metadata` 列（TEXT 类型），无需改 db.py/models.py。

总计约 280 行，3 个文件。

## 11. 测试计划

| 用例 | 模拟方式 | 验证 |
|------|---------|------|
| A1 正常完成 | E2E 已有 | 任务 done |
| A2 Gateway timeout + 续杯 | 手动设 gateway_timeout=60s，任务需要 90s | 续杯 1 次后完成 |
| A3 连 working 都没写 | 模拟 Agent 第一步就超时 | 续杯后从步骤 1 开始 |
| A4 Agent 自己 failed | Agent 输出 `status: failed` | 不续杯 |
| A5 fallback 成功 | 模拟 Gateway timeout（很难模拟） | 查任务状态决定 |
| A7 认证失败 | 改错 token | 不续杯，escalate |
| A8 Gateway 不可达 | 停 Gateway | 重试 3 次后 escalate |
| B1 假死 | kill Gateway 但保留子进程 | escalate |
| B2 compact 卡住 | 主 session 长对话触发 compact | 等待或 escalate |
| B3 进程不退出 | 模拟长时间无输出 | 等 3 轮后 escalate |
| 续杯上限 | 设 max_retries=1，任务永远完不成 | 第 2 次续杯后 failed |

---

## 12. v2.8.1 补丁：Crash 健壮性（2026-05-31）

> 作者：庞统 | 触发：R1 E2E 中 simayi 连续 crash 3 次无熔断

### 12.1 问题发现

R1 E2E 测试中，simayi-challenger 在 review 阶段连续 crash 3 次（exit=1，空 stdout）：

```
18:32:20  Spawned (broadcast), session=None, pid=13844
          status=done, recent_compact=False ← compact 检测通过（实际 Gateway 在做 compact）
          Gateway 加载 10MB main session → 触发 auto-compaction → 耗时 ~3 分钟
18:35:47  Crash (exit=1), task_status=review
          → on_complete: "review agent crashed, NOT marking done"
18:35:48  PM2 restart → ticker 重启 → re-dispatch review to simayi
          recent_routing 未拦截（crash 时 routing_decision 未持久化）
18:36:51  Crash again (exit=1)
...（共 3 次 crash）
```

**根因**：main session 历史 10MB，Gateway 触发 compact，compact 耗时导致进程异常退出。

### 12.2 暴露的三个问题

#### P1：Compact 检测失效

现有检测逻辑：
```python
for cp in main_session.get("compactionCheckpoints", []):
    if (now_ms - cp.get("createdAt", 0)) < 300_000:
        result["recent_compact"] = True
```

**失效原因**：
- Gateway 的 compact 不写入 `compactionCheckpoints`（只在 session reset 时写入，且只保留 3 条）
- 实际 `compactionCount` 已达 68 次，但 checkpoints 最新一条是 10 天前
- 因此 `recent_compact` 始终为 False

#### P2：current_agent 不回退

review dispatch 时设置 `current_agent = simayi-challenger`，crash 后不回退。
导致 `exclude_current=True` 时，simayi 被排除，但无其他 review agent 可用 → 任务卡住。

#### P3：Crash 无分级防护

- crash 后无 cooldown（cooldown 只在 api_error/429 时设置）
- review 状态无超时回收（`_check_timeouts` 只查 claimed/working）
- 无 crash 计数上限，同一 task 可无限重试
- v2.7.2 设计文档曾讨论 crash_count 熔断，结论是"❌ 不采纳，崩溃可能是任务问题"
  → 部分正确，但缺乏更精细的分级处理

### 12.3 修复方案

#### Fix-1：Compact 检测改用 session jsonl 末尾扫描

**原理**：Gateway 完成一次 compact 后，会在 session jsonl 文件末尾追加一条 `type=compaction` 记录。

```python
def _check_recent_compaction(agent_id: str, window_seconds: int = 300) -> bool:
    """读 session jsonl 末尾，检查是否有 window_seconds 内的 compaction 记录"""
    sessions_path = Path.home() / ".openclaw" / "agents" / agent_id / "sessions" / "sessions.json"
    if not sessions_path.exists():
        return False
    try:
        with open(sessions_path) as f:
            sessions = json.load(f)
        main = sessions.get(f"agent:{agent_id}:main", {})
        session_file = main.get("sessionFile", "")
        if not session_file or not Path(session_file).exists():
            return False
        # 读末尾 50 行，查找最近的 compaction
        from datetime import datetime, timezone
        now = datetime.now(timezone.utc)
        with open(session_file, "rb") as sf:
            # seek to last 50KB
            sf.seek(0, 2)
            size = sf.tell()
            sf.seek(max(0, size - 51200))
            tail = sf.read().decode("utf-8", errors="replace")
        for line in reversed(tail.splitlines()):
            if not line.strip():
                continue
            try:
                obj = json.loads(line)
            except:
                continue
            if obj.get("type") == "compaction":
                ts = obj.get("timestamp", "")
                if ts:
                    ct = datetime.fromisoformat(ts.replace("Z", "+00:00"))
                    if (now - ct).total_seconds() < window_seconds:
                        return True
        return False
    except Exception:
        return False
```

**改动范围**：`spawner.py` `_check_session_state` 方法，替换 `compactionCheckpoints` 检测逻辑。

**同时保留** `_compact_waits` 的 monitor compact 等待逻辑不变。

**边界处理**（review WARN 补充）：
- jsonl 不存在 → 返回 False（无 compact = 安全）
- json.loads 失败 → 跳过该行（compact 未完成 = 不判定为 compacting）
- 只在 agent 非空闲时（status 不是 done/idle）才扫描，减少不必要 I/O
- 检测到 compacting 后同时设 cooldown（和 Fix-3a 共用），避免 ticker 反复 dispatch → AgentBusyError 循环

#### Fix-2：current_agent 回退

**原理**：crash 后在 `_task_on_complete` 中回退 `current_agent` 到 `assignee`。

```python
# dispatcher.py _task_on_complete 中
if _is_review:
    if outcome in ("completed", "session_revived"):
        _dispatcher._mark_task_status(_task_db, _task_id, "done")
    else:
        logger.warning("Task %s: review agent %s (%s), NOT marking done", _task_id, aid, outcome)
        # Fix-2: crash 后回退 current_agent，避免 exclude_current 卡死
        conn = get_connection(_task_db)
        try:
            conn.execute(
                "UPDATE tasks SET current_agent = "
                "(SELECT assignee FROM tasks WHERE id=?) "
                "WHERE id=? AND current_agent=?",
                (_task_id, _task_id, aid)
            )
            conn.commit()
        finally:
            conn.close()
```

**改动范围**：`dispatcher.py` `_task_on_complete` 回调，~6 行。

**精确化条件**（review WARN 回应）：
```python
ROLLBACK_OUTCOMES = {"crashed", "compact_failed", "process_crash", "session_stuck", "compact_hanging"}
```
只对明确的异常 outcome 回退，skipped/cancelled 不回退。

**assignee 排除问题**：回退后 assignee 可能仍在 exclude 列表。但 exclude_current 的逻辑是在 `_dispatch_reviews` 中基于 `task.current_agent` 构建，回退后 current_agent=assignee（原始执行者），exclude 会排除的是 review agent（已 crash 的那个），不是 assignee。所以无冲突。

#### Fix-3：Crash 分级防护

**设计原则**：不是"禁用 agent"（v2.7.2 已否决），而是"给 agent 恢复时间 + 限制单 task 重试次数"。

##### 3a. Crash 后设 cooldown

```python
# spawner.py _handle_exit else 分支（crash 走这里）
if outcome in ("crashed", "compact_failed", "process_crash", "agent_error"):
    if self.counter:
        self.counter.set_cooldown(agent_id, seconds=300)  # 5 分钟冷却
        logger.info("Crash cooldown set for %s: 300s", agent_id)
```

**改动范围**：`spawner.py` `_handle_exit` else 分支，~4 行。

**理由**：crash 后 agent 的 session 可能处于异常状态（timeout/compacting），
5 分钟冷却给 Gateway/agent 恢复时间。如果 compact 正在进行，5 分钟后一般已完成。

**优化**（review INFO 采纳）：cooldown 固定 5 分钟足够，不引入轮询复杂度。
compact 场景通过 Fix-1 的 compact 检测 + AgentBusyError 覆盖，cooldown 是额外保险。

##### 3b. Review 超时回收

在 `_check_timeouts` 中增加 review 状态的超时检查：

```python
# ticker.py _check_timeouts 末尾增加
# review 超时 → 推回 pending（让 ticker 重新 dispatch review）
review_tasks = queries.tasks_by_status("review")
for task in review_tasks:
    # 检查是否有活跃的 review agent（current_agent 对应的进程是否存活）
    current = task.current_agent  # 需要确认 task model 有此字段
    if current:
        session_info = self.spawner.get_session_by_agent(current) if self.spawner else None
        pid = session_info.get("pid") if session_info else None
        if pid and self._is_pid_alive(pid):
            continue  # review agent 还在跑，不回收
    # 无活跃进程 → 检查超时
    updated = task.updated_at
    if updated:
        elapsed = (now - datetime.fromisoformat(updated)).total_seconds() / 60.0
        if elapsed > self.review_timeout_minutes:  # 默认 15 分钟
            conn = get_connection(db_path)
            try:
                self._transition_status(
                    conn, task.id, "pending",
                    agent="daemon",
                    detail={"reason": "review_timeout", "elapsed_minutes": round(elapsed, 1)},
                )
                # 回退 current_agent
                conn.execute(
                    "UPDATE tasks SET current_agent = NULL WHERE id=?", (task.id,)
                )
                conn.commit()
                logger.warning("Review timeout: %s (%.1fm), pushed back to pending",
                               task.id, elapsed)
            finally:
                conn.close()
```

**改动范围**：`ticker.py` `_check_timeouts`，~25 行。

**配置**：新增 `review_timeout_minutes` 参数，默认 15 分钟。

**"无活跃进程"定义**（review WARN 回应）：双层判断：
1. `spawner.get_session_by_agent(current_agent)` → 检查内存中是否有 running session
2. 如果有 → 检查 PID 存活 → PID 活着则不回收
3. 如果无内存记录 → 无活跃进程 → 可回收

**推回原子性**（review WARN 回应）：推回前先 recheck 当前状态，
如果已是 pending/working/done/failed 则不推回（可能被其他操作改变了）。

**Fix-3b/3c 交互**（review INFO 回应）：超时推回 pending → ticker 重新 dispatch →
如果又 crash，这个 crash 会通过 `_record_attempt` 记录到 task_attempts，
自然被 `_check_crash_limit` 计数。无需特殊处理。

##### 3c. Crash 计数 + 上限 escalate

利用现有 `task_attempts` 表，在 dispatch 前查该 task 的最近 crash 次数：

```python
# dispatcher.py dispatch() 入口增加
def _check_crash_limit(self, task_id: str, db_path: Path, limit: int = 3) -> bool:
    """检查 task 最近 30 分钟内的 crash 次数是否超限"""
    try:
        conn = get_connection(db_path)
        try:
            row = conn.execute(
                "SELECT COUNT(*) as cnt FROM task_attempts "
                "WHERE task_id=? AND outcome='crashed' "
                "AND created_at > datetime('now', '-30 minutes')",
                (task_id,)
            ).fetchone()
            return (row["cnt"] if row else 0) >= limit
        finally:
            conn.close()
    except Exception:
        return False
```

在 `_dispatch_reviews` 中调用：
```python
if self._check_crash_limit(task.id, db_path, limit=3):
    # 超限 → 标 failed + escalate
    conn = get_connection(db_path)
    try:
        self._transition_status(
            conn, task.id, "failed",
            agent="daemon",
            detail={"reason": "review_crash_limit", "task_id": task.id},
        )
    finally:
        conn.close()
    logger.error("Task %s: 3 crashes in 30min, marking failed", task.id)
    continue
```

**改动范围**：`dispatcher.py` 新增 ~15 行方法 + `_dispatch_reviews` 调用点 ~8 行。

### 12.4 改动总览

| 文件 | 改动 | 行数 |
|------|------|------|
| `spawner.py` | Fix-1 compact 检测替换 + Fix-3a crash cooldown | ~30 行 |
| `dispatcher.py` | Fix-2 current_agent 回退 + Fix-3c crash 计数 | ~25 行 |
| `ticker.py` | Fix-3b review 超时回收 | ~25 行 |
| **总计** | | **~80 行** |

### 12.5 测试计划

| 用例 | 模拟方式 | 验证 |
|------|---------|------|
| Compact 检测 | 手动在 session jsonl 末尾写 compaction 记录 | recent_compact=True → AgentBusyError |
| current_agent 回退 | review agent crash → 检查 DB current_agent = assignee | ✅ 回退 |
| Crash cooldown | review agent crash → 检查 counter.is_cooling_down() | ✅ 5 分钟冷却 |
| Review 超时 | review 状态 15 分钟无活跃进程 → 检查推回 pending | ✅ 回收 |
| Crash 计数上限 | 同一 task 连续 crash 3 次 → 检查标 failed | ✅ escalate |
| E2E 回归 | 重新跑 v3.0 E2E 测试 | 全流程通过 |
