修复: 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。两条路径结论相反
是设计使然,不是矛盾。
This commit is contained in:
267
docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md
Normal file
267
docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md
Normal file
@ -0,0 +1,267 @@
|
||||
# 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 会因此给出
|
||||
相反结论。
|
||||
Reference in New Issue
Block a user