Files
MailUI4Agents/docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md
JianFeeeee fe0cfe626c 验收口径: --prefix 严格前缀 —— 修正 --only mail- 误匹配 agentmail- 造成的 3 个假失败
第一版验收用 --only mail- 得到「47 可读 / 3 不可读」,但那 3 个根本不是邮件会话:
目录名是普通 UUID,只是父目录 --home-program-agentmail-- 里含子串 mail-。
它们的错因是另一个独立缺陷(subagent/descriptor v2)。

换严格前缀后的真实数字:真 mail-* 会话 43/43 可读,0 不可读。
脚本现在同时提供 --only(子串)与 --prefix(目录名严格前缀)。
2026-09-19 12:04:43 +08:00

285 lines
14 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-* 会话 | 最终验收(`--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`(目录名严格前缀,点名某类会话时用后者)。