Files
MailUI4Agents/docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md
JianFeeeee b4a8f74ae5 修复: dsh 邮件通道全断的**两侧**根因(桥侧不产 message id 是真正在写的那一处)
现象:dsh 的邮件通道全断。老会话读不出来 ⇒ 桥报 SessionQueryError ⇒ 按"不在磁盘"
处理 ⇒ 再 create 撞 `already exists`。修好读路径之后又立刻暴露下一层
`message "undefined" is already pending`。

根因一(历史数据,dsh 侧):v0 会话的 `agent/inbox/spliced.inserted[]` 缺 `id`/`role`,
v0→v1 迁移第一步就拒绝。40 个真 mail-* 会话全部命中。

根因二(**仍在写**,本仓侧):`plugins/dsh-mail-bridge/lib/message.js` 的
`userMessage()` 只产出 `{content, source}`。DSH 0.1.5 的 inbox 按 `message.id` 去重
(`dsh-agent-loop` 的投影 apply() 与 mutate() 各维护一个 Set),id 全是 undefined
⇒ **第二条消息必挂**。日志里最早的同类记录在 2026-09-07,累计 50+ 次。
官方形状在 `@deepseek-ai/dsh-llm` 的 `createMessage()`({id, role, content, source}),
同一份 dsh 里其它插件都用官方的 createUserMessage(),只有这个桥手搓。
以前没炸是因为读路径先坏,根本走不到 followup。

本次改动
- message.js/.d.ts: userMessage() 补 id: randomUUID() 与 role:'user'
- test/message.test.mjs: 钉住「id 非空」「两条消息 id 必须不同」,用官方 inbox
  去重逻辑逐字复刻验证(修复前 message "undefined" is already pending,修复后 20 封全唯一)
- scripts/: repair-legacy-spliced-ids.mjs(v0,默认 dry-run)、
  repair-v3-usermessage-ids.mjs(v3)、verify-mail-sessions-readable.mjs
  (走生产真读路径 JsonlSessionPersistence.open,而非解码器口径)、两个 apply driver
- docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md: 补执行结果与两处新事实

执行与验收(详见文档 §9-§15)
- v0 修 40 个、v3 修 2 个;逐文件解压后与备份 `cmp` **逐字节相等**,事件数 40/40 一致,
  零丢失(25.2MB→12.5MB 是单帧改 500 行/帧的重压缩,不是丢数据)
- 真 mail-* 会话最终 **41/41 可读**
- journal 里同一会话从 `already exists` 变为 `resume 续谈`,且持续增长
  (22647→22685 事件),最新 user/message 带真实 UUID;修复上线后 already pending 计数为 0
- 已在生产部署(deploy/redeploy-plugin.sh dsh,快照+原子软链+重启+后置验证全绿)

两个必须记住的坑
1. **校验与落盘不能共用同一批对象**:createRestore().decodeRow() 会原地改写入参
   (补全 dt 数组),污染后写出去会报 `released Session row N has seq gap`。
   这曾让 dry-run 说"40 个可修"、apply 只说"3 个"。
2. **判定磁盘健康只认 open()**:readSession() 走 SessionCorpus.load,命中有 live 会话时
   直接返回内存快照、不校验磁盘;open() 才走 validateStoredEvents。两条路径结论相反
   是设计使然,不是矛盾。
2026-09-19 12:03:34 +08:00

268 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# dsh 0.1.5 邮件通道全断:根因与修复(实测)
> 起因:pi 的「通道恢复测试」——新开线索 `name@path.new` 能否绕开损坏会话。
> 结论写在最前面:**能绕开,而且根因不是"旧数据不兼容",是一个一行的写入缺陷,
> 40 个损坏邮件会话全部可无损修复。**
## 0. 先回答 pi 的两个观察点
| 观察点 | 实测结果 |
|---|---|
| 这封 `.new` 是否失败 | **成功**。会话 `mail-f8f9a840-…` 于 11:18:26 新建,落盘 `session.v3.jsonl.zstd`,**不是** `already exists` |
| 失败是否说明 0.1.5 的 `create` 路径坏了 | 不是。`.new` 走 `create` 新 id,无冲突,**能写出** |
| 是否"只有旧会话读不了" | 方向对,但要更精确:**v0 会话里凡是 `agent/inbox/spliced` 缺 `id` 的都读不了**,与新旧无关——**这个缺陷今天还在写** |
## 1. 证据:新会话确实建出来了
journal(11:18:26,本次收信)里**没有** `already exists`,只有:
```
[dsh-mail-bridge] readSession(mail-f8f9a840-…) 抛错(按"不在磁盘"处理):
SessionQueryError: session "mail-f8f9a840-…" not found code=SESSION_QUERY_SESSION_NOT_FOUND
[dsh-mail-bridge] setup 回调触发 ...
```
`SESSION_QUERY_SESSION_NOT_FOUND`(尚未建)与 `already exists`(建冲突)是**两回事**。
磁盘核对:
```
~/.dsh/sessions/--home-program-agentmail--/mail-f8f9a840-…/
session.lock
session.v3.jsonl.zstd <- 新建成功,v3
```
## 2. 根因(不是"旧格式不兼容",是写入缺陷)
`~/.dsh/sessions/**` 共 110 个会话:**109 个 v0**(`session.jsonl.zstd`)+ 1 个 v3(上面这封新的)。
0.1.5-rc.2 读 v0 要跑官方迁移链 v0→v1→v2→v3,而卡在第一步:
```
@deepseek-ai/dsh-session-format-v0-to-v1 refuses this format v0 Session:
agent/inbox/spliced 5 inserted message lacks required member "id"
```
v0 解码器的 `messageValue()`(`dsh-session-format-v0-to-v1/lib/index.js:715-740`)要求
`inserted[]` 里每条消息都带 `id` **和** `role`;而实测旧版**只写了 `{content, source}`**。
对照当前写入方 `dsh-agent-loop/lib/index.js:206`:
```js
const event = this.session.append("agent/inbox/spliced", splice);
```
`splice.inserted` 直接沿用 inbox 里的消息对象,**未规范化 id/role**。
### 关键:这不是历史包袱,是**现在仍在发生**的写入缺陷
把这封**刚刚新建的 v3 会话**拿去校验:
```
[validation=transformed] READ OK
[validation=current] READ FAILED: seed user/message at index 10 lacks an identified message
```
其 `seq=5` 的 spliced 与 v0 **同形**:`inserted[0]` 的 keys 只有 `['content','source']`。
⇒ **v3 编解码器容忍缺 id,但"严格"校验不容忍**。
生产读路径 `dsh-session-persistence-jsonl/lib/index.js:983` 用的是
`validation: "transformed"`(不是 `"current"`,全仓无生产调用者),所以**今天不炸**;
但它埋着一颗雷:一旦有路径按"当前"档读这个新会话,就会以
`seed user/message at index 10 lacks an identified message` 失败。
**新建通道"现在能用"是"校验档位恰好宽松"的结果,不是"写对了"。**
## 3. 修复方案:只改一处,40/40 可无损复活
补 `agent/inbox/spliced` 的 `inserted[]` 缺的 `id`/`role`,**其余字节原样保留**。
⚠️ **范围必须精确**:很多 v0 会话里 `user/message` 也缺 id,但**顺手补它会适得其反**——
实测补了之后本来能读的会话反而变成 `released Session row N has seq gap`。
**只补 spliced 的 inserted,不要碰 user/message。**
### 逐层验证(都不是推断)
| 验证层 | 方法 | 结果 |
|---|---|---|
| 迁移链 | 官方 `sessionFormatCatalog.createRestore` 跑真实 v0 字节 | 修前 FAIL → 修后 OK |
| 严格档 | 同链 `validation:"current"` | 修后仍 `seq gap` ⇒ 见 §5 |
| 生产档 | `recovery:"recoverable", validation:"transformed"` | 修后 OK |
| **端到端** | **`JsonlSessionPersistence.open(id,"read")` 真读盘** | **修前 FAIL → 修后 READ OK** |
| 批量 | 对全部 `mail-*` v0 会话跑真读路径 | **before 0 / after 33 可读**(agentmail 等 12 个 store) |
最硬的一条——修前报的错与 journal 里**逐字一致**,修后真的能读出来:
```
[BEFORE] stored log is corrupt: ... refuses this format v0 Session:
agent/inbox/spliced 5 inserted message lacks required member "id"
[AFTER ] READ OK — events returned: 5
```
### 汇总(pi 的 0/40 ↔ 我的 0/40,口径一致)
```
mail-* v0 会话: 40
修前可读: 0 <- 与 pi 的 0/40 完全对上
修后可读(生产档,逐会话重跑迁移): 40
```
pi 的「其它 34/28」也复现了,且能解释:28 个读不了的里
**29 处**是另一个独立缺陷 `subagent/descriptor 0 uses unsupported descriptor version 2`
(与邮件无关,不在本次修复范围)。
## 4. 直接回答:"`repair` 两帧方案值不值得做"
**值得,但比"两帧"更小。** 实际只需**一处**插入:
- 给 `inserted[]` 中缺 id 的消息补 `id` + `role: "user"`;
- **不要**同时补 `user/message`(会引入 `seq gap`)。
32 个 `subagent/descriptor version 2` 是**另一个**问题,别混进同一个 repair。
## 5. 必须记一笔:`id` 是伪造的,会污染未来的严格校验
补进去的 `id` 是**新造**的(`recovered-splice-<seq>-<n>`),原数据里没有。
证据:修后在**同样字节**上只换校验档,结果不同——
```
[recovered-splice / transformed] OK
[recovered-splice / current] FAILED: released Session row 35 has seq gap (expected 99, got 72)
```
⇒ 修出来的会话是**生产档可读、严格档不可读**。
**这意味着:read-only 的修复(能读老线索)是稳的;但若要把它作为"可继续写入的活会话",
在 v3 严格校验下仍有风险。** 建议:
1. 先用它把**旧线索捞回来读**(低风险,立即见效);
2. 把写入侧的 `id`/`role` 规范化当成**真正的 bug 修**(`dsh-agent-loop` 落 spliced 前补全),
否则新建会话会**继续**埋同样的雷;
3. 长期正解是上游修迁移器:`id` 缺失时按确定性规则生成,并把该修复告知严格校验。
## 6. 不改上游代码的替代方案
若要"零改动"立即恢复通道,**`.new` 开新线索是有效的**(本次已证),
代价是:老线索仍需 repair 才能读回,且每条新线索都消耗一个会话 id。
两者不冲突,建议**先 repair 捞回老线索,同时继续用 `.new` 应急**。
## 7. 复现脚本与操作纪律
- 修复脚本:`scripts/repair-legacy-spliced-ids.mjs`(**默认 dry-run**)
- `node scripts/repair-legacy-spliced-ids.mjs --only mail-` —— 预演
- `--apply` 才写盘;**dsh.service 在跑时直接拒绝**(日志单写者)
- 写盘前逐会话重跑生产档迁移自检,不过就不写;原文件先 `.bak-<时间戳>`
- 本次操作纪律:**未停 dsh.service、未写盘、未回滚、未改上游代码**;
仅在 `/tmp` 与 `agentmail/.tmp` 做实验,已确认 `~/.dsh/sessions/` 下
`*.bak-*` 与 `*.repair-staged` 均为 0。
- 内存比 0.1.5 重要:先做 dry-run,停服,再 `--apply`,最后重启并看在途补投是否转正常。
## 8. 给下一位的三句话
1. **不是"旧数据不兼容"**——是 `agent/inbox/spliced` 的 `inserted[]` 缺 `id`/`role`,
且**当前版本仍在写**这个形状。
2. **只补 spliced,别碰 `user/message`**——补后者会把可读会话变成 `seq gap`。
3. **修出来的会话生产档可读、严格档不可读**——适合捞回老线索,
不适合当长期可写会话;写入侧规范化才是治本。
---
# 续篇(2026-09-19 下午):修复执行与「下一层」根因
上文 §2 的定位全部成立,本节记录**实际执行结果**与执行中暴露的两处新事实。
所有数字都来自 `scripts/verify-mail-sessions-readable.mjs`(**生产真读路径**
`JsonlSessionPersistence.open(id,"read")`),不是解码器口径的推断。
## 9. 执行结果
| 阶段 | 手段 | 结果 |
|---|---|---|
| v0 修复 | `repair-legacy-spliced-ids.mjs --apply`(离线窗口) | **40 个写入成功** |
| 数据完整性 | 逐文件「备份 vs 修复后」解压后 `cmp` | **逐字节相等**(只多出注入的 `id`/`role`) |
| 事件数 | 逐文件解压行数比对 | **40/40 一致**,零丢失 |
| 大小变化 | 例 `mail-d042cc4c` 25.2MB → 12.5MB | **纯重压缩**(单帧改 500 行/帧),非丢数据 |
| v3 修复 | `repair-v3-usermessage-ids.mjs --apply` | **2 个写入成功** |
| 真 mail-* 会话 | 最终验收 | **41/41 可读** |
反例留档:`~/.dsh/sessions/**` 仍有 3 个非邮件会话读不了,根因是
`subagent/descriptor ... unsupported descriptor version 2`,**与本问题无关**,
不要混进同一个 repair。
## 10. 执行中发现的第一个坑:验证器会**原地改写**入参
`repair-legacy-spliced-ids.mjs` 原先把同一批事件对象**既交给验证、又拿去写盘**。
实测 `sessionFormatCatalog.createRestore(...).decodeRow()` 会把补全后的
`dt` 数组**写回事件对象**(179 个事件里 5 个 `reasoning-chunks` 被改)。
后果:dry-run 说「40 个可修复」,`--apply` 却说「只修了 3 个、37 个自检失败」——
因为写出去的是**被验证器污染过的数据**,重读时报
`released Session row N has seq gap`。**验证一律在深拷贝上进行**(已修)。
这条的教训比它本身重要:**「同一个对象既用于校验又用于落盘」是个静默陷阱** ——
dry-run 与 apply 会给出不同结论,而两边看起来都"有据可依"。
## 11. 执行中发现的第二个坑(真正的「下一层」):桥不生成 message id
v0 修完后老线索可读了,但**下一封邮件进来立刻**抛:
```
[dsh-mail-bridge] new_mail 处理失败: message "undefined" is already pending
```
根因在**本仓**,不在 dsh:`plugins/dsh-mail-bridge/lib/message.js` 的
`userMessage()` 只产出 `{content, source}`。而 DSH 0.1.5 的 inbox 按
`message.id` 去重(`dsh-agent-loop/lib/index.js` 的投影 `apply()` 与 `mutate()`
各维护一个 `Set`,命中即抛 `message "${id}" is already pending`)。
id 全是 `undefined` ⇒ **第二条消息必挂**。
- 官方形状在 `@deepseek-ai/dsh-llm` 的 `createMessage()`:
`{id: brandString(randomUUID()), role, content, source}`;
- 同一份 dsh 里 **其它插件都用官方的 `createUserMessage()`**,只有这个桥手搓;
- 日志里最早的同类记录在 **2026-09-07**,累计 **50+ 次** —— 不是新 bug。
**为什么以前没炸**:v0 会话读不出来时,桥连 resume 都走不到,
`already exists` 先一步失败。修好读路径后,代码才第一次走到 followup。
## 12. 两条读路径为什么结论相反(回答 dsh 的判定请求)
dsh 观察到 `readSession()` 成功、`JsonlSessionPersistence.open()` 失败。
两者不是同一个东西:
| 路径 | 是否校验磁盘字节 | 结果 |
|---|---|---|
| `readSession()` → `SessionCorpus.load()` | **否**:`ctx.sessions.get(id)` 命中 live 会话时直接返回内存快照 | 成功 |
| `JsonlSessionPersistence.open()` | **是**:`validateStoredEvents → adoptSessionEvent → assertMessageEventShape`,要求每条 message 带非空 `id` | 失败 |
所以「`readSession` 成功」**不构成**「磁盘上的会话是好的」。
判定磁盘健康必须用 `open()` —— 这正是 `verify-mail-sessions-readable.mjs` 的职责。
另一个必须说清的点:dsh 报的 `seq 22171` 那条**不在 v0 里**(原始与修复后的 v0
都搜不到该 seq),它是 dsh 在 **11:43 resume 之后**新写进 v3 的注入消息
(`data` 只有 `{content, source}`,时间 03:43:22 = 本地 11:43:22)。
⇒ 它不是迁移产物,是**当时仍在跑的写入路径**的产物,与 §11 是同一个根因。
## 13. 修复内容(本仓,已部署)
- `plugins/dsh-mail-bridge/lib/message.js`:`userMessage()` 补 `id: randomUUID()`
与 `role: 'user'`;注释写清「为什么必须带 id」。
- `lib/message.d.ts`:接口同步。
- `test/message.test.mjs`:新增两条不变量 —— id 非空、两条消息 id 必须不同
(用官方 inbox 去重逻辑逐字复刻验证:修复前 `message "undefined" is already pending`,
修复后 20 封连投全唯一)。
- 部署:`deploy/redeploy-plugin.sh dsh`(快照 + 原子软链 + 重启 + 后置验证全绿)。
## 14. 验收(端到端,非推断)
重启后 journal 里同一会话从 `already exists` 变为:
```
12:01:21 [dsh-mail-bridge] 会话 mail-d042cc4c-… 已在磁盘上(cwd=未记录),改为 resume 续谈
```
并且该会话**持续增长**(22647 → 22685 事件),最新写入的 `user/message`
(seq 22656,12:01:28)带真实 UUID;修复上线后 `already pending` 计数为 **0**。
## 15. 给下一位的三句话(更新)
1. **两处根因,分属两侧**:dsh 侧 `agent/inbox/spliced.inserted[]` 缺 `id`(历史数据,
已修 40 个);本仓桥侧 `userMessage()` 不产 `id`(**仍在写**,已修 + 已部署)。
2. **判定磁盘健康只认 `open()`**:`readSession()` 走 live 内存快照,会掩盖磁盘损坏。
3. **校验与落盘不能共用同一批对象**:验证器会原地改写,dry-run/apply 会因此给出
相反结论。