# 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. 核心设计原则

1. **每次 agent 调用都是独占的**：openclaw 无论成功失败都会返回，最差情况 timeout 返回。谁占用谁持有，进程退出就 release
2. **counter 生命周期是调用级**：spawn 时 acquire，进程退出就 release。不是任务级
3. **spawn 前检查所有可用信号**：counter + 冷却期 + session state（lock/processing/compact），避免注定失败的 spawn
4. **不主动 kill 进程**：进程可能还在正常执行，kill 会丢失所有进度
5. **续杯只有 Gateway timeout 才触发**：lock/compact/api_error 等不续杯，等 ticker
6. **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. 参数配置

```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）
```

## 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 结构：

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

```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,
)
```

## 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 分配前移**：

```python
# 新顺序（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 变量。

#### 配置变更

```yaml
# 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 |
