19 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.1:per (agent, session) 粒度(2026-05-26)
问题
v2.0 的 max_per_agent=1 是 per agent 粒度,导致:
- 张飞正在跑一个 task(sub session,use_main_session=False)
- 这时又来一个新 task 需要张飞 spawn 新 sub session
- counter 拦住 → AgentBusyError
- 但 sub session 之间是独立的(不同 session_id,不同 lock),不应该互相限制
新设计:三层控制
| 层级 | 配置 | 默认 | 作用 |
|---|---|---|---|
| per session key | max_per_session |
1 | 同一 (agent, session) 不能并发 spawn |
| per agent | max_concurrent_sessions |
3 | 同一 agent 最多同时跑 N 个不同 session |
| global | max_global_agents |
5 | 全局总并发上限 |
counter key:f"{agent_id}:{session_id}"
- main session →
zhangfei:main - sub session →
zhangfei:abc-123 - 不同 key 互不影响,同一 key 最多 1 个
session_id 分配前移:
# 新顺序(spawn_full_agent 内部)
1. 分配 session_id(main → "main", reuse → 传入值, new → uuid4)
2. session state 检查(main 时才检查)
3. counter.can_acquire(agent_id, session_id) # 检查三层
4. counter.acquire(agent_id, session_id) # 占用 key
5. spawn
cooldown
cooldown 保持 per agent 粒度(API 429 限制的是 agent 的 API key,不是 session)。 冷却期间该 agent 的所有 spawn 都被拒绝。
超限行为
can_acquire返回 False →AgentBusyError- 任务/mail 保持 pending,等下个 tick(30s)重试
- 不会丢失
release 调用点
所有 release 必须带 session_id:
| 调用点 | 当前 | 改后 |
|---|---|---|
| wrapped_on_complete 闭包 | release(aid) |
release(aid, session_id) |
| spawn 失败 fallback(行 441) | release(agent_id) |
release(agent_id, session_id) |
| _do_retry 续杯 | release(agent_id) |
release(agent_id, old_session_id) |
wrapped_on_complete 闭包需要捕获外层 session_id 变量。
配置变更
# config/default.yaml
max_global_agents: 5 # 不变
max_per_session: 1 # 新增,替代 max_per_agent
max_concurrent_sessions: 3 # 新增,per agent 总并发
# max_per_agent: 已删除
counter 生命周期(v2.1:per session 级)
spawn_full_agent 内部
│
├─ 1. 分配 session_id
├─ 2. session state 检查(main 时)
├─ 3. can_acquire(agent_id, session_id) → 检查三层
├─ 4. acquire(agent_id, session_id) → 占用 key
├─ 5. spawn 进程
│
├─ 进程退出 → wrapped_on_complete(aid, session_id) → release(aid, session_id)
│ ├─ A1/A4 完成 → 结束
│ ├─ A2/A3 timeout → _do_retry: release(aid, old_sid) → spawn_full_agent(acquire 新的)
│ └─ A7-A12 → release → 等 ticker
│
├─ monitor timeout(B)→ counter 不 release(进程还在跑)
│ └─ B1 假死 → 手动 release(aid, session_id) + 复活
│
└─ 进程崩溃/PM2 重启 → ticker _check_timeouts 检测 → 手动 release(aid, session_id)
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 |