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

775 lines
28 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.
# 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. 参数配置
```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
### A0stdout 为空(进程异常终止)
> 注意: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-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 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_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 历史 10MBGateway 触发 compactcompact 耗时导致进程异常退出。
### 12.2 暴露的三个问题
#### P1Compact 检测失效
现有检测逻辑:
```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
#### P2current_agent 不回退
review dispatch 时设置 `current_agent = simayi-challenger`crash 后不回退。
导致 `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` 记录。
```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-2current_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-3Crash 分级防护
**设计原则**:不是"禁用 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 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 次数:
```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 测试 | 全流程通过 |