diff --git a/docs/PLUGIN-CONTRACT.md b/docs/PLUGIN-CONTRACT.md index bd9a5a4..995619e 100644 --- a/docs/PLUGIN-CONTRACT.md +++ b/docs/PLUGIN-CONTRACT.md @@ -408,15 +408,50 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一 > 4. `caughtUp`(`:470`)在新进程里重置 ⇒ 首个成功心跳后 `catchUp` 跑一次(`:524`) > 5. 它拉 `/mail/inbox?status=unread&limit=20`(`:481`)⇒ **把整个未读积压当天重投一遍** > -> 实测:单条会话日志里**真邮件**投递(以 `mails` 表为判据,见下方警告)共 **107 次**、 -> 涉及 **81 封**不同邮件;其中 **04:00 那个整点**占 +> 实测:单条会话日志里**真邮件**投递(以 `mails` 表为判据,见下方警告)共 **108 次**、 +> 涉及 **82 封**不同邮件;其中 **04:00 那个整点**占 > 09-18:4 / 09-19:3 / 09-20:3 / 09-21:7 次;投递时刻精确落在 > 04:00:23 / 04:01:23 / 04:03:23… 这类 ≤5 封一批的节奏上(B-7.2 的每批上限)。 > 会话内的 `session/end-seed` 也逐日落在 04:00。 > -> 关键判别(用**时序**,不是用"现在有没有已读行"):这 **107 次投递中, -> 「投递时该读者已有已读行」= 0 次**。也就是说 **`catchUp` 从不重投已读邮件 —— -> 它投的是"当时确实未读"的那些**。"未读"的成因有两条,互相独立: +> ⚠️ **这两个数会一直涨**(我 09-21 上午量到 107/81,下午同一条命令就是 108/82 —— +> 只要这条链还在,每来一封新信就 +1)。所以**别把它当验收阈值**,要钉的是 +> **关系**:"重投次数 ≥ 该会话真邮件数" 且 "重投时刻聚在 04:00 整点"。 +> 与 `check-deploy-drift.mjs` 里那个被我删掉的"133 个文件"是同一条教训。 +> +> 关键判别(用**时序**,不是用"现在有没有已读行"):这 **108 次投递中, +> 「投递时该读者已有已读行」= 0 次** ⇒ **在 dsh 这条日志覆盖的窗口里**, +> `catchUp` 投的都是"当时确实未读"的那些。 +> +> ⚠️⚠️ **但"绝不重投已读"这个泛化是错的 —— 我自己在 09-21 把它写宽了,09-21 当天量到反例。** +> 同一份 dsh 数据只能说"这 108 次里没有",**不能说"机制上不会"**。反例在 **pi 侧**: +> 带 `catchup:true` 标记(`relay-policy.js:113` 的"离线期间积压的邮件,现在补投给你") +> 的投递共 **63 次**,其中 **7 次投递时 pi 的已读行已经存在**: +> +> | 邮件 | 投递 | 该行 `read_at` | 差 | +> |---|---|---|---| +> | `1868127e` | 09-14 09:25:31 | 09:22:25 | +186s | +> | `e211b554` | 09-14 09:26:29 | 09:22:25 | +244s | +> | `2fce271d` | 09-14 09:27:14 | 09:22:25 | +289s | +> | `c3638d4e` | 09-14 09:27:57 | 09:22:25 | +332s | +> | `7a350f9d` | 09-14 15:19:21 | 15:16:12 | +189s | +> | `57b0c703` | 09-14 15:20:44 | 15:16:12 | +272s | +> | `18527c6b` | 09-14 15:21:38 | 15:16:12 | +326s | +> +> **根因是"快照 + 串行"这个组合**,不是"未读判定写错了": +> `catchUp` 先 `GET /mail/inbox?status=unread&limit=20` 取一份**清单快照**, +> 然后 **逐封串行**投(每封起一轮模型,B-7.2)。这 7 封都在同一批快照里, +> 而模型在投第 1 封之后调了一次 `read_inbox`(`01:22:25` / `07:16:12`), +> **把剩下几封一次标成已读** —— 但快照早就取好了,后面的照投不误。 +> ⇒ 判据"投递时该读者是否已有已读行"**测不出这条**:它测的是"当下快照对不对", +> 而这里快照**当时是对的**,只是**投的时候过期了**。 +> **"判据没答 ≠ 判据答错了"**:这 0/108 没答错,它答的是另一个问题。 +> +> ⇒ 结论要分开写:`catchUp` 的**取件谓词**没有错(`unreadFor` 只看 `mail_reads`, +> 取的时候确实未读);**错的是取件与投递之间的时效假设**。这给了"每天重投" +> **第三条**独立成因(前两条见下)。也说明"修好 (b) 重投就会停"是新的假绿期待。 +> +> "未读"的成因有两条,互相独立(**连同上面那条"快照过期"共三条**): > **(a)** `read_mail` 不标已读(契约 T-12 对已读状态沉默); > **(b)** `markReadFor` 的占位符错位导致权威列漏写(2026-09-20 修,见 `docs/API.md`)。 > 两者都让"读过了"没变成"已读",于是下次宿主重启时被当成新信再投一遍。 @@ -436,6 +471,10 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一 > 后来才被某次 `read_inbox` 补标上**的。必须拿**每次投递的时刻**去和 `read_at` 比: > `mail_reads` 是**当前状态**,而"投递时是否已读"是**历史事实**,两者不是一回事。 > +> ★ 但**别把这条纪律读反了**:它只说明"看当前状态会漏判/误判", +> **不等于"不存在真反例"** —— 上面 pi 侧那 7 例**正是**用"投递时刻 vs `read_at`"这个 +> 正确口径量出来的真反例。**同一条纪律既排除了 11 个假反例,也捞出了 7 个真反例。** +> > **为什么不能只记「投过没有」**:那会把「重复」换成「丢件」。上面第 2 步里 > 那一轮被掉断,发件人**没有**收到回信,而落盘记录说「已投过」→ 永远跳过。 > 丢件比重复严重:重复至少人能看出来,丢件是静默的。