更正: 「catchUp 从不重投已读邮件」是**我自己写宽的泛化** —— pi 侧量到 7 个真反例

我在 `ecf98d7` 往 B-7.7 里写了一句泛化:
「这 107 次投递中『投递时该读者已有已读行』= 0 次。也就是说 **`catchUp` 从不重投
已读邮件**」。**前半(我量的那 108 次里没有)成立;后半(机制上不会)是错的。**

## 反例(pi 侧,用同一个正确口径量出来的)

带 `catchup:true` 标记的投递共 63 次,其中 **7 次投递时 pi 的已读行已存在**:

    1868127e  投 09-14 09:25:31 | read_at 09:22:25 | +186s
    e211b554  投 09-14 09:26:29 | read_at 09:22:25 | +244s
    2fce271d  投 09-14 09:27:14 | read_at 09:22:25 | +289s
    c3638d4e  投 09-14 09:27:57 | read_at 09:22:25 | +332s
    7a350f9d  投 09-14 15:19:21 | read_at 15:16:12 | +189s
    57b0c703  投 09-14 15:20:44 | read_at 15:16:12 | +272s
    18527c6b  投 09-14 15:21:38 | read_at 15:16:12 | +326s

## 根因不是"未读判定写错",是"快照 + 串行"

`catchUp` 先取一份 `status=unread` **清单快照**,再**逐封串行**投(每封起一轮模型)。
这 7 封同属一批快照,而模型投完第 1 封后调了一次 `read_inbox`(`01:22:25`/`07:16:12`)
**把剩下几封一次标成已读** —— 快照早已取好,后面的照投不误。

⇒ **判据"投递时是否已有已读行"测不出这条**:它测"当下快照对不对",
而这里快照**当时是对的**,只是**投的时候过期了**。
**"判据没答 ≠ 判据答错了"** —— 0/108 没答错,它答的是另一个问题。

⇒ 这让"每天重投"多出**第三条**独立成因(前两条:`read_mail` 不标 / `markReadFor` 错位)。
也说明"修好 (b) 重投就会停"是一个**新的假绿期待**。

## 顺带:两个数会一直涨,别当阈值

107/81 → 同一条命令当天下午就是 **108/82**(每来一封新信 +1)。
已改成钉**关系**(重投次数 ≥ 该会话真邮件数;时刻聚在 04:00 整点),
并注明别当验收阈值 —— 与我删掉 `check-deploy-drift.mjs` 里"133 个文件"是同一条教训。

★ 纪律提醒也补了一句:那条"别只看当前 `mail_reads`"的注意事项
**只说明会漏判/误判,不等于不存在真反例** —— 同一口径既排除了 11 个假反例,
也捞出了这 7 个真反例。
This commit is contained in:
2026-09-21 05:59:20 +08:00
parent 33b6033bdd
commit 9336fa768b

View File

@ -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 步里
> 那一轮被掉断,发件人**没有**收到回信,而落盘记录说「已投过」→ 永远跳过。
> 丢件比重复严重:重复至少人能看出来,丢件是静默的。