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:
@ -101,6 +101,54 @@ const log = (...args) => console.error('[pi-mail-bridge]', ...args);
|
||||
// SSE 断线重放)都发生在秒到分钟级,几千封之前的 id 不可能再来。
|
||||
const deliveredMails = new BoundedSet(MAX_TRACKED_MAILS);
|
||||
|
||||
/*
|
||||
★ 2026-09-26(用户报的):**投递即标已读**。
|
||||
|
||||
# 缺陷:桥投了信却不动 status ⇒ 每次重启都重投一遍 ⇒ 回声
|
||||
|
||||
投递路径(SSE `new_mail` / 心跳补投)只做两件事:起 worker、把 id 记进
|
||||
`deliveredMails`。**没有任何一处调 `/mail/read`** —— 桥里唯一那处标已读
|
||||
在 `read_inbox` 工具里,要等模型自己去读收件箱。
|
||||
|
||||
于是每封被投递的信**永远是 unread**;而 `catchUp` 按 `status=unread` 拉。
|
||||
⇒ 桥一重启(每次部署都会),积压的"未读"被当成离线漏投**再投一遍**。
|
||||
|
||||
实测(2026-09-26):
|
||||
· 5 个 mail_id 各出现在**两条不同 pi 会话**里(04:54 一条、08:01 一条)
|
||||
· 同一封信被投两次 ⇒ 两个 worker 各回一封 ⇒ 对方收到两封 ⇒ 各回两封…
|
||||
· pi 的收件箱 287 封 unread 中 **187 封已经回过信了**
|
||||
(`EXISTS(SELECT 1 FROM mails r WHERE r.parent_mail_id=m.mail_id)`)
|
||||
· 两条会话各烧到 463 / 268 封,用户原话:「我都不记得我下达这个任务,
|
||||
是你的桥自动重投存在 bug」「就是你的错误的重投机制造成了回声」
|
||||
|
||||
# 修法:把"投递"与"已读"合成一个动作
|
||||
|
||||
`deliveredMails` 是**内存**里的去重集合,重启即丢。数据库的 `status` 才是
|
||||
跨重启的那个"我接管过了"记录 —— 两者必须同时写,否则口径不一致:
|
||||
|
||||
内存说"投过了"、库说"没读过" ⇒ 重启后桥只认库 ⇒ 重投。
|
||||
|
||||
因此凡是把一封标记进 `deliveredMails` 的地方,都要同时把它在**库里**标成已读。
|
||||
这就是 `markDelivered` 存在的理由:不漏一处(漏一处就是一条重投路径)。
|
||||
|
||||
# 为什么标已读不会"吞掉"信
|
||||
|
||||
标已读只改 status,不改内容、不删行;`read_inbox` 传 `status=all` 依然看得到。
|
||||
而"这封信已经被交给一个 worker 处理了"正是它此刻的真实状态 —— 库里记的
|
||||
本来就该是这件事,而不是"模型有没有顺手调过 read_inbox"。
|
||||
|
||||
# 失败不阻塞
|
||||
|
||||
标不上不该让投递失败(正文已经在 worker 手里,代价只是下次重启可能再投一次,
|
||||
与旧行为一致、不更差)。写日志,好让"标已读坏了"这件事可见。
|
||||
*/
|
||||
function markDelivered(mailId) {
|
||||
if (!mailId) return;
|
||||
deliveredMails.add(mailId);
|
||||
client?.post('/mail/read', { mail_ids: [mailId] }).catch((e) =>
|
||||
log(`投递标已读失败 ${mailId}: ${e?.message || e}`));
|
||||
}
|
||||
|
||||
let allowedModels = [];
|
||||
let modelRuntime = null;
|
||||
let client = null;
|
||||
@ -165,7 +213,7 @@ function handlePermissionDecision(data) {
|
||||
// 决策回执的内容马上随这次恢复交给 worker(备注 + 等人期间新到的邮件都在里面),
|
||||
// 所以先把它记成"已交付":稍后那封同内容的邮件就不会被当成新任务再起一轮。
|
||||
const decisionMailID = data?.decision_mail_id || '';
|
||||
if (decisionMailID) deliveredMails.add(decisionMailID);
|
||||
if (decisionMailID) markDelivered(decisionMailID);
|
||||
|
||||
// 拉"等人期间新到的邮件"要发一次 HTTP,而本函数跑在 SSE 读循环上(必须廉价)
|
||||
// —— 所以整体转异步:先把事件收下,几毫秒后带着上下文去唤醒 worker。
|
||||
@ -316,7 +364,7 @@ async function catchUp(pending, workspaces) {
|
||||
const tasks = selectCatchup(box?.mails ?? box, deliveredMails);
|
||||
for (const ev of tasks) {
|
||||
if (deliveredMails.has(ev.mail_id)) continue; // 逐封再查(B-7.6)
|
||||
deliveredMails.add(ev.mail_id);
|
||||
markDelivered(ev.mail_id);
|
||||
pool.submit('mail', ev);
|
||||
delivered += 1;
|
||||
}
|
||||
@ -462,7 +510,7 @@ function handleSSEEvent(type, data) {
|
||||
if (data?.role && data.role !== 'to' && data.role !== 'cc') return;
|
||||
const id = data?.mail_id;
|
||||
if (!id || deliveredMails.has(id)) return; // B-3 第 1 步:去重
|
||||
deliveredMails.add(id);
|
||||
markDelivered(id);
|
||||
// ★ 决策回执**不是新任务**(2026-09-13 线上缺陷)。
|
||||
//
|
||||
// 它长得像普通邮件("Re: 权限请求 - 拒绝"),内容却已经随 SSE 的
|
||||
|
||||
99
plugins/pi-mail-bridge/test/delivery-marks-read.test.mjs
Normal file
99
plugins/pi-mail-bridge/test/delivery-marks-read.test.mjs
Normal file
@ -0,0 +1,99 @@
|
||||
/**
|
||||
* ★ 投递即标已读 —— 防「桥重启 → 重投 → 回声」。
|
||||
*
|
||||
* # 缺陷(用户报的,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), '反例确实不标已读 ⇒ 会判红');
|
||||
});
|
||||
@ -135,8 +135,12 @@ export const WIRING = [
|
||||
must: /reason: renderDecisionReason\(/ },
|
||||
{ file: 'index.mjs', what: '决策事件把 note 传给 pool',
|
||||
must: /routePermission\(relayKey, decision, note, waiting\)/ },
|
||||
// ★ 2026-09-26:这一条原来钉的是 `deliveredMails.add(decisionMailID)` ——
|
||||
// 直接写内存集合。那个形状正是「桥重启后重投」缺陷的载体(deliveredMails
|
||||
// 只在内存、库里的 status 从没被写)。现在统一走 markDelivered:
|
||||
// 内存与库一起写。语义没变(仍"不再当新任务"),载体变了。
|
||||
{ file: 'index.mjs', what: '决策回执记成已交付(不再当新任务)',
|
||||
must: /deliveredMails\.add\(decisionMailID\)/ },
|
||||
must: /markDelivered\(decisionMailID\)/ },
|
||||
{ file: 'index.mjs', what: 'new_mail 分支认得决策回执',
|
||||
must: /mail_type === 'permission_decision'/ },
|
||||
{ file: 'turn.mjs', what: '通知投递路径也带备注',
|
||||
|
||||
Reference in New Issue
Block a user