# 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 + 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"}  → 重置为 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 | 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 给庞统 |
