auto-sync: 2026-05-26 11:29:30
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Spawner Monitor 设计文档
|
||||
|
||||
> 版本:v1.1 | 日期:2026-05-22 | 作者:庞统 | 状态:评审通过(v1.1 已修正评审意见)
|
||||
> 版本:v2.0 | 日期:2026-05-26 | 作者:庞统 | 状态:v2.0 大幅更新(P0 stdout 修复 + spawn 前检查 + counter 调用级 + 假死复活术)
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
@@ -15,13 +15,29 @@
|
||||
|
||||
## 2. 核心设计原则
|
||||
|
||||
1. **不主动 kill 进程**:进程可能还在正常执行,kill 会丢失所有进度
|
||||
2. **保持 session 不变**:重试时复用同一个 session-id,Agent 能看到之前的上下文
|
||||
3. **monitor timeout > Gateway timeout**:给 Gateway 足够时间自行处理
|
||||
4. **续杯机制**:进程退出但任务未完成 → 用同一 session 再 spawn 一次
|
||||
5. **escalate 不自动 kill**:超过重试上限后标记 failed + escalate,是否 kill 留给用户决定
|
||||
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. 参数配置
|
||||
## 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:
|
||||
@@ -31,7 +47,7 @@ daemon:
|
||||
max_monitor_timeouts: 3 # monitor timeout 上限(情况 B)
|
||||
```
|
||||
|
||||
## 4. 情况 A:进程在 monitor_timeout 内退出
|
||||
## 5. 情况 A:进程在 monitor_timeout 内退出
|
||||
|
||||
```
|
||||
0s 600s 630s
|
||||
@@ -42,6 +58,21 @@ daemon:
|
||||
|
||||
进程退出后,读 stdout JSON + stderr + 查任务实际 API 状态。
|
||||
|
||||
### ⚠️ P0 修复:stdout JSON 解析路径
|
||||
|
||||
openclaw agent `--json` 输出格式是 `{ "response": { "meta": { ... } } }`,
|
||||
不是 `{ "meta": { ... } }`。`_parse_stdout_json` 必须取 `data["response"]["meta"]`。
|
||||
|
||||
修复前 68% 的 spawn 结果 transport=null(62/91 次),A 场景分类全部失效。
|
||||
|
||||
### ⚠️ P2 兜底:transport=null 时的 stderr 辅助判断
|
||||
|
||||
如果 P0 修复后 transport 仍为 null(解析失败),A2/A3 判定前先检查 stderr:
|
||||
- stderr 含 lock/busy → lock_conflict
|
||||
- stderr 含 compact → compact_failed
|
||||
- stderr 含 rate_limit → api_error
|
||||
- 否则走 gateway_timeout(续杯)
|
||||
|
||||
### A1:exit=0 + transport=gateway + 任务已是 done/review
|
||||
|
||||
```
|
||||
@@ -55,7 +86,7 @@ daemon:
|
||||
|
||||
处理:
|
||||
- 记录 outcome = "completed"
|
||||
- counter.release()
|
||||
- counter.release()(由 wrapped_on_complete 保证)
|
||||
- 无需其他操作
|
||||
```
|
||||
|
||||
@@ -64,16 +95,17 @@ daemon:
|
||||
```
|
||||
现象:
|
||||
- exit_code = 0
|
||||
- meta.transport = "gateway"
|
||||
- meta.transport = "gateway"(P0 修复后可正确解析)
|
||||
- 任务 API 状态 = working
|
||||
|
||||
原因:Gateway timeout 触发,Agent 被中断但之前已写了 working。
|
||||
Agent 可能执行了步骤1-2(working + 执行),但没完成步骤3-4(outputs + review)
|
||||
|
||||
处理:
|
||||
- counter.release()(v2.0:调用级生命周期)
|
||||
- 续杯次数 +1
|
||||
- 超过上限(3) → ❌ failed + escalate → counter.release()
|
||||
- 未超限 → 🔄 用同一 session_id spawn(续杯),counter 不 release
|
||||
- 超过上限(3) → ❌ failed + escalate
|
||||
- 未超限 → 🔄 通过 spawn_full_agent 续杯(内部 can_acquire + acquire)
|
||||
- 续杯 message:提示 Agent 检查历史继续未完成工作
|
||||
```
|
||||
|
||||
@@ -88,9 +120,10 @@ daemon:
|
||||
原因:Gateway timeout 触发,Agent 连步骤1(写 working)都没来得及
|
||||
|
||||
处理:
|
||||
- counter.release()(v2.0:调用级生命周期)
|
||||
- 续杯次数 +1
|
||||
- 超过上限(3) → ❌ failed + escalate → counter.release()
|
||||
- 未超限 → 🔄 用同一 session_id spawn(续杯),counter 不 release
|
||||
- 超过上限(3) → ❌ failed + escalate
|
||||
- 未超限 → 🔄 通过 spawn_full_agent 续杯
|
||||
- 续杯 message:完整任务 prompt(和首次一样)
|
||||
```
|
||||
|
||||
@@ -105,7 +138,7 @@ daemon:
|
||||
|
||||
处理:
|
||||
- 记录 outcome = "agent_failed"
|
||||
- counter.release()
|
||||
- counter.release()(由 wrapped_on_complete 保证)
|
||||
- 尊重 Agent 的判断,不续杯
|
||||
- 如果 Agent 写了 detail,记录到事件
|
||||
```
|
||||
@@ -122,12 +155,12 @@ daemon:
|
||||
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
|
||||
处理(v2.0):
|
||||
- counter.release()(由 wrapped_on_complete 保证)
|
||||
- A5/A6 fallback 不应出现——出现说明 spawn 时 agent 被占用(L3 检查失效)
|
||||
- 记录 ERROR 级日志,含 agent_id/session_id/task_id/transport/fallbackReason/counter_active
|
||||
- 标 failed + escalate
|
||||
- 不续杯
|
||||
```
|
||||
|
||||
### A6:exit=0 + transport=embedded + fallbackReason ≠ gateway_timeout
|
||||
@@ -169,12 +202,10 @@ daemon:
|
||||
|
||||
原因: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 逻辑)
|
||||
处理(v2.0):
|
||||
- counter.release()(进程退出 = release)
|
||||
- 不续杯,等 ticker 重新调度
|
||||
- 记录 outcome = "gateway_unreachable"
|
||||
```
|
||||
|
||||
### A9:exit≠0 + stderr 含 rate_limit/500/503/API error
|
||||
@@ -186,12 +217,11 @@ daemon:
|
||||
|
||||
原因:模型提供商限流、服务异常
|
||||
|
||||
处理:
|
||||
- api_retry_count +1(不计入 max_retries)
|
||||
- api_retry_count ≥ 3 → ❌ failed + escalate → counter.release()
|
||||
- 未超限 → 不改变任务状态,不 release counter
|
||||
- 只写 metadata(outcome=api_error),让 ticker 下个 tick 自然重新调度
|
||||
- ⚠️ 不在 spawner 里 sleep 等待
|
||||
处理(v2.0):
|
||||
- counter.release()(进程退出 = release)
|
||||
- 推回 pending(让 ticker 重新调度)
|
||||
- counter.set_cooldown(agent_id, 120s)(冷却期,防止立即重试又 429)
|
||||
- 不续杯
|
||||
```
|
||||
|
||||
### A10:exit≠0 + stderr 含 compaction-diag/context-overflow/timeout-compaction
|
||||
@@ -203,10 +233,9 @@ daemon:
|
||||
|
||||
原因:compact 后模型返回错误(丢失上下文导致无法继续)
|
||||
|
||||
处理:
|
||||
- retry_count +1(计入 max_retries)
|
||||
- 超过上限(3) → ❌ failed + escalate → counter.release()
|
||||
- 未超限 → 🔄 用同一 session_id spawn(续杯),counter 不 release
|
||||
处理(v2.0):
|
||||
- counter.release()(进程退出 = release)
|
||||
- 不续杯,等 ticker 重新调度
|
||||
- 记录 outcome = "compact_failed"
|
||||
```
|
||||
|
||||
@@ -220,12 +249,10 @@ daemon:
|
||||
原因: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 等待
|
||||
处理(v2.0):
|
||||
- counter.release()(进程退出 = release)
|
||||
- 不续杯,等 ticker 重新调度
|
||||
- 记录 outcome = "lock_conflict"
|
||||
```
|
||||
|
||||
### A12:exit≠0 + stderr 无特殊关键字
|
||||
@@ -237,14 +264,13 @@ daemon:
|
||||
|
||||
原因:Agent 自身逻辑错误、工具执行失败、或其他未知错误
|
||||
|
||||
处理:
|
||||
- retry_count +1(计入 max_retries)
|
||||
- 超过上限(3) → ❌ failed + escalate → counter.release()
|
||||
- 未超限 → 🔄 用同一 session_id spawn(续杯),counter 不 release
|
||||
处理(v2.0):
|
||||
- counter.release()(进程退出 = release)
|
||||
- 不续杯,等 ticker 重新调度
|
||||
- 记录 outcome = "agent_error"
|
||||
```
|
||||
|
||||
## 5. 情况 B:monitor_timeout 到了进程还没退出
|
||||
## 6. 情况 B:monitor_timeout 到了进程还没退出
|
||||
|
||||
```
|
||||
0s 600s 630s
|
||||
@@ -264,11 +290,16 @@ daemon:
|
||||
原因:Gateway 异常退出/崩溃,没有清理 lock 和 session 状态
|
||||
子进程可能已经变成孤儿进程
|
||||
|
||||
处理:
|
||||
- ❌ failed + escalate
|
||||
- 不 kill(让用户决定)
|
||||
- 记录 outcome = "session_stuck"
|
||||
- escalate 消息中包含:PID、session key、诊断信息
|
||||
处理(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 关键字
|
||||
@@ -331,7 +362,7 @@ daemon:
|
||||
- 记录 outcome = "session_state_mismatch"
|
||||
```
|
||||
|
||||
## 6. 续杯机制
|
||||
## 7. 续杯机制
|
||||
|
||||
### 续杯触发条件
|
||||
|
||||
@@ -341,11 +372,13 @@ daemon:
|
||||
|
||||
| 任务类型 | Session 策略 | 说明 |
|
||||
|---------|-------------|------|
|
||||
| `_mail` 项目 | **主 Agent session**(不带 `--session-id`) | Mail 投递到主 session,Gateway Queue 保证可靠排队,session lane 隔离 Mail 和 Task 互不干扰 |
|
||||
| `_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
|
||||
@@ -406,7 +439,7 @@ await self.spawner.spawn_full_agent(
|
||||
)
|
||||
```
|
||||
|
||||
## 7. 计数器设计
|
||||
## 8. 计数器设计
|
||||
|
||||
| 计数器 | 用途 | 上限 | 超限处理 |
|
||||
|--------|------|------|---------|
|
||||
@@ -418,25 +451,28 @@ await self.spawner.spawn_full_agent(
|
||||
|
||||
存储在 `task_attempts.metadata` JSON 中。
|
||||
|
||||
### counter 生命周期
|
||||
### counter 生命周期(v2.0:调用级)
|
||||
|
||||
```
|
||||
首次 acquire(dispatcher.dispatch)
|
||||
spawn_full_agent 内部 acquire
|
||||
│
|
||||
├─ 续杯 spawn → counter 不 release(保持占用)
|
||||
├─ 继续等(B2/B3) → counter 不 release
|
||||
├─ 暂时性失败回 ticker(A8/A9/A11) → counter 不 release
|
||||
├─ 进程退出 → wrapped_on_complete → counter.release()
|
||||
│ ├─ A1/A4 完成 → 结束
|
||||
│ ├─ A2/A3 timeout → _do_retry 手动 release → spawn_full_agent 重新 acquire
|
||||
│ └─ A7-A12 → release → 等 ticker
|
||||
│
|
||||
└─ 最终完成/failed/escalate → counter.release()
|
||||
├─ monitor timeout(B)→ counter 不 release(进程还在跑)
|
||||
│ └─ B1 假死 → 手动 release + 复活
|
||||
│
|
||||
└─ 进程崩溃/PM2 重启 → ticker _check_timeouts 检测 → 手动 release
|
||||
```
|
||||
|
||||
counter 占用贯穿整个续杯链,只在以下情况 release:
|
||||
- 任务最终完成(A1/A4)
|
||||
- 超过重试上限 → failed + escalate
|
||||
- 认证失败(A7)
|
||||
- 假死(B1)
|
||||
counter 生命周期 = 调用级:spawn 时 acquire,进程退出时 release。
|
||||
只有情况 B(进程不退出)counter 保持占用。
|
||||
|
||||
## 8. escalate 消息格式
|
||||
wrapped_on_complete 保证 release(try/finally),即使业务回调异常也不泄漏。
|
||||
|
||||
## 9. escalate 消息格式
|
||||
|
||||
```
|
||||
⚠️ Agent {agent_id} 任务 {task_id} 执行异常
|
||||
@@ -462,7 +498,7 @@ Session: {session_key}
|
||||
请决定如何处理。
|
||||
```
|
||||
|
||||
## 9. 改动范围
|
||||
## 10. 改动范围
|
||||
|
||||
| 文件 | 改动 | 预估行数 |
|
||||
|------|------|---------|
|
||||
@@ -478,7 +514,7 @@ Session: {session_key}
|
||||
|
||||
总计约 280 行,3 个文件。
|
||||
|
||||
## 10. 测试计划
|
||||
## 11. 测试计划
|
||||
|
||||
| 用例 | 模拟方式 | 验证 |
|
||||
|------|---------|------|
|
||||
|
||||
Reference in New Issue
Block a user