Files
sanguo_moziplus_v2/docs/design/mail-fix-design.md
T
cfdaily 0d7425b88c
Deploy / ci (push) Waiting to run
Deploy / deploy (push) Blocked by required conditions
Deploy / notify-deploy-failure (push) Blocked by required conditions
auto-sync: 2026-06-07 01:35:53
2026-06-07 01:35:53 +08:00

8.3 KiB
Raw Blame History

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.dbv1 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

改动

# _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_mailtype=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-1Tab badge + mailUnread 计算逻辑修正

问题

  • Tab badge 用 sseEvents,不是 mail API 的实际未读数
  • store.tsmailUnread!m.is_read 计算,但废弃 is_read 后语义不对
  • failed 的邮件 is_read 仍为 false,会被误计为 unread

修复

文件 1src/frontend/src/store.tsmailUnread 计算逻辑)

// 之前:
mailUnread: mails.filter(m => !m.is_read).length,

// 之后:按 status 计算,unread = 需要处理的邮件数
mailUnread: mails.filter(m => !['done', 'failed', 'cancelled'].includes(m.status)).length,

文件 2src/frontend/src/App.tsxTab 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_readmark_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"}  → 重置为 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:邮件已创建,等待调度
  • claimedticker 调度,分配给 Agent
  • workingAgent 正在处理
  • doneAgent 处理完成
  • failed:处理失败,可人工重试

邮件复用 Task 的状态机和状态转换逻辑,不需要独立的状态机。

6. 不做的事

项目 原因
Mail 加 depends_on 当前不需要,复杂依赖用 Task
Mail Tab 加发送入口 P2,当前通过 API 发送足够
inform 走简化流程(跳过 working/review 先统一走 Task 状态机,确认能跑通再优化
conversation_id 后端依赖 纯前端聚合字段,不加后端逻辑
投递到主 session Mail 投递到主 Agent sessionuse_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 给庞统