From f29a194c9c8f18acdf8d7ef15733b53078e362eb Mon Sep 17 00:00:00 2001 From: cfdaily Date: Tue, 26 May 2026 11:29:30 +0800 Subject: [PATCH] auto-sync: 2026-05-26 11:29:30 --- docs/design/spawner-monitor-design.md | 176 ++++++++++++++++---------- 1 file changed, 106 insertions(+), 70 deletions(-) diff --git a/docs/design/spawner-monitor-design.md b/docs/design/spawner-monitor-design.md index 847947d..b562590 100644 --- a/docs/design/spawner-monitor-design.md +++ b/docs/design/spawner-monitor-design.md @@ -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. 测试计划 | 用例 | 模拟方式 | 验证 | |------|---------|------|