8.3 KiB
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 无发送入口 | 🟡 功能缺失 |
调查发现
- v1 投递卡死根因:
isAgentBusy()进程死后仍返回 true + Gateway 永远存活(PID 有效)→ webchat 占用 main session lock → poller 永远认为忙碌 - v2 不存在此问题:spawner 用
--session-id uuid4()创建新 session,不走 main session lock - v2 Mail 和 v1 Sanguo Mail 是两套独立系统:v2 Mail 存在
_mail/blackboard.db,v1 Sanguo Mail 用文件系统 inbox
2. 设计原则
- Mail = 通信层,Task = 任务层:Mail 不加 depends_on,复杂工作用 Task
- done = Agent 处理完返回,不是收到即 done
- 统一用 done 标记完成,废弃
is_read/mark_executed - inform 也不直接 done:统一从 pending 开始,由 Agent 处理后写 done
- 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
改动:
# _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
改动:
# 之前:
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 + 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 计算逻辑)
// 之前:
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 取值)
// 之前:
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 更新
# 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 调度,分配给 Agentworking:Agent 正在处理done:Agent 处理完成failed:处理失败,可人工重试
邮件复用 Task 的状态机和状态转换逻辑,不需要独立的状态机。
6. 不做的事
| 项目 | 原因 |
|---|---|
| Mail 加 depends_on | 当前不需要,复杂依赖用 Task |
| Mail Tab 加发送入口 | P2,当前通过 API 发送足够 |
| inform 走简化流程(跳过 working/review) | 先统一走 Task 状态机,确认能跑通再优化 |
| conversation_id 后端依赖 | 纯前端聚合字段,不加后端逻辑 |
| 投递到主 session | Mail 投递到主 Agent session(use_main_session=True),通过 Gateway queue 排队;spawn session 执行 Task 与主 session session lane 隔离,互不干扰 |
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 给庞统 |