Files
MailUI4Agents/plugins/pi-mail-bridge/test/delivery-marks-read.test.mjs
JianFeeeee 4175c0ba45 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`(依赖构建机路径,改动前后同样红)全绿。
2026-09-26 09:18:39 +08:00

100 lines
4.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ★ 投递即标已读 —— 防「桥重启 → 重投 → 回声」。
*
* # 缺陷(用户报的,2026-09-26)
*
* 投递路径只把 mail_id 记进内存的 `deliveredMails`,**不动库里的 status**。
* 而 `catchUp` 按 `status=unread` 拉 ⇒ 桥一重启(每次部署都会),积压的
* "未读"被当成离线漏投**再投一遍**:
*
* · 5 个 mail_id 各进了**两条不同 pi 会话**(04:54 一条、08:01 一条)
* · 同一封信投两次 ⇒ 两个 worker 各回一封 ⇒ 对方收到两封 ⇒ 各回两封…
* · pi 收件箱 287 封 unread 中 **187 封已经回过信了**
* · 两条会话各烧到 463 / 268 封
*
* 用户原话:「我都不记得我下达这个任务,是你的桥自动重投存在 bug」
* 「就是你的错误的重投机制造成了回声」
*
* # 判据要钉住什么
*
* 不是"有没有 markDelivered 这个函数"(那太弱),而是:
* ① `deliveredMails.add` **只能**出现在 markDelivered 内部 —— 别处直接 add
* 就是一条绕过标已读的重投路径(这正是缺陷的形状)
* ② markDelivered 必须真的 POST /mail/read
* ③ 三个投递点(SSE / 补投 / 决策回执)都走它
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const src = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'index.mjs'),
'utf8',
);
test('★ deliveredMails.add 只允许出现在 markDelivered 内部', () => {
/*
这一条是整组判据的核心。
任何别处的 `deliveredMails.add(x)` 都意味着「我接管了这封,但库里还是
unread」⇒ 下次重启重投。缺陷就是这么长出来的:四处投递各自 add,
没有一处标已读。
允许的行号集合:markDelivered 函数体的那几行。
*/
const lines = src.split('\n');
const fnStart = lines.findIndex(l => /^function markDelivered\(/.test(l));
assert.ok(fnStart >= 0, 'markDelivered 必须存在');
// 函数体到下一个顶层 } 为止
let fnEnd = fnStart;
for (let i = fnStart; i < lines.length; i++) {
if (/^\}/.test(lines[i]) && i > fnStart) { fnEnd = i; break; }
}
const strays = [];
lines.forEach((line, i) => {
if (!/deliveredMails\.add\(/.test(line)) return;
if (i > fnStart && i < fnEnd) return; // 在 markDelivered 体内,合法
if (/^\s*(\/\/|\*|\/\*)/.test(line)) return; // 注释里提到它(说明文字)
strays.push(`${i + 1}: ${line.trim()}`);
});
assert.deepEqual(strays, [],
`这些地方绕过 markDelivered 直接 add ⇒ 库里的 status 不会被更新 ⇒ 重启重投:\n${strays.join('\n')}`);
});
test('markDelivered 同时写内存与库(两处口径必须一致)', () => {
const i = src.indexOf('function markDelivered(');
assert.ok(i > 0);
const body = src.slice(i, src.indexOf('\n}', i));
assert.match(body, /deliveredMails\.add\(/, '要写内存集合');
assert.match(body, /client\?\.post\('\/mail\/read'/, '要写库里的 status');
assert.match(body, /mail_ids: \[mailId\]/, '按 id 标(不传 mail_ids 会被服务端要求 workspace)');
});
test('★ 三个投递点都走 markDelivered(少一处就是一条重投路径)', () => {
// SSE new_mail 主路径
// 注意:`return;` 后面可能跟行尾注释("// B-3 第 1 步:去重"),
// 所以不能写 `return;\s*\n` —— 那样会被注释挡住而误报缺失。
assert.match(src, /if \(!id \|\| deliveredMails\.has\(id\)\) return;[^\n]*\n\s*markDelivered\(id\);/,
'SSE 主投递路径');
// 心跳补投(同样:`continue;` 后有行尾注释)
assert.match(src, /if \(deliveredMails\.has\(ev\.mail_id\)\) continue;[^\n]*\n\s*markDelivered\(ev\.mail_id\);/,
'补投路径');
// 决策回执
assert.match(src, /if \(decisionMailID\) markDelivered\(decisionMailID\);/,
'决策回执');
});
test('判据自检:反例(别处 add、不标已读)必须判红', () => {
// 这正是修复前的形状 —— 判据若放过它,就防不住回归
const before = 'if (data?.mail_id) deliveredMails.add(data.mail_id);';
const lines = [before];
const fnStart = lines.findIndex(l => /^function markDelivered\(/.test(l));
assert.equal(fnStart, -1, '反例里没有 markDelivered ⇒ 回退到最弱判据');
assert.ok(/deliveredMails\.add\(/.test(before), '反例确实会 add');
assert.ok(!/mail\/read/.test(before), '反例确实不标已读 ⇒ 会判红');
});