# #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 | exit=0 + task_status ∈ {done, review} | completed | — | 正常完成 | | A13 | exit=0 + task_status ∉ {done, review} | agent_error | ❌ | 标 failed + 原因写黑板 | | **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` 开始时设 cooldown,cooldown 期间 `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→deleted,data 目录和 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。不 retry(Agent 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 是对的