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:
@ -108,6 +108,21 @@ type Plugin struct {
|
||||
// 所以靠这个字段做桥接。
|
||||
currentSessionID string
|
||||
|
||||
// currentWorkspace 是当前正在处理的那封邮件的**收件工作区**(信封的 path 位)。
|
||||
//
|
||||
// ★ 2026-09-26:收件箱接口要求 workspace(缺了 400),而工具 handler 没有
|
||||
// 独立 session 上下文,只能靠这里暂存 —— 与 currentSessionID 同一形状、
|
||||
// 同一生命周期(回合开始设、结束清空)。
|
||||
//
|
||||
// 为什么必须收窄:三维地址 `name@path.session` 的 path 位在收件箱侧此前
|
||||
// 从未生效 ⇒ 在 mc 工作区干活的会话读收件箱会拿到 agentmail 的信并照着去
|
||||
// 改 agentmail 的代码(用户 2026-09-26 当场指出)。
|
||||
//
|
||||
// 我今天先部署了服务端、只修了 pi/opencode/dsh 三个桥,漏了这里 ——
|
||||
// 线上随即出现 `read_inbox 工具执行失败: HTTP 400 缺少 workspace`(07:42 起)。
|
||||
// 这就是「服务端先改、四个桥后改」的那半天窗口。
|
||||
currentWorkspace string
|
||||
|
||||
// 单调递增的 last-seen-ID:被重放的旧事件不会让它回退。
|
||||
// 原来直接赋值(p.lastEventID = eid),Gateway 重放时发旧 ID,
|
||||
// 于是 lastEventID 从 123 退回 116 → 下次重连又报 116 → 又重放。
|
||||
@ -519,6 +534,10 @@ func (p *Plugin) heartbeat() error {
|
||||
var resp struct {
|
||||
AllowedModels []string `json:"allowed_models"`
|
||||
PendingMails int `json:"pending_mails"`
|
||||
// PendingWorkspaces 是「哪些工作区有待补投的未读」。心跳是进程级的,
|
||||
// 没有"我的工作区"可言;而补投要按工作区收窄(否则重放别的活),
|
||||
// 所以由它给清单,补投逐个消费。
|
||||
PendingWorkspaces []string `json:"pending_workspaces"`
|
||||
}
|
||||
if err := p.post("/agent/heartbeat", payload, &resp); err != nil {
|
||||
return err
|
||||
@ -531,7 +550,7 @@ func (p *Plugin) heartbeat() error {
|
||||
if !p.catchupDone {
|
||||
p.catchupDone = true
|
||||
if resp.PendingMails > 0 {
|
||||
go p.catchUp(resp.PendingMails)
|
||||
go p.catchUp(resp.PendingMails, resp.PendingWorkspaces)
|
||||
}
|
||||
}
|
||||
|
||||
@ -545,38 +564,59 @@ func (p *Plugin) heartbeat() error {
|
||||
// - 正序(最旧的先处理),保持时间线
|
||||
// - 只补 normal 类型(permission 不补投——人在 WebUI 上看到就知道了)
|
||||
// - 每封之间等 InjectInputSync 返回(串行处理)
|
||||
func (p *Plugin) catchUp(pending int) {
|
||||
func (p *Plugin) catchUp(pending int, workspaces []string) {
|
||||
limit := catchupLimit
|
||||
if pending < limit {
|
||||
limit = pending
|
||||
}
|
||||
|
||||
log.Printf("[homeagent-mail-bridge] 补投 %d 封离线期间的邮件(共 %d 封未读)", limit, pending)
|
||||
|
||||
var inbox struct {
|
||||
Mails []struct {
|
||||
MailID string `json:"mail_id"`
|
||||
FromName string `json:"from_name"`
|
||||
Subject string `json:"subject"`
|
||||
MailType string `json:"mail_type"`
|
||||
ReplyTo string `json:"reply_to"`
|
||||
// 补拉路径也必须知道发件方是人还是 Agent:Agent 之间不自动回信。
|
||||
// 缺了它补投的邮件会被保守当成 Agent 来信,于是人发的那封失去自动回复。
|
||||
FromHuman bool `json:"from_human"`
|
||||
// parent_mail_id 非空 = 这封是回信。收件箱返回的字段名是它,
|
||||
// 而 SSE 事件里叫 in_reply_to —— 两个名字指同一件事。
|
||||
ParentMailID string `json:"parent_mail_id"`
|
||||
// from_session_id 用于档位继承:模型调 send_mail 时,Gateway 据此
|
||||
// 从来源会话继承权限档位(InheritedMode)。
|
||||
SessionID string `json:"session_id"`
|
||||
} `json:"mails"`
|
||||
}
|
||||
url := fmt.Sprintf("%s/api/v1/mail/inbox?status=unread&limit=%d", p.gwURL, limit)
|
||||
if err := p.get(url, &inbox); err != nil {
|
||||
log.Printf("[homeagent-mail-bridge] 补拉失败: %v", err)
|
||||
// ★ 逐工作区补投,不再读一次全局收件箱。
|
||||
//
|
||||
// 旧写法不带任何收窄 ⇒ 会把**所有工作区**的漏投一起重放。清单来自心跳的
|
||||
// `pending_workspaces`(心跳是进程级的,没有"我的工作区"可言,所以由它给清单)。
|
||||
//
|
||||
// 拿不到清单就不补投:带着空 workspace 去拉,服务端会 400。
|
||||
if len(workspaces) == 0 {
|
||||
log.Printf("[homeagent-mail-bridge] 补投跳过:pending_mails=%d 但服务端未给出 pending_workspaces", pending)
|
||||
return
|
||||
}
|
||||
delivered := 0
|
||||
for _, ws := range workspaces {
|
||||
n, err := p.catchUpWorkspace(ws, limit, pending)
|
||||
if err != nil {
|
||||
// 单个工作区失败不影响其余(与"心跳失败不报错"同一原则)
|
||||
log.Printf("[homeagent-mail-bridge] 补投工作区 %s 失败(不影响其余): %v", ws, err)
|
||||
continue
|
||||
}
|
||||
delivered += n
|
||||
}
|
||||
if delivered > 0 {
|
||||
log.Printf("[homeagent-mail-bridge] 补投 %d 封离线期间的邮件(共 %d 封未读,跨 %d 个工作区)",
|
||||
delivered, pending, len(workspaces))
|
||||
}
|
||||
}
|
||||
|
||||
// catchUpWorkspace 补投**一个工作区**的未读邮件,返回实际投递封数。
|
||||
func (p *Plugin) catchUpWorkspace(workspace string, limit, pending int) (int, error) {
|
||||
var inbox struct {
|
||||
Mails []struct {
|
||||
MailID string `json:"mail_id"`
|
||||
FromName string `json:"from_name"`
|
||||
Subject string `json:"subject"`
|
||||
MailType string `json:"mail_type"`
|
||||
ReplyTo string `json:"reply_to"`
|
||||
FromHuman bool `json:"from_human"`
|
||||
ParentMailID string `json:"parent_mail_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
} `json:"mails"`
|
||||
}
|
||||
url := fmt.Sprintf("%s/api/v1/mail/inbox?status=unread&limit=%d&workspace=%s",
|
||||
p.gwURL, limit, url.QueryEscape(workspace))
|
||||
if err := p.get(url, &inbox); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
delivered := 0
|
||||
for _, m := range inbox.Mails {
|
||||
if m.MailType != "normal" {
|
||||
continue // permission 等非邮件驱动的不补投
|
||||
@ -639,8 +679,13 @@ func (p *Plugin) catchUp(pending int) {
|
||||
)
|
||||
|
||||
p.currentSessionID = m.SessionID
|
||||
// 补投这一轮同样要设工作区:模型会在这轮里调 read_inbox。
|
||||
// 不设就是 400(实测 2026-09-26 07:42 起线上就在报这个)。
|
||||
p.currentWorkspace = workspace
|
||||
reply := p.sdk.InjectInputSync(p.name, outputChannelName, prompt)
|
||||
p.currentSessionID = ""
|
||||
p.currentWorkspace = ""
|
||||
delivered++
|
||||
if reply == "" {
|
||||
// B-6:模型没回,发一封告知。发出去就算处理完(理由同 handleNewMail)。
|
||||
p.sendFailureReply(m.FromName, m.Subject, m.MailID, "模型未产生回复")
|
||||
@ -685,6 +730,7 @@ func (p *Plugin) catchUp(pending int) {
|
||||
}
|
||||
p.ledger.complete(m.MailID)
|
||||
}
|
||||
return delivered, nil
|
||||
}
|
||||
|
||||
// ─── SSE ───
|
||||
@ -968,8 +1014,10 @@ func (p *Plugin) handleNewMail(evt mailEvent, resumed bool) {
|
||||
// InjectInputSync 阻塞等待 agent 处理完毕,返回最终回复文本。
|
||||
// 工具 handler 没有独立的 session 上下文,因此在本轮处理期间暂存来源会话。
|
||||
p.currentSessionID = evt.SessionID
|
||||
p.currentWorkspace = evt.Workspace
|
||||
reply := p.sdk.InjectInputSync(p.name, outputChannelName, prompt)
|
||||
p.currentSessionID = ""
|
||||
p.currentWorkspace = ""
|
||||
|
||||
// B-6:模型没回(空 = turn/end 信号 kind=error,或模型没说话)
|
||||
if reply == "" {
|
||||
@ -1495,7 +1543,11 @@ func (p *Plugin) scopeQuery(sep string) string {
|
||||
if p.currentSessionID == "" {
|
||||
return ""
|
||||
}
|
||||
return sep + "session_id=" + url.QueryEscape(p.currentSessionID)
|
||||
q := sep + "session_id=" + url.QueryEscape(p.currentSessionID)
|
||||
if p.currentWorkspace != "" {
|
||||
q += "&workspace=" + url.QueryEscape(p.currentWorkspace)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// inboxURL 拼收件箱地址。单独抽出来是为了能被单测直接断言 ——
|
||||
@ -1508,5 +1560,11 @@ func (p *Plugin) inboxURL(status string, limit int) string {
|
||||
if p.currentSessionID != "" {
|
||||
scope = "&session_id=" + url.QueryEscape(p.currentSessionID)
|
||||
}
|
||||
// ★ workspace 同样要带(见 currentWorkspace 字段的说明)。缺了服务端直接 400
|
||||
// —— 那是刻意的:旧语义(不带 = 全部工作区)正是用户报的那个越界缺陷。
|
||||
// 拿不到时不带,让服务端报 400:错误可见,好过静默跨工作区拿到别处的信。
|
||||
if p.currentWorkspace != "" {
|
||||
scope += "&workspace=" + url.QueryEscape(p.currentWorkspace)
|
||||
}
|
||||
return fmt.Sprintf("%s/api/v1/mail/inbox?status=%s&limit=%d%s", p.gwURL, status, limit, scope)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user