Files
sanguo_moziplus_v2/docs/design/08-classify-outcome-optimization.md
cfdaily fc9b66b905
CI / lint (pull_request) Failing after 9s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 4s
docs(#08): update A13 revised - exit=0 always completed
Merge old A12/A13 into single A13 revised: trust exit_code=0
regardless of stdout/JSON output. Old logic caused inform Mail
infinite retry loop.
2026-06-09 23:41:53 +08:00

343 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# #08 Classify Outcome 优化 + Registry 清理
> 版本:v2.0 | 日期:2026-06-02 | 作者:庞统 | 状态:✅ 已实施,司马懿评审通过
>
> v2.0 最终状态:
> - Phase -1 TCP probe → 废弃,替代为独立 Gateway Watchdog v2(已部署)
> - Classify Outcome v3.1 → 全部实施(v1.0: A3/A3b/api_error/cooldown; v1.1: A14-A17/A8/A10/cooldown统一60s
> - Registry 清理 → 不做物理删除,逻辑删除+归档,后续单独实施
> - 并发控制一致性 → TODO,留 #02 Main Session 统一处理
> - stderr_preview / exit_signal → 暂缓
> - A7 compact_failed retry → 暂缓
## 1. 背景与动机
### 1.1 classify outcome 问题
`_classify_outcome` 的 A0 行(`status=None, stdout 空, exit≠0 → crashed`)把所有「进程没输出 JSON 就退了」的情况一视同仁,无法区分:
| 实际场景 | exit_code | 当前判定 | 理想判定 |
|----------|-----------|----------|----------|
| openclaw CLI 连不上 Gateway | 1 | crashed | 可恢复 |
| 进程被 SIGINT/SIGTERM 杀(compact 期间等) | 130/143 | crashed | 可恢复 |
| Agent 代码 bug 崩溃 | 1 | crashed | 不可恢复(但要等 ticker 兜底) |
此外:
- fallback_used 直接判死刑,不允许 retry
- compact_failed 不可恢复——但 compact 结束后应该可以续杯
- network/rate_limit/lock 推回 pending 而非 retry——counter 在手里,retry 更快
- stderr 没记录到 task_attempts,排查靠猜
### 1.2 Registry 孤儿问题
测试产生的 e2e-* 项目 data 目录被删,但 registry.db 记录还在(128 条)。每次 tick 遍历 100+ 个 no_db 项目,浪费 I/O。
### 1.3 并发控制一致性
architecture-v3.0.md §8.2 描述三层并发控制,代码已实现但存在一处偏差:
- `AgentProfile.max_concurrent`config 中每个 agent 的 `max_concurrent`**定义了但没被使用**
- 实际 per-agent 限制统一用 `counter.max_concurrent_sessions`(全局值 3
- 不影响功能(因为当前 max_concurrent_sessions=3 已是宽松上限),但是设计↔代码不一致
## 2. 设计目标
1. **classify outcome**:区分可恢复 vs 不可恢复,可恢复走 retry(续杯),不可恢复标 failed + 原因写黑板
2. **Registry 清理**:删除项目时同步清理 registry,discover 时同步清理孤儿
3. **并发控制对齐**:标记 AgentProfile.max_concurrent 为 TODO 或接入 counter
## 2.1 Phase -1: Gateway 存活检查 — ❌ 已废弃
### 原方案(TCP probe
spawner spawn 前 TCP + WebSocket Upgrade 握手探测 Gateway。实测 406 次全部 ok,但线上仍频繁发生 stalled session、lane task error、FailoverError。
### 失败原因
TCP 握手只能检测进程端口是否监听,无法检测 Gateway **业务可用性**lane task 卡死、provider 全挂等)。结论:TCP probe 方案无效。
### 替代方案
独立 Gateway Watchdog v2(已部署):bash 脚本每分钟扫描 Gateway **进程日志**3 条规则检测 FailoverError/stalled/rate_limit,自动重启 + 5 分钟冷却。设计文档:`docs/design/gateway-watchdog.md`
## 3. Classify Outcome 判定树 v2.0
### 3.1 核心分类原则
| 分类 | 处理 | 说明 |
|------|------|------|
| **可恢复 → retry** | `_do_retry`(续杯) | counter 已在手里,重新 spawn 比推回 pending 更快 |
| **不确定 → crashed(等 ticker 兜底)** | cooldown + 保持 working | ticker 超时回收 or crash_limit 标 failed |
| **Agent 自己判的** | 尊重,不干预 | A4: task_status=failed |
| **完成** | 结束 | 正常完成 |
| **不可恢复 → failed** | 标 failed + 原因写黑板 | 确定性错误,重试不会改变结果 |
### 3.2 输入因素
| 因素 | 来源 | 说明 |
|------|------|------|
| exit_code | 进程退出码 | 0=正常, 1=通用错误, 128+N=信号 |
| json_result.status | openclaw stdout JSON | ok / timeout / error / None |
| json_result.summary | 同上 | completed 等 |
| json_result.fallback_used | 同上 | Gateway 降级标志 |
| stderr_text | 进程 stderr | 关键字匹配辅助分类 |
| stdout_text | 进程 stdout | 是否有输出 |
| task_status | 黑板 DB 任务实际状态 | done / failed / working 等 |
| **exit_code 信号类型** (新增) | exit_code ≥ 128 | SIGINT(130) / SIGTERM(143) = 外部中断 |
| **stderr 关键字(无 JSON 时)** (新增) | stderr_text | 无 JSON 输出时也检查 network/compact 等 |
### 3.3 判定表
#### 第一层:有 JSON 输出(status ≠ None
| 编号 | 条件 | outcome | 可恢复? | 处理 |
|------|------|---------|----------|------|
| A1 | status=ok, summary=completed | completed | — | 结束 |
| A2 | status=timeout | gateway_timeout | ✅ | retry(续杯) |
| A3 | status=ok, fallback_used, **fallback_count ≥ 2** | fallback_exhausted | ❌ | 标 failed + 原因写黑板 |
| A3b | status=ok, fallback_used, **fallback_count < 2** | fallback_retry | ✅ | retry + cooldown 60s |
| A4 | task_status=failed | agent_failed | — | 尊重,不干预 |
| A5 | status=ok(其他组合) | completed | — | 结束 |
| A6 | status=error + stderr 含 auth 关键字 | auth_failed | ❌ | 标 failed + 原因写黑板 |
| A7 | status=error + stderr 含 compact 关键字 | compact_interrupted | ✅ | retry + cooldown 60s(等 compact 完成) |
| A8 | status=error + stderr 含 network 关键字 | gateway_unreachable | ✅ | retry + cooldown 60s |
| A9 | status=error + stderr 含 rate_limit 关键字 | api_error | ✅* | release counter + 推回 pending + api_retry_count 上限 |
> *A9 是特殊路径:不走统一 `_do_retry` 续杯,而是 release counter + 推回 pending + 独立 api_retry_count 计数器(达 max_retries 标 failed)。保留此路径是因为 api_error 有独立的计数器逻辑,与 retry_count 互不干扰。
| A10 | status=error + stderr 含 lock 关键字 | lock_conflict | ✅ | retry + cooldown 60s |
| A11 | status=error(其他) | agent_error | ❌ | 标 failed + 原因写黑板 |
#### 第二层:无 JSON 输出(status = None, stdout 空)
| 编号 | 条件 | outcome | 可恢复? | 处理 |
|------|------|---------|----------|------|
| A12 | ~~已合并到 A13 revised~~ | — | — | 见下方 A13 revised |
| **A13 revised** | exit=0(无 JSON 输出) | completed | — | 信任进程退出码,exit=0 即正常完成。旧逻辑按 task_status 区分,非终态判 agent_error → 导致 inform Mail 永不标 done,与 dispatcher inform auto-done 形成死循环 |
| **A14** | exit=130 (SIGINT) 或 exit=143 (SIGTERM) | interrupted | ✅ | retry |
| **A15** | exit≠0 + stderr 含 network 关键字 | gateway_unreachable | ✅ | retry + cooldown 30s |
| **A16** | exit≠0 + stderr 含 compact 关键字 | compact_interrupted | ✅ | retry + cooldown 60s |
| A17 | exit≠0(其他,排除 A14-A16 | crashed | 不确定 | cooldown 300s + 保持 working → ticker 兜底 |
### 3.4 各编号详细说明
#### A2: gateway_timeout(续杯)
- 唯一由 Gateway 官方信号触发的续杯
-`_do_retry`,用同一 session-id 续杯
- retry_count 上限 3 次
#### A3/A3b: fallback 分级处理(新)
- **A3b**fallback_count < 2):fallback 说明任务本身没问题,是模型/网络抖了,retry
- **A3**fallback_count ≥ 2):连续两次 fallback,说明模型质量不可靠,标 failed
- fallback_count 存储位置:task_attempts.metadata 中的 `fallback_count` 字段
- 每次检测到 fallback_used=true 时递增,retry 成功(非 fallback 结束)时重置
#### A4: Agent 自己标 failed
- Agent 通过 API `POST /tasks/{id}/status` 主动标 failed
- 含义:Agent 有意识地判断任务无法完成(需求矛盾、资源不足等)
- 处理:尊重 Agent 的判断,记录 outcome=agent_failed,不干预
#### A6: auth_failed(不可恢复)
- 401/403/unauthorized → 认证/授权失败
- 重试不会改变结果,需要人工介入
- 标 failed + `{"reason": "auth_failed", "stderr_preview": "..."}`
#### A7: compact_interrupted(暂缓未实施)
- compact 是 Gateway 的上下文压缩过程
- compact 结束后 session 状态恢复,理论上可以续杯
- **当前代码 A7 compact_failed 仍为不可恢复(should_retry=False**,暂不改动
- 等 A7 实施时一并处理
#### A9: api_error(特殊路径,release counter + push pending
- 原逻辑和新逻辑一致:release counter + 推回 pending + cooldown
- 有独立的 `api_retry_count` 计数器,达 max_retries(3) 标 failed
- 不走统一 `_do_retry` 续杯,因为 api_error 需要独立的计数器逻辑
- cooldown 由 counter 默认 cooldown 控制(与 crashed 相同)
#### A11: agent_error(不可恢复)
- status=error 但 stderr 不匹配任何已知关键字
- 标 failed + `{"reason": "agent_error", "stderr_preview": "前500字"}`
#### A14: interrupted(可恢复,新增)
- exit=130 (SIGINT) 或 exit=143 (SIGTERM)
- 原因:进程被外部中断(PM2 restart、compact 期间被杀、手动 Ctrl+C
- 不是代码 bug,retry 很可能成功
- retry + cooldown 60s
#### A15/A16: 无 JSON 输出 + stderr 关键字(可恢复,新增)
- 原逻辑 A0 把这些统判 crashed
- 新逻辑:stderr 含 network/compact 关键字 → 对应的可恢复分类
- A15: network → retry + cooldown 60s
- A16: compact → retry + cooldown 60s
#### A17: crashed(不确定,等 ticker 兜底)
- 排除所有可恢复场景后,真正的"不知道什么原因崩了"
- **不立刻标 failed**(保持现有逻辑)
- 设 cooldown 300s,任务保持 working
- ticker `_check_timeouts` 检测超时 → 标 failed
-`_check_crash_limit` 连续 3 次 → 标 failed
- 两种兜底机制都写原因到黑板
### 3.5 cooldown 参数汇总
> ⚠️ v1.1 统一简化:A14/A15/A8/A10 cooldown 统一 60s,减少认知负担和参数维护。
| 场景 | cooldown | 理由 |
|------|----------|------|
| A3b fallback | 60s | 统一简化 |
| A7/A16 compact | 60s | 等 compact 完成 |
| A8/A15 network | 60s | 网络暂时性问题 |
| A9 rate_limit | 60s | API 限流需要等待 |
| A10 lock | 60s | 统一简化 |
| A14 interrupted | 60s | 统一简化 |
| A17 crashed | 300s | 给 session 恢复时间 |
### 3.6 retry 时 cooldown 的实现
retry 走 `_do_retry``spawn_full_agent(skip_counter=True)`
cooldown 在 retry 前设:
```python
if cls.get("cooldown_seconds"):
self.counter.set_cooldown(agent_id, seconds=cls["cooldown_seconds"])
await asyncio.sleep(cls["cooldown_seconds"])
```
或者更优雅:cooldown 由 `can_acquire` 检查,retry 时 `spawn_full_agent` 内部 Phase 1 会调 `can_acquire`,cooldown 期间自动等待。但当前 `_do_retry``skip_counter=True` 跳过了 counter 检查。
**方案**:在 `_do_retry` 开始时设 cooldowncooldown 期间 `asyncio.sleep`,然后继续 spawn。这样不需要改 counter 逻辑。
### 3.7 stderr 记录增强 — 暂缓
stderr_preview 和 exit_signal 写 metadata 的方案暂缓,本轮不实施。
已有 metadata 记录 fallback_count、api_retry_count、retry_count 等计数器。
## 4. Registry 清理 — 最终决策:逻辑删除 + 归档,不做物理删除
### 决策记录
- **delete_project 保持逻辑删除**status→deleteddata 目录和 blackboard.db 保留)
- **不做物理删除**:项目数据有长期价值,保留可恢复
- **归档方案**TODO):定期将 deleted 项目的 data 移至 NAS 归档目录,从活跃存储清理
- **批量管理 API**:暂不实施,当前项目数量少(15 个 active),手动管理可接受
- **discover_projects 孤儿清理**:暂不实施,当前无孤儿问题
### 性能评估
逻辑删除不物理删对性能无影响:
- registry.db 多几十条 deleted 记录,SQLite 微秒级
- ticker 只处理 active 项目,deleted 的不被遍历
- API 查询已过滤 deleted 状态
## 5. 并发控制一致性检查
### 5.1 现状
| 设计层 | 配置/代码 | 实际生效 |
|--------|----------|----------|
| per session key | `max_per_session=1` | ✅ 生效 |
| per agent | `max_concurrent_sessions=3` | ✅ 生效 |
| global | `max_global_agents=5` | ✅ 生效 |
| per tick | `max_dispatch_per_tick=3` | ✅ 生效 |
| **per agent profile** | `AgentProfile.max_concurrent` | ❌ **定义了未使用** |
### 5.2 问题
`config/default.yaml` 中每个 agent 有 `max_concurrent`(张飞=1, 司马懿=2, 庞统=3),但 `Router` 加载 profile 后 `max_concurrent` 从未被引用。实际 per-agent 限制统一用 `counter.max_concurrent_sessions=3`
### 5.3 方案(标记为 TODO,不在本次实施)
两种对齐方向:
- **方向 A**:删掉 `AgentProfile.max_concurrent`,统一用 `max_concurrent_sessions`
- **方向 B**counter.can_acquire() 读取 agent profile 的 max_concurrent 替代全局值
当前所有 agent 的 max_concurrent 都 ≤ 3,且 `max_concurrent_sessions=3` 已是宽松上限,不影响运行。标记为 TODO,在 #02 Main Session 实施时统一处理。
## 6. 影响范围
### 已实施
| 文件 | 改动 | 风险 |
|------|------|------|
| `src/daemon/spawner.py` | `_classify_outcome` A0→A14-A17 拆分 + A8/A10 改可恢复 | 中 — 核心逻辑 |
| `src/daemon/spawner.py` | `_handle_exit` should_retry 分支加 cooldown + A3b cooldown 30→60 + else 分支加 compact_interrupted | 中 |
| `scripts/gateway-watchdog.sh` | 独立 Gateway Watchdog v2(替代 Phase -1 TCP probe | 低 — 新文件 |
### 未实施(暂缓)
| 文件 | 原计划改动 | 状态 |
|------|------|------|
| `src/daemon/spawner.py` | A7 compact_failed 改可恢复 | 暂缓 |
| `src/daemon/spawner.py` | stderr_preview / exit_signal 写 metadata | 暂缓 |
| `src/blackboard/registry.py` | delete_project 增强 + discover 孤儿清理 | 不做(逻辑删除+归档) |
| `src/api/admin_routes.py` | 批量清理 API | 不做 |
| `tests/test_spawner.py` | 单元测试 | 暂缓 |
## 7. 测试计划
### 7.1 单元测试(mock
| 测试 | 覆盖 |
|------|------|
| test_A14_interrupted_sigint | exit=130 → retry |
| test_A14_interrupted_sigterm | exit=143 → retry |
| test_A15_no_json_network | exit=1 + stderr network → retry |
| test_A16_no_json_compact | exit=1 + stderr compact → retry |
| test_A17_crashed | exit=1 + 空 stderr → crashed + cooldown |
| test_A3b_fallback_retry | fallback_count=0 → retry |
| test_A3_fallback_exhausted | fallback_count=2 → failed |
| test_A7_compact_retry | status=error + compact → retry + 60s cooldown |
| test_A8_network_retry | status=error + network → retry + 60s cooldown |
| test_A9_api_error_push_pending | status=error + rate_limit → push pending + api_retry_count |
| test_A10_lock_retry | status=error + lock → retry + 60s cooldown |
| test_A11_agent_error_reason | status=error + 未知 stderr → failed + reason |
| test_A6_auth_failed_reason | status=error + auth → failed + reason |
| test_stderr_recorded | metadata 包含 stderr_preview |
| test_fallback_count_tracking | fallback_count 跨 retry 累计 |
### 7.2 E2E 测试(需 RUN_INTEGRATION=1
- 模拟 compact 场景:spawn → compact → retry → 成功
- 模拟 network 中断:spawn → network error → retry → 成功
## 8. 与现有设计的关系
本文档是 `spawner-monitor-design.md` 的增量更新,不替代原文档。改动点:
- §5 A0 章节更新为 A14-A17 四种细分
- §5 A5/A6 (fallback) 改为 A3/A3b 分级处理
- §5 A7-A12 中 compact/network/rate_limit/lock 改为 retry
- 新增 §3.6 cooldown 参数汇总
- 新增 §4 Registry 清理
- 新增 §5 并发控制一致性检查(TODO 标记)
## 9. v2.1 修订:失败处理一致性修复(2026-06-06)
> 审计发现设计文档描述的 failed 处理在代码中有 3 处 gap,已修复并通过司马懿评审。
### 9.1 发现的问题
| BUG | 设计要求 | 代码实际 | 影响 |
|-----|---------|---------|------|
| BUG-1 | A6 auth_failed / A11 agent_error → 标 failed + 原因写黑板 | 只设 cooldown 300s,任务留 working 等 ticker 30 分钟 | 不可恢复错误白等 30 分钟 |
| BUG-2 | Mail 幻觉门控无回复 → 标 failed(no_reply_found) | 留 working 等 ticker recheck | Agent 未回复 Mail 卡 30 分钟 |
| BUG-3 | 所有设计只描述到标 failed 为止 | 标完 failed 无任何通知 | failed 任务无人关注 |
### 9.2 修复内容
**F1: auth_failed + agent_error → 立刻标 failed**spawner.py:855-860
`_handle_exit()` "其他"分支中,cooldown 之后、`_do_on_complete_async` 之前,新增不可恢复 outcome 立刻标 failed 逻辑。
**F2: 所有 failed → comment @pangtong-fujunshi**spawner.py:1456-1470
`_mark_task()` 中 conn.close() 之后,检查 `status == "failed"` 时通过 Blackboard comment + mention_queue 通知庞统。不走 Mail,走黑板 comment + mention 标准流程。
**F3: Mail 幻觉门控失败 → 立刻标 failed**dispatcher.py:679-707
`_mail_auto_complete()` 中,无回复时用与标 done 同样的重试模式(3 次尝试)标 failed。不 retryAgent turn 已结束,同样 prompt 大概率同样结果)。
### 9.3 不改的
- compact 检测:Fix-1 session jsonl 扫描是主要防线,Phase 2 无上限 skip + Monitor B2 最多 31.5 分钟,合理
- compact_failed (有JSON+stderr):暂缓(08 §1.1 已标注)
- agent_error 不改为 retry:未知错误 retry 无意义
- api_error:当前推回 pending + cooldown 是对的