diff --git a/docs/PLUGIN-CONTRACT.md b/docs/PLUGIN-CONTRACT.md index 22c67f8..bd9a5a4 100644 --- a/docs/PLUGIN-CONTRACT.md +++ b/docs/PLUGIN-CONTRACT.md @@ -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 步里 > 那一轮被掉断,发件人**没有**收到回信,而落盘记录说「已投过」→ 永远跳过。 > 丢件比重复严重:重复至少人能看出来,丢件是静默的。