28 KiB
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. 核心设计原则
- 每次 agent 调用都是独占的:openclaw 无论成功失败都会返回,最差情况 timeout 返回。谁占用谁持有,进程退出就 release
- counter 生命周期是调用级:spawn 时 acquire,进程退出就 release。不是任务级
- spawn 前检查所有可用信号:counter + 冷却期 + session state(lock/processing/compact),避免注定失败的 spawn
- 不主动 kill 进程:进程可能还在正常执行,kill 会丢失所有进度
- 续杯只有 Gateway timeout 才触发:lock/compact/api_error 等不续杯,等 ticker
- 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 JSON(openclaw 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 回复内容 |
分类原则
- 优先用
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
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 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 检测失效
现有检测逻辑:
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 记录。
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。
# 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-3:Crash 分级防护
设计原则:不是"禁用 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 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 回应):双层判断:
spawner.get_session_by_agent(current_agent)→ 检查内存中是否有 running session- 如果有 → 检查 PID 存活 → PID 活着则不回收
- 如果无内存记录 → 无活跃进程 → 可回收
推回原子性(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 测试 | 全流程通过 |