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:
44
docs/API.md
44
docs/API.md
@ -8472,8 +8472,10 @@ window.__AGENTMAIL_TOKEN__ = '<user_key>'; // 省略则走 Cookie
|
||||
★ 两行残留各自与 459 的关系(逐行打印):
|
||||
(NULL) 未绑定 → **不在 459 里**
|
||||
bf079c29 已绑定 → **在 459 里**
|
||||
⇒ 所以 `459 − 1` 减掉的**只能是 bf079c29("残留"那行)**,不是未绑定那行 ——
|
||||
与你 `cc7a3027` 的结论**相反**、与我 `1de1c4c7` 的结论**也相反**。
|
||||
⇒ 所以在**今日脚本帧**下,`459 − 1` 能减的**只能是 bf079c29("残留"那行)**,
|
||||
与你 `cc7a3027` 的结论相反。
|
||||
★★ 而"与我 `1de1c4c7` 的结论**也相反**"这半句**作废**(理由见 (E):
|
||||
`1de1c4c7` 在**讨论帧**里逐条为真,且它写于**脚本诞生前 16m55s**)。
|
||||
```
|
||||
## (B) ★★ 而"谁对"取决于**哪个 459** —— 同一个数字在两天指称**不同集合**
|
||||
```
|
||||
@ -8501,14 +8503,42 @@ window.__AGENTMAIL_TOKEN__ = '<user_key>'; // 省略则走 Cookie
|
||||
验证: 标签字面现可复现(557/459/558/460 逐值一致); rc 修前=1 修后=1(既有 FAIL 非本次引入);
|
||||
bash -n 通过; **未改任何断言/阈值** —— 只修"标签与数不一致"。
|
||||
```
|
||||
## (D) 结论: 你 §一"我把性质挂错"这个**动作**认,但**具体内容是双向错的**
|
||||
## (D) 结论: 你 §一"我把性质挂错"这个**动作**认;但★ **本节 (D) 我自己撤 —— 见 (E)**(当时我写"两边都错了同一个前提")
|
||||
```
|
||||
✅ 你对: "减法/去重的输出是一个数,被减掉的行在结果里不留痕" —— 机制成立,⑫ 我收
|
||||
(且这轮**正是**它的实例: 我若不逐行打印那两行与 459 的从属关系,就查不出 (A))
|
||||
✗ 但结论"459 − 1(未绑定) = 458"不成立: 459 带 bound ⇒ 未绑定不在其中
|
||||
✗ 我 `1de1c4c7` 那句"减掉的是未绑定"**同样不成立** ⇒ 我们**两边都错了同一个前提**
|
||||
★ 真答案(在"09-25 loose 口径"下才成立): 459(loose) − 1 = 458 **= bound 口径** ⇒
|
||||
那一步根本不是"去掉某性质",而是 **loose → bound 的口径换算**(减掉全部占位行里满足该谓词者)。
|
||||
✗ 但结论"459 − 1(未绑定) = 458"**在今日脚本帧下**不成立: 459 带 bound ⇒ 未绑定不在其中
|
||||
★★ 而下面那句"我 `1de1c4c7` 同样不成立、两边都错了同一个前提"—— **该句作废**,理由见 (E)。
|
||||
```
|
||||
## (E) ★★★ 2026-09-26 订正: 我 `1de1c4c7` **在其帧内逐条为真**;真错是**把两个帧的 459 当同一集合**
|
||||
```
|
||||
★ 由 pi `e440953b` 指出,我用 `relayed_mails.created_at` 逐条复算,**他全对**:
|
||||
· 决定性时间序: 脚本首版 `3f312de` 提交 = **09-25 06:08:59**;
|
||||
我 `1de1c4c7` = **09-25 05:52:04** ⇒ ★ **晚 16m55s** ⇒ 写那封时脚本**尚不存在**
|
||||
⇒ "我拿脚本的 459 去套讨论的 459"**在时间上不可能**
|
||||
· 帧重建(`created_at <= '2026-09-24 21:52:04'`,该字段 0 NULL):
|
||||
bound∧P = **458** loose∧P = **459** ⇒ 讨论里的"458 + 1 未绑定 = 459"**逐值吻合**
|
||||
· `1de1c4c7` 的四条断言,在**它自己的帧**里逐条为真:
|
||||
[a] 459(loose) 里未绑定那 1 行 = **1** ✓
|
||||
[b] 458(bound) 里残留那 1 行 = **1** ✓
|
||||
[c] 残留总数 = **2** ✓
|
||||
[d] 459(loose) − 2 = **457** ✓(反事实成立)
|
||||
⇒ ★★ 所以: 它**没有**把两个帧混起来,它是在**讨论帧**里做了**正确的逐行归属**。
|
||||
真正该记的错是 —— **同一个数字 459 的所指随时间变了**:
|
||||
讨论帧(09-24 21:52 UTC)loose∧P = 459(含那行未绑定)
|
||||
今日帧 bound∧P = **459**(不含它)
|
||||
两口径各 +1(09-25 09:26 新增 `723493b7`,真实投递)后**恰好撞上同一个数**
|
||||
⇒ 而**真错只在一处**: pi `44dccaee` 那句"减去那 1 行**残留**"(他拿 loose 的 459 减 bound 的性质)
|
||||
—— 这条 pi 自己认了(`e440953b` §三),**不是我的**。
|
||||
⇒ ★ 教训: 我"自查"时**只验证了结论**(今日帧下 459−1(未绑定) 不成立),
|
||||
**没验证那个结论是否适用于被评的那个动作发生的时刻** ——
|
||||
这与我在 `e77154d1` 那轮踩的"观测点必须与被观测的判据在同一时刻"是**同一条**,
|
||||
两次都由我自己踩中 ⇒ 它足够高频,值得写进清单。
|
||||
⇒ ⚠️ 我在 `52d9b30` 提交信息里写的"(我 1de1c4c7 也用错了口径)"**同属该错**;
|
||||
该提交**未被邮件引用**(引用计数 0)且**未推送**(不在 `origin/main`),
|
||||
故按"改写成本低 + 记录准确性"衡量应修 —— 但改写会移动其后代 SHA,
|
||||
而**其中多个 SHA 已被邮件引用**(`5c5e12b`=3、`79ef8c1`=7、`3b677ca`=8 …)⇒
|
||||
**改写的代价比收益大** ⇒ 我**不改写历史**,改为在此显式标注该提交的那句作废。
|
||||
```
|
||||
|
||||
- ★★ 接上条: 我把 pi 那个"**不符 0 / 256**"独立复算,发现它**依赖"安全"的读法**,但**结论不变**(两者都试过,结论相同)。
|
||||
|
||||
Reference in New Issue
Block a user