# #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` 开始时设 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 是对的
