补充: 「差集」有**两个**成员(不是 §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 与三处口径标注。
This commit is contained in:
2026-09-19 12:47:32 +08:00
parent a0e950109a
commit 176c90272b
2 changed files with 114 additions and 2 deletions

View File

@ -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-<seq>-<n>` 这种确定性 id 合法;**"没炸"不等于"校验器认可格式"**,
可能只是它根本没查格式。
3. **数字相同不等于集合相同**(§18.2 的表):同一轮里"43"有**三种口径**
(被修 artifact / 验收候选 / 目录名),前两个都恰好是 43 而是**不同集合**。
**报数时必须连着口径一起报**,否则审计会对着两个都叫 43 的东西各说各话。

View File

@ -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 <seq> message lacks required member "id"`。
* 它**当前是潜伏的**:全盘 109 个 v0 里触发数 **0**。
* ⇒ 若哪天某个 v0 的 `title-llm-request` 丢了 id,**报错形态与当初完全相同,而本脚本覆盖不到**。
* 届时应把处理逻辑推广到该事件(`messages[]` 同 `inserted[]`),而不是另起一个脚本。
*
* 用法:
* node scripts/repair-legacy-spliced-ids.mjs # 预演(默认,不写盘)