Commit Graph

5 Commits

Author SHA1 Message Date
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
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