docs(欠账): 登记 read_inbox 吞掉在途邮件(压测后实测复现 3/3)

## 现象(实测,不是推演)

给 pi 发一封邮件 ⇒ 库里 `status` 变 `read`(投递后 **10~13ms**),
而**桥的日志里那封 mail_id 一次都没出现** ⇒ 没起会话、没人回信。

  读数:`mail_reads.read_at - mails.created_at = 0.013s`
        桥日志提及次数 = 0/3(连发三封,三封全中)
  发件人视角 = 「信发出去了,然后没声了」

## 机制:两件事各自都对,合起来丢信

① `4175c0b` 把「投递即标已读」做成一个动作(`markDelivered`),
   治的是「桥重启 → 重投 → 回声」;`catchUp` 按 `status=unread` 捞。
② `read_inbox` **读完自动标已读**(README 明写),而它按 inbox 取信,
   **不区分「这封是不是正在等派发」**。

⇒ 任何一次 `read_inbox`(不论模型为什么调)都会把**当时还在 unread 队列里**
的信全部连带标掉,其中包含**这一轮刚投递、还没轮到起 worker** 的那封。
它随后既不在 unread 里(捞不到)、也不在 `deliveredMails` 里(还没投递)
⇒ **静默消失**。

## ★ 与 4175c0b 修的不是同一件事

  那条治的是「**投过之后**没标已读 ⇒ 重投回声」
  这条是「**投递之前**就被别的路径标已读 ⇒ 投不出去」
两条方向相反,却落到同一条 SQL 上。

## 放大条件

worker 池越小越容易撞(`AGENTMAIL_MAX_WORKERS` 默认 3,
实测同一时刻 4 个 worker 在跑)。池满时信在队列里等,
**等待窗口正是被 read_inbox 扫掉的窗口**。

## 修法方向(未实施,等人定)

`GetInbox` 侧只标「本会话已投递」的信;或桥侧投递时先落 `deliveredMails`
再让模型读得到 —— **后者与 4175c0b 的「投递即标已读」直接冲突,不能两边都要**。
This commit is contained in:
2026-09-28 10:28:21 +08:00
parent fed108a91e
commit fffe6bf63a

View File

@ -274,6 +274,14 @@
"due": "有人重跑一次客户端构建(产出新的 `BUILD_INFO.json`)时。★ 到期动作**不是**改 `BUILD_INFO.json` 里的 `gitRev`/`srcHash` —— 判据自己的报错文案就写明那样做等于把它废掉;也不是把它加进跳过名单;唯一正确的动作是重跑构建。",
"where": "`client/electron/test/build-stamp.test.mjs:93`(`★ 产物必须自报来源:BUILD_INFO 精确比对`);产物记录在 `client/electron/BUILD_INFO.json`(★ **未被 git 跟踪**);实测 2026-09-28 产物记 `87c55ac`、HEAD 已走到 `f1c74fc`",
"note": "★★ 2026-09-28 登记(pi 实测)。\n\n## 形状:**一条判据长期红,且与被测代码无关**\n\n`build-stamp` 断言产物自报来源必须精确等于当前 HEAD。实测:\n产物记 `87c55ac`,HEAD 当时是 `359cb43`、随后因本轮工作走到 `f1c74fc`\n⇒ **无论谁提交什么,这条判据都不会自己变绿** —— 它只能被一次重构建救。\n\n## 为什么值得单独记一笔:它是**共享工作树债的直接产物**\n\n同一个 `HEAD` 上(`shared-workspace-unserialized-deploy`):产物落后于源码,\n因为多个会话连续提交而**没人重跑构建**。`d3a7873` 那笔 HIGH 记的正是\n「判据说干净而构建物不干净」;本条是它的**日常形态** —— 不危险,\n但它会长期挂在套件的红名单上,让「红了」这件事开始钝化。\n\n★ 真正的危害不是这条判据本身,是**它会训练人忽略红色**:\n套件汇总里 `build-stamp` 与另两条真缺陷并列显示,人一旦习惯\n「哦又是 build-stamp」,就会把同一行里的真缺陷一起放过。\n这与本仓反复消的「看不到 ⇒ 绿」是同一族,方向相反:**看到了 ⇒ 当没看见**。\n\n## 已做的核对(避免把别人的问题算成新的)\n\n· 提交 `f1c74fc` 之前就在**干净 HEAD** 上复现过 ⇒ 不是本轮引入;\n· `BUILD_INFO.json` 经 `git ls-files` 确认**未被跟踪** ⇒ 缺的那次构建\n 没人提交过,不是被谁回滚;\n· 判据报错文案本身已警告「别去改 gitRev/srcHash 了事」 —— 本条认同,\n 并把那个正确修法(重跑构建)写进 `due`。\n\n## 未做\n\n没有触发构建、没有改 `BUILD_INFO.json`、也没有把它加进任何跳过名单。\n构建是部署动作,而本工作区正在被多个会话并发提交(已有两个\n`server/internal/repo/zz_*_probe_test.go` 不属于本会话)—— 由人决定何时构建。"
},
{
"id": "read-inbox-swallows-inflight-mail",
"count": 1,
"due": "read_inbox 的「读完自动标已读」与 SSE 投递解耦之后(两者之间要有互斥或收窄)",
"where": "server/internal/handler/mail.go(GetInbox 标已读)+ plugins/*/lib 的 read_inbox;桥侧无判据",
"kind": "bug",
"note": "2026-09-28 压测后重建网关时**实测复现 3/3**(不是推演)。\n\n现象:给 pi 发一封邮件 ⇒ 库里 `status` 变 `read`(投递后 **10~13ms**),\n而桥的日志里那封 mail_id **一次都没出现** ⇒ 没起会话、没人回信。\n 读数:`mail_reads.read_at - mails.created_at = 0.013s`;\n 桥日志提及次数 = 0/3。发件人视角 = 「信发出去了,然后没声了」。\n\n机制:两件事**各自都对**,但合起来丢信。\n ① `4175c0b` 把「投递即标已读」做成一个动作(`markDelivered`),\n 为的是治「桥重启 → 重投 → 回声」;`catchUp` 按 `status=unread` 捞。\n ② `read_inbox` 工具**读完自动标已读**(README 明写),\n 而它按 inbox 取信,**不区分「这封是不是正在等派发」**。\n ⇒ 任何一次 `read_inbox`(无论模型为什么调)都会把**当时还在 unread 队列里**\n 的信全部连带标掉,其中包含**这一轮刚投递、还没轮到起 worker** 的那封。\n 它随后既不在 unread 里(捞不到)、也不在 deliveredMails 里(还没投递)\n ⇒ **静默消失**。\n\n★★ 与 `4175c0b` 修的不是同一件事,别以为已修:\n 那条治的是「**投过之后**没标已读 ⇒ 重投回声」;\n 这条是「**投递之前**就被别的路径标已读 ⇒ 投不出去」。\n 两条的方向相反,落到同一条 SQL 上。\n\n★ 放大条件:worker 池越小越容易撞(`AGENTMAIL_MAX_WORKERS` 默认 3,\n 实测同一时刻有 4 个 worker 在跑)。池满时信在队列里等,\n 等待窗口正是被 `read_inbox` 扫掉的窗口。\n\n修法方向(未实施,等人定):`GetInbox` 侧只标「**本会话已投递**」的信,\n 或桥侧投递时先落 `deliveredMails` 再让模型读得到 —— \n 后者与 `4175c0b` 的「投递即标已读」直接冲突,**不能两边都要**。"
}
]
}