Files
sanguo_moziplus_v2/docs/design/spawner-monitor-design.md
T
2026-05-22 13:56:23 +08:00

16 KiB
Raw Blame History

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. 参数配置

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)。

Session 策略

任务类型 Session 策略 说明
_mail 项目 主 Agent session(不带 --session-id Mail 投递到主 sessionGateway Queue 保证可靠排队,session lane 隔离 Mail 和 Task 互不干扰
普通任务 新 session--session-id uuid4 未来可动态选择主/sub

实现:spawn_full_agent(use_main_session=True) → 不传 --session-iddispatcher 根据 project_id == "_mail" 判断。

续杯 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,
)

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_timeoutmax_retriesmax_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