/** * ★ 投递即标已读 —— 防「桥重启 → 重投 → 回声」。 * * # 缺陷(用户报的,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), '反例确实不标已读 ⇒ 会判红'); });