diff --git a/docs/design/mail-fix-design.md b/docs/design/mail-fix-design.md new file mode 100644 index 0000000..349f2d7 --- /dev/null +++ b/docs/design/mail-fix-design.md @@ -0,0 +1,215 @@ +# Mail 修复方案设计文档 + +> 版本:v1.0 | 日期:2026-05-22 | 作者:庞统 | 状态:评审中 + +## 1. 背景与问题 + +moziplus v2 的飞鸽传书(Mail)功能当前存在多个 bug 和设计缺陷,导致 v2 Mail 基本不可用。 + +### 已确认的 Bug + +| 编号 | 问题 | 严重程度 | +|------|------|---------| +| M-BUG-1 | ticker 不扫 `_mail` 项目,邮件永远 pending | 🔴 阻塞 | +| M-BUG-2 | `send_mail` 对 inform 类型直接 `status=done`,无投递确认 | 🔴 阻塞 | +| M-BUG-3 | Tab badge 取值错误(用 sseEvents 而非 mail API) | 🟡 体验 | +| M-BUG-4 | `is_read` / `mark_executed` 语义与 Agent 系统不匹配 | 🟡 设计 | +| M-BUG-5 | MailPanel 无发送入口 | 🟡 功能缺失 | + +### 调查发现 + +1. **v1 投递卡死根因**:`isAgentBusy()` 进程死后仍返回 true + Gateway 永远存活(PID 有效)→ webchat 占用 main session lock → poller 永远认为忙碌 +2. **v2 不存在此问题**:spawner 用 `--session-id uuid4()` 创建新 session,不走 main session lock +3. **v2 Mail 和 v1 Sanguo Mail 是两套独立系统**:v2 Mail 存在 `_mail/blackboard.db`,v1 Sanguo Mail 用文件系统 inbox + +## 2. 设计原则 + +1. **Mail = 通信层,Task = 任务层**:Mail 不加 depends_on,复杂工作用 Task +2. **done = Agent 处理完返回**,不是收到即 done +3. **统一用 done 标记完成**,废弃 `is_read` / `mark_executed` +4. **inform 也不直接 done**:统一从 pending 开始,由 Agent 处理后写 done +5. **conversation_id 只做前端聚合**,不加后端依赖机制 + +## 3. Mail vs Task 约束 + +| 场景 | 用 Mail | 用 Task | +|------|---------|---------| +| 通知/同步信息 | ✅ | ❌ | +| 简单请求(< 10 分钟) | ✅ | ❌ | +| 问问题 | ✅ | ❌ | +| 多步骤/有依赖/需追踪 | ❌ | ✅ | +| 需要产出物追踪 | ❌ | ✅ | +| 不确定复杂度 | 先 Mail → 发现复杂 → 转 Task | — | + +Mail 转 Task 的约束: +- 当前 Task 创建入口只有 Dashboard 和庞统的 Control UI +- Agent 不会调 v2 `/api/mail` 发邮件(不知道 API 存在) +- Agent 不会主动创建 Task(只能通过 mozi CLI) +- **结论:Mail 转 Task 需要庞统或用户手动操作,不自动转换** + +## 4. 修复方案 + +### P0-1:ticker 加 `_mail` 虚拟项目扫描 + +**问题**:`_mail` 不在 registry 中,ticker 只扫 registry + `_general`,不扫 `_mail`。 + +**修复**:在 ticker 的 tick 循环中,和 `_general` 一样加 `_mail` 虚拟项目处理。 + +**文件**:`src/daemon/ticker.py` + +**改动**: +```python +# _tick_all_projects 中,_general 之后加 _mail + +# 虚拟项目 _mail:飞鸽传书 +mail_db = Path(self.registry.root) / "_mail" / "blackboard.db" +if mail_db.exists() and "_mail" not in active_project_ids: + try: + pr = await self._tick_project("_mail", { + "id": "_mail", "name": "飞鸽传书", + "status": "active", "source": "virtual", + }) + results["projects"]["_mail"] = pr + except Exception: + logger.exception("Tick %d _mail error", tick_num) + results["projects"]["_mail"] = {"error": str(e)} +``` + +**预估**:~10 行 + +### P0-2:send_mail 统一 pending + +**问题**:`send_mail` 对 `type=inform` 直接设 `status=done`,无投递确认。 + +**修复**:inform 和 text 统一从 `pending` 开始,由 Agent 处理后写 done。 + +**文件**:`src/api/mail_routes.py` + +**改动**: +```python +# 之前: +status="done" if body.get("type") == "inform" else "pending", + +# 之后: +status="pending", +``` + +**预估**:1 行 + +**注意**:inform 类型邮件的 Agent spawn prompt 需要引导 Agent 快速确认(不需要复杂执行),在 spawner 的 spawn message 中根据 task_type 区分。 + +### P1-1:Tab badge 改用实际未处理邮件数 + +**问题**:Tab badge 用 `sseEvents`,不是 mail API 的实际未读数。 + +**修复**:改用 `useStore.getState().mailUnread`(loadMails 已算出)。 + +**文件**:`src/frontend/src/App.tsx` + +**改动**: +```typescript +// 之前: +const unread = ((useStore.getState().sseEvents || []) as any[]) + .filter((n: any) => !n.read).length; + +// 之后: +const unread = useStore.getState().mailUnread || 0; +``` + +**预估**:1 行 + +### P1-2:废弃 is_read / mark_executed,统一用 done + +**问题**: +- `is_read` 在 Agent 系统里无意义(Agent 不需要"已读") +- `mark_executed` 是给人手动标记的,但语义模糊 +- Agent 通过 Task 状态机写 done 后,`is_read` 仍是 false,前端还显示"未读" + +**修复**: + +**后端**:`src/api/mail_routes.py` +- 删除 `mark_read` 和 `mark_executed` 端点(或标记 deprecated) +- `list_mail` 返回时,`status=done` 的邮件 `is_read` 强制为 true +- 不改数据库字段(保持向后兼容),只在 API 层面调整 + +**前端**:`src/frontend/src/components/MailPanel.tsx` +- 去掉"标记已读"和"标记已执行"按钮 +- 改为展示邮件状态:`pending`(未处理)/ `claimed/working`(处理中)/ `done`(已处理)/ `failed`(失败) +- 未处理邮件 = `status != done && status != failed` + +**文件**:`mail_routes.py` + `MailPanel.tsx` + +**预估**:~30 行 + +### P1-3:前端加人工重试按钮(failed → pending) + +**问题**:投递失败的邮件没有重试入口。 + +**修复**:MailPanel 中对 `failed` 状态的邮件显示"重试"按钮。 + +**后端**:`src/api/mail_routes.py` 新增端点或复用 status 更新 +```python +# PATCH /api/mail/{task_id}/status +# {"status": "pending"} → 重置为 pending,ticker 重新调度 +``` + +**前端**:`MailPanel.tsx` 加重试按钮 + +**预估**:~20 行 + +### P2-1:投递失败自动 escalate + +**问题**:Agent 多次失败后无人知道。 + +**修复**:ticker 检测 `_mail` 任务的 `retry_count >= 3` → escalate 给庞统。 + +**文件**:`src/daemon/ticker.py`(_check_timeouts 中已有 escalate 逻辑,_mail 自然享受) + +**预估**:0 行(现有逻辑已覆盖) + +## 5. 邮件状态机 + +``` +pending → claimed → working → done + │ │ + └→ failed ─┘→ pending(人工重试) +``` + +- `pending`:邮件已创建,等待调度 +- `claimed`:ticker 调度,分配给 Agent +- `working`:Agent 正在处理 +- `done`:Agent 处理完成 +- `failed`:处理失败,可人工重试 + +邮件复用 Task 的状态机和状态转换逻辑,不需要独立的状态机。 + +## 6. 不做的事 + +| 项目 | 原因 | +|------|------| +| Mail 加 depends_on | 当前不需要,复杂依赖用 Task | +| Mail Tab 加发送入口 | P2,当前通过 API 发送足够 | +| inform 走简化流程(跳过 working/review) | 先统一走 Task 状态机,确认能跑通再优化 | +| conversation_id 后端依赖 | 纯前端聚合字段,不加后端逻辑 | +| 投递到主 session | v2 spawner 用新 session 避免 lock 冲突,投递到主 session 会遇到 v1 同样的问题 | + +## 7. 改动范围 + +| 文件 | 改动 | 预估行数 | +|------|------|---------| +| `src/daemon/ticker.py` | `_tick_all_projects` 加 `_mail` 虚拟项目 | ~10 行 | +| `src/api/mail_routes.py` | send_mail 统一 pending + 废弃 mark_read/mark_executed + 重试端点 | ~30 行 | +| `src/frontend/src/App.tsx` | Tab badge 改用 mailUnread | ~1 行 | +| `src/frontend/src/components/MailPanel.tsx` | 废弃已读/已执行按钮 + 状态展示 + 重试按钮 | ~40 行 | + +总计约 80 行,4 个文件。 + +## 8. 测试计划 + +| 用例 | 验证方式 | 预期 | +|------|---------|------| +| Mail 投递到 Agent | POST /api/mail → ticker 调度 → Agent spawn → done | 邮件状态 pending → done | +| Tab badge 数字 | 发 3 封邮件,2 封 done | badge 显示 1 | +| inform 类型不直接 done | POST /api/mail {type: inform} | 初始 status=pending | +| 重试按钮 | 邮件 failed → 点重试 → status 回 pending → ticker 重新调度 | 重新执行 | +| escalate | 邮件连续 3 次失败 | 自动 escalate 给庞统 |