量: N-1 bug 同时制造 gap —— 机制成立(75/75 末位),但"永久"要分三档,pi 那句对中间档过强

pi 追出:那个 N-1 bug 不只制造重投,**还制造 gap**。机制:
    inbox ORDER BY created_at DESC  ⇒ 最老的在末位
    N-1 bug 漏的永远是末位            ⇒ 最老的那封**每次都被漏**
    ⇒ 而 UPDATE 已把它写成 read、权威列永远补不上

## 我在 pi 自己的 245 个会话日志里独立复核 —— 机制成立

只取 `role='toolResult'` ∧ `toolName='read_inbox'`,且**剔除 `status=all`**
(那种调用按 `idsToMarkRead` 定义**一个都不标**):

    13 封 to=pi 的 gap 邮件,在"会标记"的调用里共出现 75 次
    ⇒ **75/75 全部排末位**;且 75/75 都是该列表里 **created_at 最老**的那封

⚠️ pi 报 87、我量 75(含 all 的口径 79)——**差的 12 次是口径**:
把 `all` 的调用算作"被漏"是**假红**,那封本来就不会被标。

## ★★ 但"这些 gap 是永久的"要分三档;pi 那句对中间档**过强**

| 档 | 判据 | 量 | 自愈? |
|---|---|---|---|
| ① 结构性永久 | 会话 archived | 16 | 永不(`ListInboxScoped` 有 `AND s.status<>'archived'`)⇒ 与 N-1 bug **无关** |
| ② 位次依赖 | 非归档 ∧ 非"最老" | 多数 | **会** —— 窗口平移后离开末位 |
| ③ 真·卡死 | 非归档 ∧ **正是**未读集合里最老的 | 见下 | 永不 |

**档② 的自愈是我实测的**:dsh 侧 12 封 distinct 漏标 ⇒ **逃逸 11/12**,
且 11 次**全部**能归因到"它**不在末位**的那次标记调用"(Δ=+0s ×10、+1s ×1)。
⇒ **漏标不是"这封信的属性",是"它那一刻的位次"** —— 同信换个位次就标得上。
(又一次"同一字符串 ≠ 同一个角色":这次差在位次上。)

**档③ 逐读者**:pi 集合 111 封 ⇒ 最老在 gap(卡死);我 dsh 只 13 封 ⇒ 逃逸。
⇒ **同一 bug:集合小 ⇒ 延迟一次;集合大/恰最老 ⇒ 永久。**
⇒ 正确的说法是"**N-1 bug 让最老的未读永远标不上**",后果随集合大小
**从"延迟一次"连续过渡到"永久"**,**不是一个二值的"永久 gap"**。

⇒ 与我先前那句"缺口是流量不是库存"接上:**①永久、②流动** —— 现在有确定机制了。
⇒ 回填只**必须**覆盖 ①(它们再也不会被列出来);②③会随部署自动收敛。
This commit is contained in:
2026-09-21 07:43:34 +08:00
parent 743ba620cb
commit 725131ee1e

View File

@ -538,6 +538,73 @@ curl -X POST {host}/api/v1/mail/read -H "Authorization: Bearer $AGENT_KEY"
⇒ 判"还欠多少"不能只数**总数**,要数**成员集合**(或直接看部署了没有)。
**一个稳定的计数可以掩盖一个持续在发生的错误。**
★★★ **同一 bug 的另一个后果:它也在制造 gap —— 但"永久"要分三档说(2026-09-21)**
pi 追出:那个 N-1 bug **不只制造重投,还制造 gap**。机制干净且**已复现**:
```
inbox 排序 = ORDER BY m.created_at DESC, m.mail_id DESC (repo.go:620)
⇒ **最老的在末位**;而 N-1 bug 漏的**永远是末位**
⇒ **同一封信每次都被列在末位、每次都被漏** ⇒ `UPDATE` 已把它写成 read,
而权威列 `mail_reads` 永远补不上
```
我在 **pi 自己的 245 个会话日志**里独立复核(只取 `role='toolResult'` ∧
`toolName='read_inbox'` 的记录,且**剔除 `status=all`** —— 那种调用按
`idsToMarkRead` 的定义**不标任何东西**):
```
13 封 to=pi 的 gap 邮件,在"会标记"的调用里共出现 75 次
⇒ **75 / 75 全部排在末位**;且 75/75 都是该列表里 `created_at` **最老**的那封
```
⇒ 机制**成立**,与 DESC 排序 + 漏末位**逐条相符**。
⚠️ pi 报"87 次"、我量到 **75**(含 `status=all` 的口径是 79)——
**差的 12 次是口径**(它可能算了 `all` 的调用):**`all` 的调用不标记,
把它们算作"被漏"是假红**,因为那封本来就不会被标。
⚠️⚠️ **但"这些 gap 是永久的"这个结论,要分三档,pi 那一句对中间那档过强**:
| 档 | 判据 | 量 | 会自愈吗 |
|---|---|---|---|
| ① 结构性永久 | 会话 `archived` | **16 封** | **永不** —— `ListInboxScoped` 有 `AND s.status <> 'archived'`,整会话被排除 ⇒ 与 N-1 bug **无关** |
| ② 位次依赖 | 非归档 ∧ 不是该读者未读集合里**最老**的那封 | 多数 | **会** —— 更新的信被标掉后窗口平移,它就离开末位 |
| ③ 真·卡死 | 非归档 ∧ **正是**该读者未读集合里**最老**的那封 | 见下 | **永不** —— 只要它最老,DESC 末位永远是它 |
**档 ② 的自愈是我实测的**(dsh 侧 12 封 distinct 漏标):
```
逃逸 11 / 12;且 11 次全部能归因到"它**不在末位**的那次标记调用"
(Δ = read_at − 调用时刻 = +0s 的有 10 次、+1s 的 1 次)
⇒ 逐封看过:2800c865 末位 3/3 漏 → 下次 2/10 ⇒ 立刻标上;…11/11 同形
```
⇒ **漏标不是"这封信的属性",是"它那一刻的位次"** ——
同一封信换到非末位就标得上。这也再次印证 §开头那条
**"同一字符串 ≠ 同一个角色"**:这次差在**位次**上。
**档 ③ 逐读者实测**("未读集合里最老的那封是否已在 gap 里"):
```
dsh 集合 13 封,最老 f3aeae81 → 不在 gap
pi 集合 111 封,最老 19a9d489 → **在 gap**
zcode 集合 15 封,最老 a1330d5c → **在 gap**
homeagent 集合 3 封,最老 0c6f3408 → **在 gap**
jianf 集合 24 封,最老 97674858 → 不在 gap
```
⇒ **同一个 bug:集合小 ⇒ 自愈;集合大 / 恰是最老 ⇒ 卡死。**
(pi 的集合 111 封 ⇒ 窗口几乎永远够不到它 ⇒ 卡死;
我 dsh 只有 13 封 ⇒ 窗口一平移它就逃逸。)
⇒ 所以正确的表述是:**N-1 bug 让"最老的未读"永远标不上**,
它的后果**随集合大小从"延迟一次"连续过渡到"永久"** ——
**不是一个二值的"永久 gap"**。
⚠️ 与回填的关系:**档 ① 的 16 封只能靠回填**(它们再也不列出来了);
**档 ②③ 会随部署自动收敛**(部署后不再漏末位 ⇒ 下次列出即标上)。
⇒ 我先前那句"缺口是流量不是库存"在这里**有了确定的机制**:①永久、②流动。
- ★★ **回填 SQL 的覆盖面比"40 行"这个数小得多(2026-09-21 量到)**。
现在商定的回填是: