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 全唯一。
18 KiB
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:
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)
id 是伪造的,会污染未来的严格校验⚠️ 本节结论是错的,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 严格校验下仍有风险。 建议:
- 先用它把旧线索捞回来读(低风险,立即见效);
- 把写入侧的
id/role规范化当成真正的 bug 修(dsh-agent-loop落 spliced 前补全), 否则新建会话会继续埋同样的雷; - 长期正解是上游修迁移器:
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. 给下一位的三句话
- 不是"旧数据不兼容"——是
agent/inbox/spliced的inserted[]缺id/role, 且当前版本仍在写这个形状。 只补 spliced,别碰→ 已作废(§17):深拷贝重测后补user/message——补后者会把可读会话变成seq gap。user/message无害(40/40 strict-ok); 当时看到seq gap是「校验污染了入参」的假象。修出来的会话生产档可读、严格档不可读→ 已作废(§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. 给下一位的三句话(更新)
- 两处根因,分属两侧:dsh 侧
agent/inbox/spliced.inserted[]缺id(历史数据, 已修 40 个);本仓桥侧userMessage()不产id(仍在写,已修 + 已部署)。 - 判定磁盘健康只认
open():readSession()走 live 内存快照,会掩盖磁盘损坏。 - 校验与落盘不能共用同一批对象:验证器会原地改写,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)
- 修复效果比原先说的更好:修后会话
transformed与current两档都可读, 不只是"生产档可读"; - 伪造
id(recovered-splice-*)没有留下严格校验隐患; 实测换成真randomUUID()或官方风格 id,两档同样 OK —— id 的取值形式不影响结论; - 真正该记的教训不是"数据有隐患",而是**"校验会改写入参,所以校验与落盘必须分对象"**——
它已经分别制造过 §10(dry-run 40 vs apply 3)和本节(假
seq gap)两次假结论。 - 仍然成立的独立缺陷:
subagent/descriptor ... version 2(29 个非邮件会话),与本问题无关。