Files
sanguo_moziplus_v2/docs/design/spawner-monitor-design.md
T
cfdaily 0d7425b88c
Deploy / ci (push) Waiting to run
Deploy / deploy (push) Blocked by required conditions
Deploy / notify-deploy-failure (push) Blocked by required conditions
auto-sync: 2026-06-07 01:35:53
2026-06-07 01:35:53 +08:00

28 KiB
Raw Blame History

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 statelock/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. 参数配置

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 JSONopenclaw agent --json 输出)+ 查任务 DB 状态。

JSON 输出格式

openclaw agent --json 输出到 stdout 的 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 回复内容

分类原则

  • 优先用 statusstatus 是 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

A0bstdout 为空但 exit=0

现象:
  - 进程退出,exit_code = 0
  - stdout 完全为空(没有 JSON 输出)
  - status 解析为 None

原因:openclaw agent --json 在某些情况下不输出 JSON(已知行为)

处理:
  - 查任务 DB 状态
  - done/review → outcome = "completed"(正常完成)
  - 其他 → outcome = "agent_error"(不续杯,等 ticker

A1status="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/A3status="timeout"

现象:
  - stdout JSON status = "timeout"

原因:Gateway timeoutAgent 被中断

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

A4status="ok" + 任务 DB status=failed

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

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

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

A5/A6status="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-A12status="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. 情况 Bmonitor_timeout 到了进程还没退出

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

B1lock 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(让用户决定)

B2lock 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"

B3lock 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 消息中列出两种可能,让用户判断

B4lock 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-iddispatcher 根据 project_id == "_mail" 判断。

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

续杯 message

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

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}"}}'

写入产出(如果之前没写)

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": "<摘要>"}}'

如果无法解决,标记失败

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 timeoutB)→ counter 不 release(进程还在跑)
  │   └─ B1 假死 → 手动 release + 复活
  │
  └─ 进程崩溃/PM2 重启 → ticker _check_timeouts 检测 → 手动 release

counter 生命周期 = 调用级:spawn 时 acquire,进程退出时 release。 只有情况 B(进程不退出)counter 保持占用。

wrapped_on_complete 保证 releasetry/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_timeoutmax_retriesmax_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 历史 10MBGateway 触发 compactcompact 耗时导致进程异常退出。

12.2 暴露的三个问题

P1Compact 检测失效

现有检测逻辑:

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

P2current_agent 不回退

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

P3Crash 无分级防护

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

12.3 修复方案

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

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

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-2current_agent 回退

原理crash 后在 _task_on_complete 中回退 current_agentassignee

# 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 回应):

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-3Crash 分级防护

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

3a. Crash 后设 cooldown
# 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 状态的超时检查:

# ticker.py _check_timeouts 末尾增加
# review 超时 → 推回 pending(让 ticker 重新 dispatch review
review_tasks = queries.tasks_by_status("review")
for task in review_tasks:
    # 检查是否有活跃的 review agentcurrent_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 次数:

# 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 中调用:

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 测试 全流程通过