文档: 记下 B-7.7 那条债的**实测实例** —— 每日 04:00 的 dsh 重启让重投每天复发

与契约里 2026-09-04 那个事故**同形**,只是换成了"宿主被定时重启"。链条完整量到:

1. root crontab `0 4 * * * /usr/bin/systemctl restart dsh.service`(第 6 行)
2. 每天 04:00:03 `dsh.service` Stop/Start(journalctl 逐日可见)
3. 新进程 ⇒ `deliveredMails` 空(index.ts:469,内存 BoundedSet)
4. `caughtUp` 重置 ⇒ 首个心跳后 catchUp 跑一次(:470 / :524)
5. 拉 `/mail/inbox?status=unread&limit=20`(:481)⇒ 整个未读积压当天重投一遍

实测(以 `mails` 表为判据):真邮件投递 **107 次 / 81 封**不同邮件,
04 点整点占 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 从不重投已读邮件,它投的是"当时确实未读"的。"未读"有两个独立成因:
(a) `read_mail` 不标已读(T-12 对已读状态沉默);(b) `markReadFor` 占位符错位
(2026-09-20 修)。两者都让"读过了"没变成"已读"。

⚠️ 我把这里算错过两次,都写进文里当纪律:
① `agent/inbox/spliced` 里混着**非邮件**注入(后台作业完成通知、续跑提示),
   按"事件数"统计会当成邮件(我一度报 09-20 有 18 次,真值 **3**);
   ⇒ 要拿 id 去问 `mails` 表在不在,才算真邮件。**同一事件类型 ≠ 同一种载荷。**
② 判断"有没有重投已读的信"**不能看当前 `mail_reads`**:11 封现在是"已读+曾重投",
   看着像反例,其实是**先被重投、后来才补标**的。必须拿**每次投递的时刻**比 `read_at`
   —— `mail_reads` 是当前状态,"投递时是否已读"是历史事实,两者不是一回事。

dsh 桥去重仍是内存态 ⇒ B-7.7 要求的落盘账本这条债**仍未销**(已在文里写明)。
This commit is contained in:
2026-09-21 04:44:32 +08:00
parent 63d430f423
commit ecf98d7d36

View File

@ -398,6 +398,44 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
>
> 模型上下文里因此出现两段几乎相同的指令。
>
> ★★ **同一条规律在 dsh 宿主上的实测实例(2026-09-21 量到,非推测)**:
> 与上面那个 09-04 事故**同形**,只是换成了"宿主被定时重启"。完整链条:
>
> 1. root crontab 有一条 `0 4 * * * /usr/bin/systemctl restart dsh.service`
> (`/var/spool/cron/crontabs/root` 第 6 行)
> 2. 于是每天 **04:00:03** `dsh.service` 被 Stop/Start(`journalctl -u dsh` 逐日可见)
> 3. **新宿主进程 ⇒ `deliveredMails` 是空的**(`plugins/dsh-mail-bridge/src/index.ts:469`,内存 `BoundedSet`)
> 4. `caughtUp`(`:470`)在新进程里重置 ⇒ 首个成功心跳后 `catchUp` 跑一次(`:524`)
> 5. 它拉 `/mail/inbox?status=unread&limit=20`(`:481`)⇒ **把整个未读积压当天重投一遍**
>
> 实测:单条会话日志里**真邮件**投递(以 `mails` 表为判据,见下方警告)共 **107 次**、
> 涉及 **81 封**不同邮件;其中 **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` 从不重投已读邮件 ——
> 它投的是"当时确实未读"的那些**。"未读"的成因有两条,互相独立:
> **(a)** `read_mail` 不标已读(契约 T-12 对已读状态沉默);
> **(b)** `markReadFor` 的占位符错位导致权威列漏写(2026-09-20 修,见 `docs/API.md`)。
> 两者都让"读过了"没变成"已读",于是下次宿主重启时被当成新信再投一遍。
>
> ⇒ 只要"每日重启 + 内存去重 + 任何一条让已读没记上的路径"三者同时存在,
> 重投就会**每天复发**,且看起来像"对方又发了一遍"。B-7.7 要求的落盘账本正是
> 为这条链准备的;dsh 桥目前仍是内存态 ⇒ **这条债仍未销**。
>
> ⚠️ **我第一版把这里算错过两次,都记下来**(同一个 `agent/inbox/spliced` 事件类型里
> 混着**非邮件**的注入 —— 后台作业完成通知、session 续跑提示等):
> ① 按"事件数"统计会把这批噪声算成邮件(我一度报出 09-20 有 18 次,真值是 **3**);
> ② 只看正文里有没有 `邮件 ID` 还不够 —— 要拿 id 去问 **`mails` 表**在不在,
> 才算"真的是一封邮件被投递"。**同一个事件类型 ≠ 同一种载荷。**
>
> ⚠️ 另一条**取证纪律**(我第一版也算错过):判断"有没有重投已读的信"**不能看当前
> `mail_reads`**。有 11 封现在是"已读 + 曾重投",看着像反例 —— 其实它们是**先被重投、
> 后来才被某次 `read_inbox` 补标上**的。必须拿**每次投递的时刻**去和 `read_at` 比:
> `mail_reads` 是**当前状态**,而"投递时是否已读"是**历史事实**,两者不是一回事。
>
> **为什么不能只记「投过没有」**:那会把「重复」换成「丢件」。上面第 2 步里
> 那一轮被掉断,发件人**没有**收到回信,而落盘记录说「已投过」→ 永远跳过。
> 丢件比重复严重:重复至少人能看出来,丢件是静默的。