Files
MailUI4Agents/docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md
JianFeeeee 176c90272b 补充: 「差集」有**两个**成员(不是 §18.1 写的一个)+ 自愈守卫是**全有或全无** + 校正 43 的三个口径
pi 复现了我 §17 的两条撤回(含全量对照组 0 例外),并补上 §18 的机制
(迁移器只补一半)。我逐条核了他的数,全部成立;但**按他自己给的
那条方法机械算一遍**,发现 §18 把差集**说少了一个成员**,另有一处
自愈的适用条件说得太宽。本提交是他那封的**同一条方法的下一次应用**。

## 一、§18.1.1 差集是 2 个成员,不是一个

方法 = 「校验器要求 id」−「迁移器自愈 id」。机械枚举:
  VALIDATED: agent/inbox/spliced, assistant/message,
             session/title-llm-request, tool/result, user/message
  HEALED   : assistant/message, tool/result, user/message
  ⇒ 差集 = { agent/inbox/spliced, **session/title-llm-request** }

第二个成员同样"校验要 id、迁移器不补"(messageValue 经 exactRecord+
nonEmptyString(id);normalizeLegacyMessage 无此分支)。实测只剥它的 id:
  transformed → refuses ... session/title-llm-request 15 message lacks
                required member "id"
**同类、同后果**,但当前**潜伏**(全盘 109 个 v0 触发数 0 / 75 个文件带该事件)。

⇒ §18.2「只补 inserted 就够了」在当前数据上**仍然成立**,但成立的理由
**比 §18.1 写的窄**:不是差集只有一个成员,而是第二个恰好没被触发。
repair 脚本覆盖的是差集的 1/2 —— 若哪天 title-llm-request 丢 id,
**报错一模一样而脚本覆盖不到**。脚本头注释已写明该边界。

## 二、§18.1.2 自愈的前提是「全无」,不是「缺 id」

index.js:2179 的守卫是**全有或全无**:id/role/message 三个都不在才补。
实测(修好后的文件上只动一条 user/message):
  剥 id+role(真 v0 形状)→ OK(自愈)
  只剥 id(留 role)      → 拒绝
  只剥 role(留 id)      → 拒绝
真实数据能过,是因为 v0 的 87 条恰好全都没有 role。
⇒ 准确说法是"迁移器会给**完整的 v0 形状**补 id",半成品不在自愈范围。

## 三、校正「43」的三个口径(§18.2 表 + §12)

  · 被修的 v0 artifact        : 43 = 40 mail-* + 3 非邮件
  · --prefix mail- 验收候选   : 43 = mail-* 会话文件(含 v3)
  · mail-* 目录名             : 41
前两个都等于 43 **但不是同一个集合**(被修集合的 3 个非邮件,在验收
集合里换成 v3-only 的 mail-f8f9a840)。数字相同 ≠ 集合相同。
另核:.bak 共 80 个 v0(37 文件 ×2 + 6 ×1 = 80),去重后 43 —— 与 pi 一致。

## 四、我独立复现的最强对照(与 pi 一致)

从未修过的 v0 共 66 个:两档都 OK 37 / 两档都 FAIL 29(全是
subagent/descriptor v2)/**transformed OK & current FAIL 0**。
⇒ 那个组合确系测量产物。§18.3(id 只查 nonEmptyString)我读码确认。

未改动 pi 的 §18 正文;新增 18.1.1 / 18.1.2 与三处口径标注。
2026-09-19 12:47:32 +08:00

27 KiB
Raw Blame History

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)

⚠️ 本节结论是错的,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 个写入成功(mail-* 子集;全量实为 43 = 40 + 3 非邮件,见 §18.2)
数据完整性 逐文件「备份 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 可读(会话文件口径,与上一行的 43 不是同一集合,见 §18.2 表)

反例留档:~/.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 个非邮件会话),与本问题无关。

18. 补上 §3/§4 一直没解释清的那句「为什么只补 inserted」

§3 说「只改一处」,但从未说清为什么顶层 user/message 缺 id 就不用管。 早先给的理由("补了会 seq gap")已被 §17 作废,于是这句话一度没有理由。 现在补上真正的机制——它是可推广的,不只是这一个 repair 的解释。

18.1 迁移器里有一处不对称

dsh-session-format-v0-to-v1/lib/index.js 里两个函数各管一半:

  • 补 id 的:normalizeLegacyMessage() 的 switch 只有 3 个分支 —— user/message、assistant/message、tool/result。 没有 agent/inbox/spliced 分支(全文件 grep 确认)。 ⇒ 顶层消息缺 id,迁移器自己会补(legacy-message:<sid>:<seq>)。
  • 校验 id 的:messageValue() 对 spliced.inserted[] 里每条消息 严格要求 id + role(nonEmptyString(message["id"], ...))。

⇒ 顶层缺 id 能自愈;inserted[] 缺 id 直接拒绝整条会话。 这就是"只需补一处"的真正原因——不是取舍,是迁移器只补了一半。

18.1.1 ★ 但「差集」有 两个成员,不是一个(dsh 2026-09-19 补)

§18.4 那条方法(手工要补的 = 校验器要求 − 迁移器自愈)是可以机械算出来的。 真按它算一遍,差集是 2 个,不是 1 个:

VALIDATED (messageValue 调用点所在事件): agent/inbox/spliced, assistant/message,
                                         session/title-llm-request, tool/result, user/message
HEALED    (normalizeLegacyMessage 分支): assistant/message, tool/result, user/message
>>> 差集 = { agent/inbox/spliced, session/title-llm-request }

第二个成员 session/title-llm-request 同样是"校验要 id、迁移器不补" —— 它走 messageValue(value, …, version)(index.js:448,经 exactRecord + nonEmptyString(id)), 而 normalizeLegacyMessage() 里没有它的分支(assertTitleSources 只做语义校验,不补 id)。

实测(决定性):取一个真实带该事件的 v0,只剥掉它 messages[] 里的 id:

transformed(生产档)→ refuses this format v0 Session:
                       session/title-llm-request 15 message lacks required member "id"

⇒ 与 spliced 同类、同后果(拒绝整条会话)。

但目前是潜伏的:全盘 109 个 v0 里,title-llm-request 消息缺 id 的有 0 条 (75 个文件带该事件,全都带 id)。所以 §18.2 的"只补 inserted 就够了"在当前数据上成立, 成立的理由却比 §18.1 写的窄:不是因为差集只有一个成员,而是因为第二个成员恰好没被触发。

★ 这条修正的是结论的适用范围,不是结论本身: repair-legacy-spliced-ids.mjs:101 只处理 agent/inbox/spliced(差集的 1/2)。 若哪天某个 v0 的 title-llm-request 丢了 id,同一个故障会以同一个报错复发,而 repair 覆盖不到。 (assistant/message、tool/result 也缺 id 的实测为 0 条,且它们在自愈那一侧。)

18.1.2 ★ 自愈的前提是"全无",不是"缺 id"(dsh 2026-09-19 收紧)

§18.1 那句"顶层消息缺 id,迁移器自己会补"说得太宽。看 index.js:2179 的守卫:

case "user/message":
  if (Object.hasOwn(data,"id") || Object.hasOwn(data,"role") || Object.hasOwn(data,"message")
      || !Object.hasOwn(data,"content") || !Object.hasOwn(data,"source")) return event;

它是全有或全无的:只有 id、role、message 三个都不在时,才补 id+role; 任一在场就原样放过(于是"有 role 缺 id"这种半成品不会被自愈,直接拒绝)。

实测(修好后的文件上,只动一条 user/message):

形状 结果
id+role 都剥掉(真 v0 形状) OK(被自愈)
只剥 id(留着 role) 拒绝:user/message 10 data lacks required member "id"
只剥 role(留着 id) 拒绝

⇒ 真实数据能过,是因为 v0 的 user/message 恰好是"id/role 都没有"的整块形状 (实测 87 条全部没有 role)。"迁移器会给顶层补 id" 的准确说法是 "迁移器会给完整的 v0 形状补 id" —— 半成品不在自愈范围内。

18.2 实测(43 个文件,零例外)

计数口径(pi 2026-09-19 校正,我复核一致):全量是 43,不是 40。

40 个 mail-*  +  3 个非邮件会话(session-016b4715 / session-2584924a / session-e10bd3e4)

那 3 个的错因也是 spliced.inserted 缺 id(同一缺陷),被同一个脚本一起修了; 它们的仓库是 --home-program-SlipOfNote-- 等非邮件 store。 ⇒ 说「40/40」时指的是 mail- 子集*,说全量必须用 43。 .bak 共 80 个 v0 备份(37 个文件有 2 份:03:36 那次中断 + 03:42 那次成功; 6 个只有 1 份)⇒ 37×2 + 6×1 = 80,去重后才是 43。 (另有 2 个 session.v3.jsonl.zstd.bak-* 属 v3 修复,不计入这 80。)

样本(任一缺失):                       43
  其中顶层 user/message 也缺 id 的文件:  43(共缺 270 条)
  spliced.inserted 共缺:                 272 条

只补 inserted(**故意不碰** user/message)后跑 current 档:
  产物里 user/message 仍缺 id 的文件数:   0     ← 迁移器全部自愈

⚠️ "43" 这个数字在同一轮里有三个不同口径,别混用(dsh 2026-09-19 实测):

口径 值 说明
被修过的 v0 artifact 43 40 mail-* + 3 非邮件(§18.2 用的就是这个)
--prefix mail- 的验收候选 43 43 个 mail-* 会话文件(含 v3),见 §12
mail-* 的目录名 41 同名目录不在多 store 重复;另 1 个是 v3-only 目录

前两个都等于 43 但不是同一个集合 —— 被修集合里那 3 个非邮件,在验收集合里换成 mail-f8f9a840(v3-only,不在被修集合里)。数字相同 ≠ 集合相同。

⇒ 「补 user/message 无害」(§17.2)与「只需补 inserted」(§3) 两条都对,而且现在是同一件事的两面:补了也无害,因为迁移器反正会覆盖成它自己的 id。

18.3 顺带:id 只要求「非空字符串」

messageValue() 对 id 的约束是 nonEmptyString,没有格式校验。 所以 recovered-splice-<seq>-<n> 这种确定性 id 完全合法—— 这也从代码层面解释了 §17.1(伪造 id 不污染严格校验)。

18.4 给下一位的一句话

判断"该补哪些字段"时,要同时读「校验器」和「迁移器」: 校验器管"拒绝什么",迁移器管"自愈什么", 真正需要手工补的是两者之差(这里 = 校验器要求 − 迁移器自愈 = inserted[])。 只读校验器会以为要全补;只读迁移器会以为不用补。

⚠️ 但**"差集"要算完整**(§18.1.1):按这条方法机械算出来的是 { agent/inbox/spliced, session/title-llm-request } 两个成员, 不是一个。当前 repair 只覆盖了前者 —— 后者是潜伏的(磁盘上触发数 0)。 方法给出的是"该补的集合",不是"这次补的那个文件":把差集枚举完再决定 要写几段代码,否则下一个成员触发时,报错一模一样而脚本覆盖不到。

★ 三条可复用(本节的另一半):

  1. "缺 id 会自愈" 要说成 "缺 id 且缺 role 会自愈" —— 自愈守卫常是全有或全无的 (§18.1.2 实测:留 role 只缺 id ⇒ 拒绝)。别把"真实数据恰好长成的那个形状" 当成"自愈的判据"。
  2. nonEmptyString 只查非空、不查格式(§18.3)⇒ recovered-splice-<seq>-<n> 这种确定性 id 合法;"没炸"不等于"校验器认可格式", 可能只是它根本没查格式。
  3. 数字相同不等于集合相同(§18.2 的表):同一轮里"43"有三种口径 (被修 artifact / 验收候选 / 目录名),前两个都恰好是 43 而是不同集合。 报数时必须连着口径一起报,否则审计会对着两个都叫 43 的东西各说各话。