Files
sanguo_moziplus_v2/docs/design/spawner-monitor-design.md
T
2026-05-22 12:58:15 +08:00

487 lines
16 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 设计文档
> 版本:v1.1 | 日期:2026-05-22 | 作者:庞统 | 状态:评审通过(v1.1 已修正评审意见)
## 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. **不主动 kill 进程**:进程可能还在正常执行,kill 会丢失所有进度
2. **保持 session 不变**:重试时复用同一个 session-id,Agent 能看到之前的上下文
3. **monitor timeout > Gateway timeout**:给 Gateway 足够时间自行处理
4. **续杯机制**:进程退出但任务未完成 → 用同一 session 再 spawn 一次
5. **escalate 不自动 kill**:超过重试上限后标记 failed + escalate,是否 kill 留给用户决定
## 3. 参数配置
```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
```
## 4. 情况 A:进程在 monitor_timeout 内退出
```
0s 600s 630s
├──────────────────────┤──────────────────┤
│ Agent 执行中 │ Gateway timeout │ monitor timeout
│ │ 进程退出 │ (情况 B 才到这里)
```
进程退出后,读 stdout JSON + stderr + 查任务实际 API 状态。
### A1exit=0 + transport=gateway + 任务已是 done/review
```
现象:
- exit_code = 0
- meta.transport = "gateway"
- meta.fallbackReason = null
- 任务 API 状态 = done 或 review
原因:Agent 正常完成
处理:
- 记录 outcome = "completed"
- counter.release()
- 无需其他操作
```
### A2exit=0 + transport=gateway + 任务仍是 working
```
现象:
- exit_code = 0
- meta.transport = "gateway"
- 任务 API 状态 = working
原因:Gateway timeout 触发,Agent 被中断但之前已写了 working。
Agent 可能执行了步骤1-2(working + 执行),但没完成步骤3-4outputs + review
处理:
- 续杯次数 +1
- 超过上限(3) → ❌ failed + escalate → counter.release()
- 未超限 → 🔄 用同一 session_id spawn(续杯),counter 不 release
- 续杯 message:提示 Agent 检查历史继续未完成工作
```
### A3exit=0 + transport=gateway + 任务仍是 claimed
```
现象:
- exit_code = 0
- meta.transport = "gateway"
- 任务 API 状态 = claimed
原因:Gateway timeout 触发,Agent 连步骤1(写 working)都没来得及
处理:
- 续杯次数 +1
- 超过上限(3) → ❌ failed + escalate → counter.release()
- 未超限 → 🔄 用同一 session_id spawn(续杯),counter 不 release
- 续杯 message:完整任务 prompt(和首次一样)
```
### A4exit=0 + transport=gateway + 任务已是 failed
```
现象:
- exit_code = 0
- 任务 API 状态 = failed
原因:Agent 自己判断无法完成,主动写了 failed
处理:
- 记录 outcome = "agent_failed"
- counter.release()
- 尊重 Agent 的判断,不续杯
- 如果 Agent 写了 detail,记录到事件
```
### A5exit=0 + transport=embedded + fallbackReason=gateway_timeout
```
现象:
- exit_code = 0
- meta.transport = "embedded"
- meta.fallbackReason = "gateway_timeout"
原因:Gateway 端超时,CLI 自动 fallback 到本地 embedded 执行。
fallback 用的是新 sessiongateway-fallback-* 前缀),不在原 session 里。
Agent 可能在 fallback 里完成了部分工作。
处理:
- 查任务实际 API 状态
- 如果已是 done/review → 同 A1
- 如果仍是 working/claimed → 同 A2/A3(续杯,用原 session_id,不用 fallback session
- 续杯 prompt 额外提示:"之前有 fallback 执行,请调 API 检查任务当前状态和已有产出,确认是否已完成"
- 记录 outcome = "fallback_timeout",附带 warning
```
### A6exit=0 + transport=embedded + fallbackReason ≠ gateway_timeout
```
现象:
- exit_code = 0
- meta.transport = "embedded"
- meta.fallbackReason ≠ "gateway_timeout"(可能是连接断开、Gateway 异常等)
原因:Gateway 不可用,CLI 本地 fallback
处理:
- 同 A5:查任务状态,决定是否续杯
- 记录 outcome = "fallback_other"
```
### A7exit≠0 + stderr 含 401/403/auth/unauthorized
```
现象:
- exit_code ≠ 0
- stderr 含认证失败关键字
原因:Gateway token 过期、配置错误
处理:
- ❌ failed + escalate
- 不续杯(重试也会失败)
- 记录 outcome = "auth_failed"
```
### A8exit≠0 + stderr 含 ECONNREFUSED/ETIMEDOUT/gateway closed
```
现象:
- exit_code ≠ 0
- stderr 含连接错误关键字
原因:Gateway 进程挂了、网络断
处理:
- connect_retry_count +1(不计入 max_retries
- connect_retry_count ≥ 3 → ❌ failed + escalate → counter.release()
- 未超限 → 不改变任务状态(保持 claimed/working),不 release counter
- 只写 metadataoutcome=gateway_unreachable),让 ticker 下个 tick 自然重新调度
- ⚠️ 不在 spawner 里 sleep 等待(避免阻塞 monitor 逻辑)
```
### A9exit≠0 + stderr 含 rate_limit/500/503/API error
```
现象:
- exit_code ≠ 0
- stderr 含模型 API 错误关键字
原因:模型提供商限流、服务异常
处理:
- api_retry_count +1(不计入 max_retries
- api_retry_count ≥ 3 → ❌ failed + escalate → counter.release()
- 未超限 → 不改变任务状态,不 release counter
- 只写 metadataoutcome=api_error),让 ticker 下个 tick 自然重新调度
- ⚠️ 不在 spawner 里 sleep 等待
```
### A10exit≠0 + stderr 含 compaction-diag/context-overflow/timeout-compaction
```
现象:
- exit_code ≠ 0
- stderr 含 compact 相关关键字
原因:compact 后模型返回错误(丢失上下文导致无法继续)
处理:
- retry_count +1(计入 max_retries
- 超过上限(3) → ❌ failed + escalate → counter.release()
- 未超限 → 🔄 用同一 session_id spawn(续杯),counter 不 release
- 记录 outcome = "compact_failed"
```
### A11exit≠0 + stderr 含 lock/busy/concurrent/lane task error
```
现象:
- exit_code ≠ 0
- stderr 含 lock 冲突关键字
原因:session lock 冲突(webchat/cron/其他 spawner 占用)
v2 用 --session-id uuid4 已基本避免,但保留兜底
处理:
- lock_retry_count +1(不计入 max_retries
- lock_retry_count ≥ 3 → ❌ failed + escalate → counter.release()
- 未超限 → 不改变任务状态,不 release counter
- 只写 metadataoutcome=lock_conflict),让 ticker 下个 tick 自然重新调度
- ⚠️ 不在 spawner 里 sleep 等待
```
### A12exit≠0 + stderr 无特殊关键字
```
现象:
- exit_code ≠ 0
- stderr 无认证/连接/API/compact/lock 关键字
原因:Agent 自身逻辑错误、工具执行失败、或其他未知错误
处理:
- retry_count +1(计入 max_retries
- 超过上限(3) → ❌ failed + escalate → counter.release()
- 未超限 → 🔄 用同一 session_id spawn(续杯),counter 不 release
- 记录 outcome = "agent_error"
```
## 5. 情况 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 状态
子进程可能已经变成孤儿进程
处理:
- ❌ failed + escalate
- 不 kill(让用户决定)
- 记录 outcome = "session_stuck"
- escalate 消息中包含:PID、session key、诊断信息
```
### 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"
```
## 6. 续杯机制
### 续杯触发条件
进程退出 + 任务 API 状态不是终态(done/failed/cancelled)。
### 续杯 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,
)
```
## 7. 计数器设计
| 计数器 | 用途 | 上限 | 超限处理 |
|--------|------|------|---------|
| `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 生命周期
```
首次 acquiredispatcher.dispatch
├─ 续杯 spawn → counter 不 release(保持占用)
├─ 继续等(B2/B3 → counter 不 release
├─ 暂时性失败回 tickerA8/A9/A11 → counter 不 release
└─ 最终完成/failed/escalate → counter.release()
```
counter 占用贯穿整个续杯链,只在以下情况 release:
- 任务最终完成(A1/A4
- 超过重试上限 → failed + escalate
- 认证失败(A7
- 假死(B1
## 8. 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}(强制终止,会丢失进度)
请决定如何处理。
```
## 9. 改动范围
| 文件 | 改动 | 预估行数 |
|------|------|---------|
| `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 个文件。
## 10. 测试计划
| 用例 | 模拟方式 | 验证 |
|------|---------|------|
| 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 |