174 lines
8.1 KiB
Markdown
174 lines
8.1 KiB
Markdown
# v2.7.2 防重复调用 & 防无限续杯 — 完整方案
|
||
|
||
**版本**: v2.0
|
||
**日期**: 2026-05-26
|
||
**作者**: 庞统
|
||
**状态**: 已实现 + 待最终评审
|
||
|
||
---
|
||
|
||
## 1. 核心原则
|
||
|
||
> **每次 agent 调用都是独占的。** openclaw 无论成功失败都会返回,最差情况 timeout 返回。谁占用谁持有,进程退出就 release。
|
||
|
||
---
|
||
|
||
## 2. 根因分析
|
||
|
||
### 2.1 2026-05-25/26 事件复盘
|
||
|
||
司马懿 + 庞统被 moziplus-v2 连续 spawn,叠加 API 调用触发 zhipu 429,双模型不可用导致 Gateway 假死。
|
||
|
||
### 2.2 发现的三个根因
|
||
|
||
| 根因 | 影响 | 严重度 |
|
||
|------|------|--------|
|
||
| **P0:`_parse_stdout_json` 解析路径错误** | `data.get("meta")` 应为 `data["response"]["meta"]`。68% 的 spawn 结果 transport=null(62/91 次)。A 场景分类全部失效 | **致命** |
|
||
| counter 生命周期是任务级 | retry 绕过 dispatcher 直接 spawn,不检查 counter | 高 |
|
||
| spawn 前缺 session state 检查 | webchat 占用 main session 时仍然 spawn,注定失败 | 高 |
|
||
|
||
### 2.3 P0 详细说明
|
||
|
||
openclaw agent `--json` 输出格式:
|
||
```json
|
||
{ "kind": "agent-response", "response": { "meta": { "transport": "gateway", ... } } }
|
||
```
|
||
|
||
代码取的是 `data.get("meta")` 而不是 `data["response"]["meta"]`,导致:
|
||
- 所有 transport=null → A1/A5/A6 分类失效
|
||
- session lock 阻塞退出被误判为 gateway_timeout(A2/A3)→ 续杯循环
|
||
- 续杯循环叠加 API 调用 → 429 → Gateway 假死
|
||
|
||
---
|
||
|
||
## 3. 完整场景清单
|
||
|
||
### 3.1 Spawn 前检查(拦截无效 spawn)
|
||
|
||
| # | 场景 | 检测方法 | 检测到后方案 |
|
||
|---|------|---------|-------------|
|
||
| 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 统一结果:不 spawn,任务保持 working,ticker 30 秒后重新调度。
|
||
|
||
L1+L3 互补:counter 防 moziplus 内部并发,session state 防外部占用(webchat/Control UI/cron)。
|
||
|
||
### 3.2 Spawn 后 — 进程退出(情况 A,0-630 秒内)
|
||
|
||
| # | 场景 | 检测方法 | 条件 | 检测到后方案 |
|
||
|---|------|---------|------|-------------|
|
||
| A1 | 正常完成 | stdout transport + 任务状态 | exit=0 + transport≠embedded + 终态 | release counter → 结束 |
|
||
| A2/A3 | Gateway timeout | stdout transport + 任务状态 | exit=0 + 非终态 + transport 正常 | release counter → 续杯 |
|
||
| A4 | Agent 自标 failed | 任务状态 | 任务=failed | release counter → 结束 |
|
||
| A5/A6 | Gateway fallback | stdout transport=embedded + fallbackReason | exit=0 + transport=embedded | release counter → 标 failed + escalate |
|
||
| A7 | 认证失败 | exit≠0 + stderr | exit≠0 + stderr 含 401/403 | release counter → 标 failed + escalate |
|
||
| A8 | Gateway 不可达 | exit≠0 + stderr | exit≠0 + stderr 含 ECONNREFUSED/ETIMEDOUT | release counter → 等 ticker |
|
||
| A9 | API 429 | exit≠0 + stderr | exit≠0 + stderr 含 rate_limit/500/503 | release counter → 推回 pending + 冷却 120s |
|
||
| A10 | Compact 失败 | exit≠0 + stderr | exit≠0 + stderr 含 compaction-diag | release counter → 等 ticker |
|
||
| A11 | Session lock 冲突 | exit≠0 + stderr | exit≠0 + stderr 含 lock/busy/concurrent | release counter → 等 ticker |
|
||
| A12 | 兜底未知错误 | 兜底 | exit≠0 不匹配以上 | release counter → 等 ticker |
|
||
| A2+P2 | transport=null 兜底 | stderr 辅助判断 | exit=0 + transport=null + 非终态 | 检查 stderr → lock/compact/api_error 或走 gateway_timeout |
|
||
|
||
**续杯:只有 A2/A3 才触发续杯。其他都 release counter,等 ticker 或推回 pending。**
|
||
|
||
### 3.3 Spawn 后 — 进程不退出(情况 B,630 秒后)
|
||
|
||
| # | 场景 | 检测方法 | 条件 | 检测到后方案 |
|
||
|---|------|---------|------|-------------|
|
||
| B1 | 假死(lock PID 死了) | _check_session_state | lock_pid 存在但 PID 不存活 | 标 failed + escalate + release counter |
|
||
| B2 | Compact 进行中 | _check_session_state + stderr | lock_pid 存活 + stderr 有 compact | 继续等(不递增计数,独立上限) |
|
||
| B3 | 进程不退出 | _check_session_state | lock_pid 存活 + 无 compact | 继续等(递增计数,≥3 次 → failed) |
|
||
| B4 | Session 状态不匹配 | _check_session_state | sessions.json status≠running | 等 60s → 按 B3 处理 |
|
||
|
||
### 3.4 续杯限制
|
||
|
||
| # | 计数器 | 上限 | 超限后方案 |
|
||
|---|--------|------|-----------|
|
||
| R1 | retry_count | 3 次(每次 ~10 分钟) | 标 failed + escalate |
|
||
| R2 | connect_retry_count | 3 次 | 标 failed |
|
||
| R3 | api_retry_count | 3 次 | 标 failed |
|
||
| R4 | lock_retry_count | 3 次 | 标 failed |
|
||
| R5 | monitor_timeout_count | 3 次 | 标 failed |
|
||
|
||
### 3.5 Ticker 兜底(每 30 秒检查)
|
||
|
||
| # | 检查 | 方案 |
|
||
|---|------|------|
|
||
| T1 | 进程存活性:counter 占用但 PID 不存活 | release counter + 推回 pending |
|
||
| T2 | 任务超时:working 超时(默认 30 分钟) | 标 failed |
|
||
|
||
### 3.6 假死复活术(2026-05-04 经验)
|
||
|
||
**现象**:sessions.json 状态为 running 但 agent 长时间无响应。
|
||
|
||
**根因**:Gateway 认为 session 还活着(running)但实际连接已断开,无超时清理。
|
||
|
||
**复活步骤**:
|
||
1. 修改 sessions.json,把对应 session 的 status 从 running 改为 idle
|
||
2. 发心跳激活(把当前任务再发一遍给 agent)
|
||
|
||
**待设计**:将此复活术集成到 ticker `_check_timeouts` 中,检测到 sessions.json status=running 但 lock PID 不存活时自动执行。
|
||
|
||
---
|
||
|
||
## 4. 改动文件清单
|
||
|
||
| 文件 | 改动 | 版本 |
|
||
|------|------|------|
|
||
| `counter.py` | 新增 cooldown 机制(is_cooling_down / set_cooldown) | v1 |
|
||
| `spawner.py` | 新增 AgentBusyError | v1 |
|
||
| `spawner.py` | spawn_full_agent 内部 counter acquire/release + wrapped_on_complete(try/finally) | v1 |
|
||
| `spawner.py` | spawn_full_agent 加 L3 session state 检查 | v2 |
|
||
| `spawner.py` | _classify_outcome 去掉 release_counter,只有 A2/A3 触发 retry | v1 |
|
||
| `spawner.py` | _classify_outcome 加 P2 transport=null 兜底 | v2 |
|
||
| `spawner.py` | _handle_exit:A9 推回 pending + 冷却,A5/A6 标 failed + context 日志 | v1 |
|
||
| `spawner.py` | _do_retry 手动 release counter + 通过 spawn_full_agent 重试 | v1 |
|
||
| `spawner.py` | P0:_parse_stdout_json 改为 data["response"]["meta"] | v2 |
|
||
| `spawner.py` | 新增 get_session_by_agent(进程存活性检查用) | v1 |
|
||
| `dispatcher.py` | 去掉 counter acquire/release,on_complete 只含业务逻辑 | v1 |
|
||
| `ticker.py` | _spawn_available_agents 不再管 counter | v1 |
|
||
| `ticker.py` | _check_timeouts 加进程存活性检查 + 推回 pending | v1 |
|
||
| `ticker.py` | 新增 _is_pid_alive | v1 |
|
||
| `main.py` | counter 创建提前,传给 spawner | v1 |
|
||
|
||
---
|
||
|
||
## 5. 司马懿评审记录
|
||
|
||
### 第一次评审(mail-1779726169654)
|
||
|
||
| 评审意见 | 结论 | 理由 |
|
||
|---------|------|------|
|
||
| wrapped_on_complete 加 try/finally | ✅ 采纳 | 防御性编程 |
|
||
| A5/A6 加 context 日志 | ✅ 采纳 | 排查方便 |
|
||
| per-provider 冷却 | ⏭ 延后 | 低优先级 |
|
||
| crash_count per-agent 累计,禁用 agent | ❌ 不采纳 | 崩溃可能是任务问题不是 agent 问题 |
|
||
| can_acquire 失败推回 claimed | ❌ 不采纳 | asyncio 单线程无竞态 |
|
||
| release 和 acquire 之间有竞态窗口 | ❌ 不存在 | 内存同步操作 |
|
||
|
||
### 第二次评审(_do_retry counter 时序)
|
||
|
||
| 评审意见 | 结论 |
|
||
|---------|------|
|
||
| _do_retry 续杯退化为 AgentBusyError | ✅ 采纳:_do_retry 入口手动 release counter |
|
||
|
||
### 第三次评审(P0/P1/P2 补丁)
|
||
|
||
| 评审意见 | 结论 |
|
||
|---------|------|
|
||
| P0 stdout 解析路径修复 | 待评审 |
|
||
| P1 spawn 前检查 | 待评审 |
|
||
| P2 transport=null 兜底 | 待评审 |
|
||
|
||
---
|
||
|
||
## 6. 待办
|
||
|
||
- [ ] 假死复活术集成到 ticker
|
||
- [ ] 历史数据清理(P0 修复前 62 条 transport=null 的 task_attempts)
|
||
- [ ] per-provider 冷却(低优先级)
|