fix(bridge): ★ 投递即标已读 —— 修「桥重启 → 重投 → 回声」

用户 2026-09-26 原话:
  「我都不记得我下达这个任务,是你的桥自动重投存在 bug」
  「就是你的错误的重投机制造成了回声」

# 我上一轮把因果搞反了

我先认定是「两个 Agent 自发辩论」,还为此写了第三道防线(数 Agent↔Agent
连续往返)。**方向错了** —— 是**桥把同一封信反复投递**,每次投递起一个
worker 回信,回信又触发下一轮。模型在做什么?它在回答一封被重复投进来的
旧信。用户根本不知道有这回事。

# 根因:deliveredMails 只在内存,库里的 status 从没被写

投递路径(SSE `new_mail` / 心跳补投 / 决策回执)只做两件事:起 worker、
把 id 记进 `deliveredMails`。**没有任何一处调 `/mail/read`** —— 桥里唯一
那处标已读在 `read_inbox` 工具里,要等模型自己去读收件箱。

于是每封被投递的信**永远是 unread**;而 `catchUp` 按 `status=unread` 拉
⇒ 桥一重启(**每次部署都会**),积压的"未读"被当成离线漏投**再投一遍**。

# 实证(不是推断)

· 5 个 mail_id 各出现在**两条不同 pi 会话**里:
    f06129f4 → 04:54:45 投进 01a0a2bd
             → 08:01:20 投进 01a0daf0
  (而那封信库里已有 1 封回信 —— 它早就被处理过)
· 同一封信被投两次 ⇒ 两个 worker 各回一封 ⇒ 对方收到两封 ⇒ 各回两封…
· pi 收件箱 287 封 unread 中 **187 封已经回过信了**
  (`EXISTS(SELECT 1 FROM mails r WHERE r.parent_mail_id=m.mail_id)`)
· 两条会话各烧到 463 / 268 封
· 桥侧:同一邮件会话 id 前缀 `01a0a2bd` 出现在 **4 个** pi 会话文件里
  (投了两次 + 别的历史残留)

# 修法:内存与库必须同时写

`deliveredMails` 是**内存**集合,重启即丢;数据库的 status 才是跨重启的
"我接管过了"记录。两者只写其一 ⇒ 口径不一致 ⇒ 重投。

新增 `markDelivered(id)`:**凡是标记"我接管了这封"的地方都走它**
(SSE / 补投 / 决策回执三个投递点),同时写内存与库。漏一处就是一条重投
路径 —— 这正是缺陷的形状(四处各自 add,没有一处标已读)。

标已读只改 status,不改内容、不删行;`read_inbox` 传 `status=all` 照常可见。
而"已交给一个 worker 处理"正是那封信此刻的真实状态 —— 库里本来就该记这件事,
而不是"模型有没有顺手调过 read_inbox"。

# 四个桥:三个有缺陷,第四个早已修过

| 桥 | 投递标已读 | 说明 |
| --- | --- | --- |
| pi | ✗ → ✓ | 三处 add 都不标 |
| opencode | ✗ → ✓ | 同上 |
| dsh | ✗ → ✓ | 同上 |
| **homeagent** | **✓ 早有** | `ledger` 落盘,跨进程 |

homeagent 不用这个修法:它的 `ledger` 记 `delivered`/`completed` 两个状态,
只有 `completed` 才跳过(投过但被中断的**仍然重投**并带说明)—— 那份设计的
注释里就写着 18:59:38 那次实测,比我今天这个修法更早也更完整。
所以对它只做了「补投按工作区收窄」那一半(见下条)。

# 附带修:homeagent 的 workspace 收窄(我今天打破了它)

我先部署服务端(缺 workspace 直接 400)并修了三个桥,**漏了 homeagent**
⇒ 线上 07:42 起持续报 `read_inbox 工具执行失败: HTTP 400 缺少 workspace`。
这是我造成的,靠自己的日志发现的(pid 还是重启前的旧进程 2291455)。

修法与另三个同源:`currentWorkspace`(信封的 `to_workspace`)在回合期间暂存
(与 `currentSessionID` 同一形状、同一生命周期),`inboxURL`/`scopeQuery` 带上它,
补投从"读一次全局收件箱"改为逐工作区(清单来自心跳的 `pending_workspaces`)。

# 清理重投燃料

151 封归档(80 封回声:会话全程无人类 + 71 封 `permission_decision` 不可投)。
★ 用 `archived` 而不是 `read` —— `read` 还能被 `status=all` 拉出来重投。
判据用服务端自己的口径(`unreadFor` = `m.status<>'archived'` 且
`mail_reads` 无该读者),不手写 SQL 猜语义。

后置:pi / dsh / opencode / homeagent 在**所有工作区**的 unread 全部为 0。

# 判据

· `delivery-marks-read.test.mjs` × 3(pi / opencode / dsh)各 4 条:
  核心那条钉的是「`deliveredMails.add` **只允许**出现在 markDelivered 内部」——
  任何别处直接 add 就是绕过标已读的重投路径。另加自检反例。
  变异验证:绕过投递点 / markDelivered 不写库 / 补投绕过,三处全判红。
· `inbox_workspace_test.go`(homeagent)5 条:URL 带 workspace、带不到时不带
  (让服务端 400:错误可见好过静默越界)、补投逐工作区、两处投递路径都设工作区
  且都清空。变异 3 处全判红。
· 改了两条既有判据(pi / dsh 的 permission-note):原来钉
  `deliveredMails.add(decisionMailID)` —— 那个形状**就是**缺陷载体。
  语义没变(仍"不再当新任务"),载体变了。

全量:pi 517 / opencode 344 / dsh 407 / homeagent 除一条既有的
`TestSDKPinMatchesBuildMachinePointer`(依赖构建机路径,改动前后同样红)全绿。
This commit is contained in:
2026-09-26 09:18:39 +08:00
parent 239ff37291
commit 4175c0ba45
15 changed files with 836 additions and 43 deletions

View File

@ -0,0 +1,96 @@
package main
import (
"net/url"
"strings"
"testing"
)
// ★ 收件箱按工作区收窄(2026-09-26)
//
// 我今天先部署了服务端(缺 workspace 直接 400),只修了 pi/opencode/dsh 三个桥,
// 漏了这里 —— 线上随即出现:
//
// 07:42:28 tool read_inbox result: 工具 read_inbox 执行失败:
// proc: homeagent-mail-bridge.tool.invoke: HTTP 400:
// {"error":"缺少 workspace:收件箱按工作区收窄..."}
//
// 这组判据钉住"读类端点必须带 workspace",以及补投必须逐工作区。
func TestInboxURLCarriesWorkspace(t *testing.T) {
p := &Plugin{gwURL: "http://gw", currentSessionID: "s1", currentWorkspace: "/home/program/agentmail"}
u := p.inboxURL("unread", 5)
if !strings.Contains(u, "workspace=%2Fhome%2Fprogram%2Fagentmail") {
t.Fatalf("inboxURL 必须带 workspace,实际: %s", u)
}
// 两维都要在:session 防"A 会话标掉 B 会话",workspace 防跨工作区越界
if !strings.Contains(u, "session_id=s1") {
t.Fatalf("inboxURL 必须带 session_id,实际: %s", u)
}
// 构造出的 URL 必须可解析(不是靠字符串拼凑蒙对的)
if _, err := url.Parse(u); err != nil {
t.Fatalf("URL 不可解析: %v", err)
}
}
// 拿不到工作区时**不带** —— 服务端会 400,那是刻意的:
// 错误可见,好过静默跨工作区拿到别处的信。
func TestInboxURLOmitsWorkspaceWhenUnknown(t *testing.T) {
p := &Plugin{gwURL: "http://gw", currentSessionID: "s1"}
u := p.inboxURL("unread", 5)
if strings.Contains(u, "workspace=") {
t.Fatalf("没有工作区时不该带 workspace 参数: %s", u)
}
}
func TestScopeQueryCarriesWorkspace(t *testing.T) {
p := &Plugin{currentSessionID: "s1", currentWorkspace: "/w"}
q := p.scopeQuery("&")
if !strings.Contains(q, "workspace=%2Fw") {
t.Fatalf("scopeQuery 必须带 workspace,实际: %q", q)
}
}
// ★ 补投不再读一次全局收件箱
func TestCatchUpIsPerWorkspace(t *testing.T) {
src := readSource(t, "plugin.go")
// catchUp 必须有 workspaces 参数并逐个分发
if !strings.Contains(src, "func (p *Plugin) catchUp(pending int, workspaces []string)") {
t.Fatal("catchUp 必须接 workspaces 清单(心跳给的 pending_workspaces)")
}
if !strings.Contains(src, "p.catchUpWorkspace(ws, limit, pending)") {
t.Fatal("catchUp 必须逐工作区分发")
}
// 真正的拉取必须带 workspace 参数
if !strings.Contains(src, "status=unread&limit=%d&workspace=%s") {
t.Fatal("补投的收件箱请求必须带 workspace")
}
// 反向对照:旧的全局读法不能残留
if strings.Contains(src, `"/api/v1/mail/inbox?status=unread&limit=%d"`) {
t.Fatal("仍残留不带 workspace 的全局收件箱读法")
}
// 拿不到清单就不补投(带空 workspace 去拉必然 400)
if !strings.Contains(src, "len(workspaces) == 0") {
t.Fatal("没有 pending_workspaces 时必须跳过补投")
}
}
// 两处投递路径都要在回合期间设上工作区 —— 模型那轮会调 read_inbox。
func TestDeliverySetsWorkspaceDuringTurn(t *testing.T) {
src := readSource(t, "plugin.go")
if strings.Count(src, "p.currentWorkspace = evt.Workspace") < 1 {
t.Fatal("SSE 投递路径必须设 currentWorkspace(信封的 to_workspace)")
}
if strings.Count(src, "p.currentWorkspace = workspace") < 1 {
t.Fatal("补投路径必须设 currentWorkspace")
}
// 两处都要清空 —— 残留会让下一轮读到上一封的工作区
if strings.Count(src, `p.currentWorkspace = ""`) < 2 {
t.Fatal("两处都必须清空 currentWorkspace(残留会串到下一轮)")
}
}