Commit Graph

4 Commits

Author SHA1 Message Date
652316674a 文档: 补上「为什么只补 inserted」的真正机制 —— 迁移器只补了一半(§18)
§3/§4 一直说「只改一处」,但从没说清为什么顶层 user/message 缺 id 就不用管。
早先给的理由(补了会 seq gap)已被 §17 作废,那句话一度**没有理由**。

真正的机制(可推广,不只是这一个 repair 的解释):
- 补 id 的 normalizeLegacyMessage() 的 switch 只有 3 个分支
  (user/message、assistant/message、tool/result),**没有 agent/inbox/spliced**;
  顶层消息缺 id 迁移器自己会补(legacy-message:<sid>:<seq>)。
- 校验 id 的 messageValue() 对 spliced.inserted[] 严格要求 id+role。
⇒ 顶层缺 id 能自愈;inserted[] 缺 id 直接拒绝整条会话。不是取舍,是只补了一半。

实测 43 个文件零例外:其中 43 个文件的顶层 user/message 也缺 id(共 270 条),
只补 inserted(故意不碰 user/message)后,产物里 user/message 仍缺 id = 0。

另:id 的约束是 nonEmptyString(无格式校验),从代码层面解释了 §17.1。

给下一位的一句话:判断「该补哪些字段」要同时读校验器与迁移器 ——
校验器管拒绝什么,迁移器管自愈什么,需要手工补的是两者之差。
2026-09-19 12:22:36 +08:00
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
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
b4a8f74ae5 修复: 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。两条路径结论相反
   是设计使然,不是矛盾。
2026-09-19 12:03:34 +08:00