487 lines
16 KiB
Markdown
487 lines
16 KiB
Markdown
# 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 状态。
|
||
|
||
### A1:exit=0 + transport=gateway + 任务已是 done/review
|
||
|
||
```
|
||
现象:
|
||
- exit_code = 0
|
||
- meta.transport = "gateway"
|
||
- meta.fallbackReason = null
|
||
- 任务 API 状态 = done 或 review
|
||
|
||
原因:Agent 正常完成
|
||
|
||
处理:
|
||
- 记录 outcome = "completed"
|
||
- counter.release()
|
||
- 无需其他操作
|
||
```
|
||
|
||
### A2:exit=0 + transport=gateway + 任务仍是 working
|
||
|
||
```
|
||
现象:
|
||
- exit_code = 0
|
||
- meta.transport = "gateway"
|
||
- 任务 API 状态 = working
|
||
|
||
原因:Gateway timeout 触发,Agent 被中断但之前已写了 working。
|
||
Agent 可能执行了步骤1-2(working + 执行),但没完成步骤3-4(outputs + review)
|
||
|
||
处理:
|
||
- 续杯次数 +1
|
||
- 超过上限(3) → ❌ failed + escalate → counter.release()
|
||
- 未超限 → 🔄 用同一 session_id spawn(续杯),counter 不 release
|
||
- 续杯 message:提示 Agent 检查历史继续未完成工作
|
||
```
|
||
|
||
### A3:exit=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(和首次一样)
|
||
```
|
||
|
||
### A4:exit=0 + transport=gateway + 任务已是 failed
|
||
|
||
```
|
||
现象:
|
||
- exit_code = 0
|
||
- 任务 API 状态 = failed
|
||
|
||
原因:Agent 自己判断无法完成,主动写了 failed
|
||
|
||
处理:
|
||
- 记录 outcome = "agent_failed"
|
||
- counter.release()
|
||
- 尊重 Agent 的判断,不续杯
|
||
- 如果 Agent 写了 detail,记录到事件
|
||
```
|
||
|
||
### A5:exit=0 + transport=embedded + fallbackReason=gateway_timeout
|
||
|
||
```
|
||
现象:
|
||
- exit_code = 0
|
||
- meta.transport = "embedded"
|
||
- meta.fallbackReason = "gateway_timeout"
|
||
|
||
原因:Gateway 端超时,CLI 自动 fallback 到本地 embedded 执行。
|
||
fallback 用的是新 session(gateway-fallback-* 前缀),不在原 session 里。
|
||
Agent 可能在 fallback 里完成了部分工作。
|
||
|
||
处理:
|
||
- 查任务实际 API 状态
|
||
- 如果已是 done/review → 同 A1
|
||
- 如果仍是 working/claimed → 同 A2/A3(续杯,用原 session_id,不用 fallback session)
|
||
- 续杯 prompt 额外提示:"之前有 fallback 执行,请调 API 检查任务当前状态和已有产出,确认是否已完成"
|
||
- 记录 outcome = "fallback_timeout",附带 warning
|
||
```
|
||
|
||
### A6:exit=0 + transport=embedded + fallbackReason ≠ gateway_timeout
|
||
|
||
```
|
||
现象:
|
||
- exit_code = 0
|
||
- meta.transport = "embedded"
|
||
- meta.fallbackReason ≠ "gateway_timeout"(可能是连接断开、Gateway 异常等)
|
||
|
||
原因:Gateway 不可用,CLI 本地 fallback
|
||
|
||
处理:
|
||
- 同 A5:查任务状态,决定是否续杯
|
||
- 记录 outcome = "fallback_other"
|
||
```
|
||
|
||
### A7:exit≠0 + stderr 含 401/403/auth/unauthorized
|
||
|
||
```
|
||
现象:
|
||
- exit_code ≠ 0
|
||
- stderr 含认证失败关键字
|
||
|
||
原因:Gateway token 过期、配置错误
|
||
|
||
处理:
|
||
- ❌ failed + escalate
|
||
- 不续杯(重试也会失败)
|
||
- 记录 outcome = "auth_failed"
|
||
```
|
||
|
||
### A8:exit≠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
|
||
- 只写 metadata(outcome=gateway_unreachable),让 ticker 下个 tick 自然重新调度
|
||
- ⚠️ 不在 spawner 里 sleep 等待(避免阻塞 monitor 逻辑)
|
||
```
|
||
|
||
### A9:exit≠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
|
||
- 只写 metadata(outcome=api_error),让 ticker 下个 tick 自然重新调度
|
||
- ⚠️ 不在 spawner 里 sleep 等待
|
||
```
|
||
|
||
### A10:exit≠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"
|
||
```
|
||
|
||
### A11:exit≠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
|
||
- 只写 metadata(outcome=lock_conflict),让 ticker 下个 tick 自然重新调度
|
||
- ⚠️ 不在 spawner 里 sleep 等待
|
||
```
|
||
|
||
### A12:exit≠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. 情况 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 状态
|
||
子进程可能已经变成孤儿进程
|
||
|
||
处理:
|
||
- ❌ failed + escalate
|
||
- 不 kill(让用户决定)
|
||
- 记录 outcome = "session_stuck"
|
||
- escalate 消息中包含:PID、session key、诊断信息
|
||
```
|
||
|
||
### 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"
|
||
```
|
||
|
||
## 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 生命周期
|
||
|
||
```
|
||
首次 acquire(dispatcher.dispatch)
|
||
│
|
||
├─ 续杯 spawn → counter 不 release(保持占用)
|
||
├─ 继续等(B2/B3) → counter 不 release
|
||
├─ 暂时性失败回 ticker(A8/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 |
|