diff --git a/docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md b/docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md index d69bdb1..b6b7e9f 100644 --- a/docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md +++ b/docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md @@ -185,12 +185,12 @@ pi 的「其它 34/28」也复现了,且能解释:28 个读不了的里 | 阶段 | 手段 | 结果 | |---|---|---| -| v0 修复 | `repair-legacy-spliced-ids.mjs --apply`(离线窗口) | **40 个写入成功** | +| 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 可读** | +| 真 mail-* 会话 | 最终验收(`--prefix mail-`) | **43/43 可读**(**会话文件**口径,与上一行的 43 **不是同一集合**,见 §18.2 表) | 反例留档:`~/.dsh/sessions/**` 仍有 3 个非邮件会话读不了,根因是 `subagent/descriptor ... unsupported descriptor version 2`,**与本问题无关**, @@ -377,8 +377,78 @@ mail-* v0 需修复的会话: 40 ⇒ **顶层缺 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` 的守卫: + +```js +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 条) @@ -388,6 +458,17 @@ mail-* v0 需修复的会话: 40 产物里 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。 @@ -403,3 +484,21 @@ mail-* v0 需修复的会话: 40 校验器管"拒绝什么",迁移器管"自愈什么", **真正需要手工补的是两者之差**(这里 = 校验器要求 − 迁移器自愈 = `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--` 这种确定性 id 合法;**"没炸"不等于"校验器认可格式"**, + 可能只是它根本没查格式。 +3. **数字相同不等于集合相同**(§18.2 的表):同一轮里"43"有**三种口径** + (被修 artifact / 验收候选 / 目录名),前两个都恰好是 43 而是**不同集合**。 + **报数时必须连着口径一起报**,否则审计会对着两个都叫 43 的东西各说各话。 diff --git a/scripts/repair-legacy-spliced-ids.mjs b/scripts/repair-legacy-spliced-ids.mjs index b0fd453..6738e5e 100644 --- a/scripts/repair-legacy-spliced-ids.mjs +++ b/scripts/repair-legacy-spliced-ids.mjs @@ -21,6 +21,19 @@ * 所以这里**不需要**补。早先注释说"补了会把能读的会话变成 seq gap"—— * **那是错的**(2026-09-19 更正):那是「校验器原地改写入参、又被拿去再校验」造成的假象; * 深拷贝重测后补与不补都 strict-ok(40/40)。详见 docs §17。 + * ⚠️ 准确说法是"迁移器会给**完整的 v0 形状**补 id"——它的守卫是**全有或全无** + * (`id`/`role`/`message` 三个都不在才补);"留 role 只缺 id"的半成品**不会**被自愈。详见 docs §18.1.2。 + * + * ⚠️ **本脚本的覆盖范围是"差集"的一半**(2026-09-19 补记,详见 docs §18.1.1): + * 用手工补 = 「校验器要求」−「迁移器自愈」这个差集去算,机械算出来有 **2 个**成员: + * { `agent/inbox/spliced`, `session/title-llm-request` } + * 本脚本只处理前一个。后一个**同样**是"校验要 id、迁移器不补" + * (`messageValue()` 经 `exactRecord`+`nonEmptyString(id)`;`normalizeLegacyMessage()` 无此分支), + * 实测只剥掉它的 `id` ⇒ 生产档照样抛 + * `session/title-llm-request message lacks required member "id"`。 + * 它**当前是潜伏的**:全盘 109 个 v0 里触发数 **0**。 + * ⇒ 若哪天某个 v0 的 `title-llm-request` 丢了 id,**报错形态与当初完全相同,而本脚本覆盖不到**。 + * 届时应把处理逻辑推广到该事件(`messages[]` 同 `inserted[]`),而不是另起一个脚本。 * * 用法: * node scripts/repair-legacy-spliced-ids.mjs # 预演(默认,不写盘)