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 与三处口径标注。
505 lines
27 KiB
Markdown
505 lines
27 KiB
Markdown
# 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`:
|
||
|
||
```js
|
||
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` 的守卫:
|
||
|
||
```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 条)
|
||
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 的东西各说各话。
|