Files
sanguo_moziplus_v2/docs/design/mail-fix-design.md
T
2026-05-22 13:08:09 +08:00

226 lines
8.2 KiB
Markdown
Raw 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.
# 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-1ticker 加 `_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-2send_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-1Tab badge + mailUnread 计算逻辑修正
**问题**
- Tab badge 用 `sseEvents`,不是 mail API 的实际未读数
- `store.ts``mailUnread``!m.is_read` 计算,但废弃 is_read 后语义不对
- failed 的邮件 is_read 仍为 false,会被误计为 unread
**修复**
**文件 1**`src/frontend/src/store.ts`mailUnread 计算逻辑)
```typescript
// 之前:
mailUnread: mails.filter(m => !m.is_read).length,
// 之后:按 status 计算,unread = 需要处理的邮件数
mailUnread: mails.filter(m => !['done', 'failed', 'cancelled'].includes(m.status)).length,
```
**文件 2**`src/frontend/src/App.tsx`Tab badge 取值)
```typescript
// 之前:
const unread = ((useStore.getState().sseEvents || []) as any[])
.filter((n: any) => !n.read).length;
// 之后:
const unread = useStore.getState().mailUnread || 0;
```
**预估**2 行
### 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"} → 重置为 pendingticker 重新调度
```
**前端**`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 给庞统 |