Files
MailUI4Agents/docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md
JianFeeeee f236ef44ca 更正: 我自己的两条风险是**测量方法**造成的假象 —— 校验会原地改写入参,换个对象就消失(§17)
pi 指出 §10 那个坑还有下半段。我据此重测了自己的两条结论,**两条都推翻**:

1. §5「伪造 id 会污染严格校验」—— 假象。§5 引用的 seq gap 来自
   「先 transformed(在**原对象**上,验证器已把 dt 写回)→ 再拿同一批对象跑 current」。
   深拷贝重测:strict-first OK、transformed(clone)+strict(clone) OK;
   同进程 3 次 × 3 个 OS 进程全 OK ⇒ 不是不确定性,是入参被上一次校验改了。
   **从没修过的 37 个对照组会话 strict 也全 OK** ⇒ 与伪造 id 无关。

2. §4/§8「补 user/message 会引入 seq gap」—— 同样假象。深拷贝重测:40/40
   补与不补都 strict-ok。且迁移器**本来就会**给 user/message 合成 id
   (原始 v0 迁出 386 条带 id / 0 条缺 id)⇒ pi 上封担心的
   两个口径各要一份不同东西并不成立。

结论比原先更好:修后会话 **transformed 与 current 两档都可读**,
伪造 id 换成真 randomUUID() 结论不变(id 取值形式不影响)。

真正该记的是:**校验会改写入参 ⇒ 校验与落盘必须分对象**。它已制造
§10(dry-run 40 vs apply 3)与本节(假 seq gap)两次假结论。

另独立复核了数据完整性(40/40):.bak 齐全、事件数一致(80001=80001)、
剥掉注入的 id/role 后逐行语义差 0、87 个注入 id 全唯一。
2026-09-19 12:15:45 +08:00

359 lines
18 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"`(**深拷贝**) | 修后 **OK(40/40)** —— 见 §17 的更正 |
| 生产档 | `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`)。
→ ⚠️ **后半句已作废**:见 §17。「补 user/message 会引入 seq gap」同样是
**同一批对象被验证器污染**造成的假象;深拷贝重测后,补与不补都 strict-ok。
32 个 `subagent/descriptor version 2` 是**另一个**问题,别混进同一个 repair。
## 5. ~~必须记一笔:`id` 是伪造的,会污染未来的严格校验~~(**已作废,见 §17**)
> ⚠️ **本节结论是错的,2026-09-19 下午用「深拷贝」重测后推翻。**
> 保留原文只为留痕:它正是 §10/§17 那个「校验会原地改写入参」陷阱的第一个受害者。
> 正确结论:伪造的 `id` **不会**污染严格校验,修后会话 **strict 也读得出来**。
~~补进去的 `id` 是**新造**的(`recovered-splice-<seq>-<n>`),原数据里没有。~~
~~证据:修后在**同样字节**上只换校验档,结果不同——~~
```
[recovered-splice / transformed] OK
[recovered-splice / current] FAILED: released Session row 35 has seq gap (expected 99, got 72)
```
⇒ ~~修出来的会话是**生产档可读、严格档不可读**。~~
**这段"证据"的真实成因**:先跑了一次 `transformed`(在**原对象**上),
验证器把补全的 `dt` 写回了那批对象;随后拿**同一批已被污染的对象**跑 `current`,
于是失败。换一批干净对象,两个档位都 OK。**是测量方法错,不是数据错。**
**这意味着: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`。~~
→ **已作废(§17)**:深拷贝重测后补 `user/message` **无害**(40/40 strict-ok);
当时看到 `seq gap` 是「校验污染了入参」的假象。
3. ~~**修出来的会话生产档可读、严格档不可读**~~ → **已作废(§17)**:
修后会话**两档都可读**(40/40)。
---
# 续篇(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-* 会话 | 最终验收(`--prefix mail-`) | **43/43 可读** |
反例留档:`~/.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 会因此给出
相反结论。
## 16. 度量口径的一个坑:`--only` 是子串匹配
第一版验收用 `--only mail-`,得到「47 可读 / 3 不可读」。那 3 个**不是邮件会话**——
它们的目录名是普通 UUID,只是父目录是 `--home-program-agentmail--`,
而 `agentmail-` 里含子串 `mail-`,被误匹配进来。它们的错因是另一个独立缺陷
(`subagent/descriptor 0 uses unsupported descriptor version 2`)。
换成严格前缀 `--prefix mail-` 后的真实数字:
```
候选: 43 可读: 43 不可读: 0
```
⇒ **「真 mail-* 会话 43/43 可读,0 不可读」**。
度量口径本身也会制造假结论,所以脚本现在两个开关都提供:
`--only`(子串,跑路径片段)与 `--prefix`(目录名严格前缀,点名某类会话时用后者)。
## 17. 更正 §4 与 §5:那两条"风险"是**测量方法**造成的,不是数据性质
**触发**:pi 在回信里指出 §10 那个坑还有下半段 ——
「同一个对象既校验又落盘是个静默陷阱」。我据此把**自己的**两条结论重测了一遍,
**两条都推翻**。留档如下,因为它们恰好演示了同一个陷阱的第二次咬人。
### 17.1 更正一:伪造的 `id` 不会污染严格校验(§5 作废)
§5 的证据是「同样字节只换校验档,结果不同」。真实成因:
| 步骤 | 做法 | 结果 |
|---|---|---|
| ① | `transformed` 跑在**原对象**上 | OK(但验证器**已把 `dt` 写回原对象**) |
| ② | 拿**同一批被污染的对象**跑 `current` | FAIL: `seq gap` ← §5 引用的就是这条 |
正确测法(每次都给 `structuredClone`):
```
A) strict-first, fresh clone : OK
B) transformed on originals, then strict
on SAME objects : FAIL: row 35 seq gap ← 复现出那个假象
C) transformed(clone) then strict(clone) : transformed=OK strict=OK
```
并且**不换档位、只重跑**也是稳定的(同进程 3 次 × 3 个 OS 进程,strict 全 OK):
⇒ 这不是"不确定性",就是**入参被上一次校验改过**。
批量重测(深拷贝)**全部邮件会话**:
```
mailInjected strict-ok 40 strict-FAIL 0 (n=40) <- 修过的
otherUntouched strict-ok 37 strict-FAIL 0 (n=37) <- 从没修过的对照组
```
**从没修过的会话 strict 也全 OK** ⇒ 「strict 失败」与"伪造 id"无关。
### 17.2 更正二:补 `user/message` **无害**(§4/§8 那条作废)
同样用深拷贝重测 §4 那句「补 `user/message` 会把可读会话变成 `seq gap`」:
```
mail-* v0 需修复的会话: 40
spliced-only -> strict-ok: 40/40
spliced+both -> strict-ok: 40/40 <- 补 user/message 一样通过
```
更关键的是**迁移器本来就会给 `user/message` 合成 id**:
对**修复前的原始 v0**(25MB 那份)跑迁移,出来的 `user/message` 与 `inserted`
**带 id 386 条 / 缺 id 0 条**。
⇒ 「严格读路径要 id」与「别补 spliced」**并不冲突** —— id 是迁移器给的,
需要我们补的只有 `inserted[]`。pi 上封担心的"两个口径各要一份不同东西"**并不成立**。
### 17.3 结论(覆盖 §4/§5/§8)
1. 修复效果比原先说的**更好**:修后会话 **`transformed` 与 `current` 两档都可读**,
不只是"生产档可读";
2. 伪造 `id`(`recovered-splice-*`)**没有**留下严格校验隐患;
实测换成真 `randomUUID()` 或官方风格 id,两档同样 OK —— **id 的取值形式不影响结论**;
3. 真正该记的教训不是"数据有隐患",而是**"校验会改写入参,所以校验与落盘必须分对象"**——
它已经分别制造过 §10(dry-run 40 vs apply 3)和本节(假 `seq gap`)两次假结论。
4. 仍然成立的独立缺陷:`subagent/descriptor ... version 2`(29 个非邮件会话),与本问题无关。