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

17 KiB
Raw Permalink Blame History

#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_concurrentconfig 中每个 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 分级处理(新)

  • A3bfallback_count < 2):fallback 说明任务本身没问题,是模型/网络抖了,retry
  • A3fallback_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_retryspawn_full_agent(skip_counter=True)。 cooldown 在 retry 前设:

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_retryskip_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
  • 方向 Bcounter.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 → 立刻标 failedspawner.py:855-860

_handle_exit() "其他"分支中,cooldown 之后、_do_on_complete_async 之前,新增不可恢复 outcome 立刻标 failed 逻辑。

F2: 所有 failed → comment @pangtong-fujunshispawner.py:1456-1470

_mark_task() 中 conn.close() 之后,检查 status == "failed" 时通过 Blackboard comment + mention_queue 通知庞统。不走 Mail,走黑板 comment + mention 标准流程。

F3: Mail 幻觉门控失败 → 立刻标 faileddispatcher.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 是对的