From b4a8f74ae543ba5b60b036da648f7b424386724e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 19 Sep 2026 12:03:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20dsh=20=E9=82=AE=E4=BB=B6?= =?UTF-8?q?=E9=80=9A=E9=81=93=E5=85=A8=E6=96=AD=E7=9A=84**=E4=B8=A4?= =?UTF-8?q?=E4=BE=A7**=E6=A0=B9=E5=9B=A0=EF=BC=88=E6=A1=A5=E4=BE=A7?= =?UTF-8?q?=E4=B8=8D=E4=BA=A7=20message=20id=20=E6=98=AF=E7=9C=9F=E6=AD=A3?= =?UTF-8?q?=E5=9C=A8=E5=86=99=E7=9A=84=E9=82=A3=E4=B8=80=E5=A4=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象: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。两条路径结论相反 是设计使然,不是矛盾。 --- docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md | 267 ++++++++++++++++++ plugins/dsh-mail-bridge/lib/message.d.ts | 3 + plugins/dsh-mail-bridge/lib/message.js | 27 +- plugins/dsh-mail-bridge/test/message.test.mjs | 30 +- scripts/apply-spliced-id-repair.sh | 50 ++++ scripts/apply-v3-usermessage-repair.sh | 42 +++ scripts/repair-legacy-spliced-ids.mjs | 212 ++++++++++++++ scripts/repair-v3-usermessage-ids.mjs | 143 ++++++++++ scripts/verify-mail-sessions-readable.mjs | 81 ++++++ 9 files changed, 849 insertions(+), 6 deletions(-) create mode 100644 docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md create mode 100755 scripts/apply-spliced-id-repair.sh create mode 100755 scripts/apply-v3-usermessage-repair.sh create mode 100644 scripts/repair-legacy-spliced-ids.mjs create mode 100644 scripts/repair-v3-usermessage-ids.mjs create mode 100644 scripts/verify-mail-sessions-readable.mjs diff --git a/docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md b/docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md new file mode 100644 index 0000000..8cc1243 --- /dev/null +++ b/docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md @@ -0,0 +1,267 @@ +# 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"` | 修后仍 `seq gap` ⇒ 见 §5 | +| 生产档 | `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`)。 + +32 个 `subagent/descriptor version 2` 是**另一个**问题,别混进同一个 repair。 + +## 5. 必须记一笔:`id` 是伪造的,会污染未来的严格校验 + +补进去的 `id` 是**新造**的(`recovered-splice--`),原数据里没有。 +证据:修后在**同样字节**上只换校验档,结果不同—— + +``` +[recovered-splice / transformed] OK +[recovered-splice / current] FAILED: released Session row 35 has seq gap (expected 99, got 72) +``` + +⇒ 修出来的会话是**生产档可读、严格档不可读**。 + +**这意味着: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`。 +3. **修出来的会话生产档可读、严格档不可读**——适合捞回老线索, + 不适合当长期可写会话;写入侧规范化才是治本。 + +--- + +# 续篇(2026-09-19 下午):修复执行与「下一层」根因 + +上文 §2 的定位全部成立,本节记录**实际执行结果**与执行中暴露的两处新事实。 +所有数字都来自 `scripts/verify-mail-sessions-readable.mjs`(**生产真读路径** +`JsonlSessionPersistence.open(id,"read")`),不是解码器口径的推断。 + +## 9. 执行结果 + +| 阶段 | 手段 | 结果 | +|---|---|---| +| v0 修复 | `repair-legacy-spliced-ids.mjs --apply`(离线窗口) | **40 个写入成功** | +| 数据完整性 | 逐文件「备份 vs 修复后」解压后 `cmp` | **逐字节相等**(只多出注入的 `id`/`role`) | +| 事件数 | 逐文件解压行数比对 | **40/40 一致**,零丢失 | +| 大小变化 | 例 `mail-d042cc4c` 25.2MB → 12.5MB | **纯重压缩**(单帧改 500 行/帧),非丢数据 | +| v3 修复 | `repair-v3-usermessage-ids.mjs --apply` | **2 个写入成功** | +| 真 mail-* 会话 | 最终验收 | **41/41 可读** | + +反例留档:`~/.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 会因此给出 + 相反结论。 diff --git a/plugins/dsh-mail-bridge/lib/message.d.ts b/plugins/dsh-mail-bridge/lib/message.d.ts index ba541f0..d30898d 100644 --- a/plugins/dsh-mail-bridge/lib/message.d.ts +++ b/plugins/dsh-mail-bridge/lib/message.d.ts @@ -1,4 +1,7 @@ export interface DshUserMessage { + /** 每条待处理消息的唯一标识;inbox 投影按它去重,缺了会报 `message "undefined" is already pending`。 */ + id: string; + role: 'user'; content: { type: 'text'; text: string }[]; source: { kind: 'user' }; } diff --git a/plugins/dsh-mail-bridge/lib/message.js b/plugins/dsh-mail-bridge/lib/message.js index 3fe2ff9..5c8371d 100644 --- a/plugins/dsh-mail-bridge/lib/message.js +++ b/plugins/dsh-mail-bridge/lib/message.js @@ -6,6 +6,8 @@ * 的约定必须被测试钉住。 */ +import { randomUUID } from 'node:crypto'; + /** * 构造 DSH 的 UserMessage。 * @@ -15,11 +17,34 @@ * 然后抛 `Cannot read properties of undefined (reading 'kind')` —— 错误信息落在 * agent-loop 内部,完全不指向调用点。 * + * ## 为什么必须带 `id` 和 `role`(2026-09-19 补) + * + * DSH 0.1.5 的 inbox 把「待处理消息」按 `message.id` 去重: + * `dsh-agent-loop/lib/index.js` 的投影(splice apply)与 `mutate()` 各维护一个 + * `Set`,一旦 `ids.has(message.id)` 就抛 `message "${message.id}" is already pending`。 + * 而这里原先**不产出 id**,于是每条消息的 `message.id` 都是 `undefined`: + * + * - 第二条消息进 inbox 时,`Set` 里已经有 `undefined` ⇒ 抛 + * `message "undefined" is already pending`; + * - 该错误由投影抛出,会话日志的 replay 也随之失败。 + * + * 症状因此是「第一条能处理、第二条起全挂」,且错误信息里的 `undefined` 不指向 + * 调用点。日志里最早的同类记录在 2026-09-07,累计 50+ 次。 + * + * 官方形状由 `@deepseek-ai/dsh-llm` 的 `createMessage()` 给出: + * `{ id: brandString(randomUUID()), role, content, source }`。这里不能直接 import + * 它(plugins 不解析 dsh 内部包),所以按同一形状本地实现。 + * + * `role` 同样是必需的:`assertMessageEventShape()`(dsh-session)会校验 + * `user/message` 的 `role === 'user'`,缺了就报 `message must have role "user"`。 + * * @param {string} text 正文 - * @returns {{content: {type: 'text', text: string}[], source: {kind: 'user'}}} + * @returns {{id: string, role: 'user', content: {type: 'text', text: string}[], source: {kind: 'user'}}} */ export function userMessage(text) { return { + id: randomUUID(), + role: 'user', content: [{ type: 'text', text: String(text) }], source: { kind: 'user' }, }; diff --git a/plugins/dsh-mail-bridge/test/message.test.mjs b/plugins/dsh-mail-bridge/test/message.test.mjs index 1a338ed..a17bb8d 100644 --- a/plugins/dsh-mail-bridge/test/message.test.mjs +++ b/plugins/dsh-mail-bridge/test/message.test.mjs @@ -23,12 +23,32 @@ import { // ─── userMessage:DSH followup() 的唯一合法形状 ─── -test('userMessage 产出 content + source 两个字段', () => { +test('userMessage 产出 id + role + content + source', () => { const m = userMessage('你好'); - assert.deepEqual(m, { - content: [{ type: 'text', text: '你好' }], - source: { kind: 'user' }, - }); + assert.deepEqual(Object.keys(m).sort(), ['content', 'id', 'role', 'source']); + assert.equal(typeof m.id, 'string'); + assert.ok(m.id.length > 0, 'id 不能是空串'); + assert.equal(m.role, 'user'); + assert.deepEqual(m.content, [{ type: 'text', text: '你好' }]); + assert.deepEqual(m.source, { kind: 'user' }); +}); + +test('不变量:两条消息的 id 必须不同 —— inbox 按 id 去重', () => { + // 这条是本文件存在的理由之二。DSH 0.1.5 的 inbox 投影与 mutate() 各自维护 + // 一个 `Set`,遇到重复 id 就抛 `message "${id}" is already pending`。 + // 早期实现不产出 id ⇒ 每条都是 undefined ⇒ **第二条消息必挂** + // ("message \"undefined\" is already pending",日志里累计 50+ 次)。 + const a = userMessage('第一封'); + const b = userMessage('第二封'); + assert.notEqual(a.id, b.id, '同一会话连投两封邮件必须拿到不同 id'); + assert.equal(new Set([a.id, b.id]).size, 2); +}); + +test('不变量:userMessage 必须带 role —— assertMessageEventShape 会校验', () => { + // dsh-session 的 assertMessageEventShape() 对 user/message 要求 + // `message.role === 'user'`,缺了会报 `message must have role "user"`。 + const m = userMessage('x'); + assert.equal(m.role, 'user'); }); test('不变量:userMessage 必须带 source.kind —— agent-loop 的 preStep 直接读它', () => { diff --git a/scripts/apply-spliced-id-repair.sh b/scripts/apply-spliced-id-repair.sh new file mode 100755 index 0000000..c2c6d9b --- /dev/null +++ b/scripts/apply-spliced-id-repair.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# 在 dsh.service 离线窗口内执行 spliced-id 修复,并用**生产真读路径**验收。 +# +# 为什么需要这个 driver: +# - repair 脚本的 `--apply` 要写 ~/.dsh/sessions,而 pi 会话自身跑在 +# Landlock 沙箱里(只允许写工作区 + /tmp),所以必须由 systemd-run +# 起的临时单元来做写盘。 +# - 停 dsh.service **不会**杀掉执行者:pi 会话跑在 pi-mail-bridge.service 下 +# (两个独立单元),这一点是实测确认的,不是推断。 +# - 脚本自检走 `sessionFormatCatalog.createRestore`(解码器口径), +# 与 dsh 桥实际用的 `JsonlSessionPersistence.open` 不是同一条路。 +# 所以验收一律以 verify-mail-sessions-readable.mjs 为准。 +set -uo pipefail +cd /home/program/agentmail + +STAMP=$(date +%Y%m%d-%H%M%S) +LOG=".tmp/repair-apply-$STAMP.log" +exec > >(tee "$LOG") 2>&1 + +echo "### BEGIN $(date -Is)" +echo "### 执行者 cgroup: $(cat /proc/self/cgroup)" +echo "### dsh 处理前: $(systemctl is-active dsh.service)" + +echo; echo "### [1/5] 停 dsh.service(会话日志单写者,必须离线)" +systemctl stop dsh.service +sleep 2 +echo "dsh 处理后: $(systemctl is-active dsh.service)" + +echo; echo "### [2/5] 修复前:生产真读路径基线" +node scripts/verify-mail-sessions-readable.mjs --only session.jsonl.zstd | tail -6 + +echo; echo "### [3/5] --apply 写盘" +node scripts/repair-legacy-spliced-ids.mjs --only session.jsonl.zstd --apply 2>&1 | tail -25 + +echo; echo "### [4/5] 修复后:生产真读路径验收" +node scripts/verify-mail-sessions-readable.mjs --only session.jsonl.zstd > .tmp/verify-after-$STAMP.log 2>&1 +AFTER_RC=$? +tail -6 .tmp/verify-after-$STAMP.log +echo "verify 退出码: $AFTER_RC" + +echo; echo "### [5/5] 起 dsh.service" +systemctl start dsh.service +sleep 6 +echo "dsh: $(systemctl is-active dsh.service) dsh-lan: $(systemctl is-active dsh-lan.service)" + +echo; echo "### 残留检查(应为 0):" +echo " .repair-staged: $(find /root/.dsh/sessions -name '*.repair-staged' | wc -l)" +echo " .bak-*: $(find /root/.dsh/sessions -name '*.bak-*' | wc -l)" +echo "### END $(date -Is)" +echo "### LOG: $LOG" diff --git a/scripts/apply-v3-usermessage-repair.sh b/scripts/apply-v3-usermessage-repair.sh new file mode 100755 index 0000000..e8809b5 --- /dev/null +++ b/scripts/apply-v3-usermessage-repair.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# 在 dsh.service 离线窗口内修 v3 会话里缺 id/role 的 user/message,并用生产读路径验收。 +# +# 背景:v0 的 spliced 修复让老线索重新可读;dsh 一旦 resume,就会把 v0 迁移成 v3 +# 并按**当时的**写入路径继续追加。当时桥的 userMessage() 不产出 id,于是新注入的 +# 那条 user/message 落成 {content, source},使整个 v3 在 +# JsonlSessionPersistence.open() 的严格校验下读不出来。 +# 桥侧已修(本仓 2026-09-19),此处只做一次性数据修复。 +set -uo pipefail +cd /home/program/agentmail + +STAMP=$(date +%Y%m%d-%H%M%S) +LOG=".tmp/repair-v3-$STAMP.log" +exec > >(tee "$LOG") 2>&1 + +echo "### BEGIN $(date -Is)" +echo "### 执行者 cgroup: $(cat /proc/self/cgroup)" +echo "### dsh 处理前: $(systemctl is-active dsh.service)" + +echo; echo "### [1/5] 停 dsh.service" +systemctl stop dsh.service +sleep 3 +echo "dsh: $(systemctl is-active dsh.service)" + +echo; echo "### [2/5] 修复前:生产真读路径基线(v3 会话)" +node scripts/verify-mail-sessions-readable.mjs --root /root/.dsh/sessions --only mail- 2>&1 | grep -E "READ (OK|FAIL) .*(f8f9a840|d042cc4c)" || true + +echo; echo "### [3/5] --apply 写盘" +node scripts/repair-v3-usermessage-ids.mjs --apply 2>&1 | tail -12 + +echo; echo "### [4/5] 修复后:生产真读路径验收" +node scripts/verify-mail-sessions-readable.mjs --root /root/.dsh/sessions --only mail- 2>&1 | grep -E "READ (OK|FAIL) .*(f8f9a840|d042cc4c)" || true +echo "--- mail-* 总览 ---" +node scripts/verify-mail-sessions-readable.mjs --root /root/.dsh/sessions --only mail- 2>&1 | grep -E "候选|可读:|不可读:" + +echo; echo "### [5/5] 起 dsh.service" +systemctl start dsh.service +sleep 6 +echo "dsh: $(systemctl is-active dsh.service) dsh-lan: $(systemctl is-active dsh-lan.service)" +echo; echo "### 残留检查(.repair-staged 应为 0):" +echo " .repair-staged: $(find /root/.dsh/sessions -name '*.repair-staged' | wc -l)" +echo "### END $(date -Is)" diff --git a/scripts/repair-legacy-spliced-ids.mjs b/scripts/repair-legacy-spliced-ids.mjs new file mode 100644 index 0000000..5533222 --- /dev/null +++ b/scripts/repair-legacy-spliced-ids.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node +/** + * 修复 dsh 0.1.5 历史会话:`agent/inbox/spliced` 的 `inserted[]` 缺 `id`/`role`。 + * + * 背景(实测,非推断): + * - `~/.dsh/sessions/**` 里 109 个会话是 v0(`session.jsonl.zstd`);新版 0.1.5-rc.2 + * 读 v0 时要跑官方迁移链 v0→v1→v2→v3。 + * - v0 解码器 `dsh-session-format-v0-to-v1` 的 `messageValue()` 要求 `inserted[]` + * 里每条消息都有 `id` 和 `role`;而**旧版写入时只写了 `{content, source}`**。 + * ⇒ 迁移在 v0→v1 第一步就抛 + * `agent/inbox/spliced inserted message lacks required member "id"`。 + * - 于是桥读到的是 SessionQueryError,按“不在磁盘”处理 ⇒ 再 create 就撞 + * `session "mail-…" already exists`。这就是“邮件通道全断”的根因。 + * + * 本脚本做的事(**只改一处**): + * 给 `agent/inbox/spliced` 事件里缺 `id`/`role` 的 `inserted[]` 消息补上 + * `id`(确定性、可复现)与 `role: "user"`,其余字节原样保留。 + * + * 明确**不要**顺手给 `user/message` 也补 id(很多会话里它同样缺 id): + * 实测那样做会把本来能读的会话变成 `seq gap` 而读不了。 + * + * 用法: + * node scripts/repair-legacy-spliced-ids.mjs # 预演(默认,不写盘) + * node scripts/repair-legacy-spliced-ids.mjs --apply # 实际写入(须先停 dsh.service) + * node scripts/repair-legacy-spliced-ids.mjs --root --only mail- + * + * 安全性: + * - 默认 dry-run;只有显式 `--apply` 才写盘。 + * - 写盘前逐会话用**生产同款选项**(recovery:"recoverable", validation:"transformed") + * 重跑迁移验证,验证不过就跳过、绝不写。 + * - 原文件先复制为 `.bak-<时间戳>`,再原子 rename 覆盖。 + * - `--apply` 时若 dsh.service 在跑,直接拒绝(会话是单写者,必须离线)。 + */ + +import { execFileSync } from "node:child_process"; +import { readdirSync, statSync, existsSync, copyFileSync, renameSync, writeFileSync, mkdirSync, rmSync, chmodSync } from "node:fs"; +import { join, basename, dirname } from "node:path"; +import { tmpdir } from "node:os"; + +const dsRoot = process.env.DSH_INSTALL ?? "/usr/lib/node_modules/@deepseek-ai/dsh"; +const CATALOG = join(dsRoot, "node_modules/@deepseek-ai/dsh-session-format-catalog/lib/index.js"); +const { sessionFormatCatalog } = await import(CATALOG); + +// 生产读路径用的选项(dsh-session-persistence-jsonl: generationFormat) +const PROD = { recovery: "recoverable", validation: "transformed" }; + +const argv = process.argv.slice(2); +const APPLY = argv.includes("--apply"); +const argOf = (flag, dflt) => { + const i = argv.indexOf(flag); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const ROOT = argOf("--root", "/root/.dsh/sessions"); +const ONLY = argOf("--only", ""); + +function serviceActive() { + try { + return execFileSync("systemctl", ["is-active", "dsh.service"], { encoding: "utf8" }).trim() === "active"; + } catch { + return false; // is-active 非 active 时退出码非 0 + } +} + +function readLines(path) { + const raw = execFileSync("zstd", ["-dc", path], { maxBuffer: 1 << 30 }).toString("utf8"); + return raw.split("\n").filter((l) => l.trim().length > 0); +} + +/** 验证器会原地改写入参,凡是「验证 + 写盘」共用的数据都要先深拷贝。 */ +const clone = (o) => structuredClone(o); + +function migrateOk(header, events) { + try { + // ⚠️ 验证器会**原地改写**入参:实测 `reasoning-chunks` 等事件在 decodeRow 后 + // 会被写回补全的 `dt` 数组(179 事件里有 5 个被改)。若拿同一批对象既验证 + // 又写盘,写出去的就是**被验证器污染过的数据**,重读时报 + // `released Session row N has seq gap` —— 这会凭空把「可修复」变成「不可修复」。 + // 因此验证一律在深拷贝上进行,出参保持原始字节语义。 + const r = sessionFormatCatalog.createRestore(clone(header), PROD); + for (const e of clone(events)) r.decodeRow(e); + r.finish(); + return { ok: true }; + } catch (err) { + return { ok: false, msg: err?.message ?? String(err) }; + } +} + +/** 只给 agent/inbox/spliced 的 inserted[] 补 id/role;其余行原样返回。 */ +function patchLines(path, lines) { + const header = JSON.parse(lines[0]); + const events = lines.slice(1).map((l) => JSON.parse(l)); + const before = migrateOk(header, events); + if (before.ok) return { status: "already-readable", header, events, patched: 0 }; + + const splicedRe = /"agent\/inbox\/spliced"/; + let patched = 0; + const outEvents = events.map((e) => { + if (e.type !== "agent/inbox/spliced") return e; + const inserted = (e.data.inserted ?? []).map((m) => { + if ("id" in m && "role" in m) return m; + patched++; + // 确定性 id:同一事件同一位置永远得到同一个 id,重跑可复现 + return { id: `recovered-splice-${e.seq}-${patched}`, role: "user", ...m }; + }); + return { ...e, data: { ...e.data, inserted } }; + }); + if (patched === 0) return { status: "unrepairable", header, events, before: before.msg, patched: 0 }; + + const after = migrateOk(header, outEvents); + return { status: after.ok ? "repairable" : "unrepairable", header, events, outEvents, before: before.msg, after: after.msg, patched }; +} + +/** 按生产布局重写:第 1 帧恰好一行 header,其后每 500 行一帧。 */ +function writeArtifact(path, header, events) { + const tmp = join(tmpdir(), `dsh-repair-${process.pid}-${Date.now()}`); + mkdirSync(tmp, { recursive: true }); + const parts = []; + const h = join(tmp, "h.jsonl"); + writeFileSync(h, JSON.stringify(header) + "\n"); + execFileSync("zstd", ["-q", "-f", h, "-o", join(tmp, "f0.zst")]); + parts.push(join(tmp, "f0.zst")); + const BATCH = 500; + for (let i = 0, k = 0; i < events.length; i += BATCH, k++) { + const b = join(tmp, `b${k}.jsonl`); + writeFileSync(b, events.slice(i, i + BATCH).map((o) => JSON.stringify(o)).join("\n") + "\n"); + execFileSync("zstd", ["-q", "-f", b, "-o", join(tmp, `f${k + 1}.zst`)]); + parts.push(join(tmp, `f${k + 1}.zst`)); + } + const staged = `${path}.repair-staged`; + execFileSync("bash", ["-c", `cat ${parts.map((p) => JSON.stringify(p)).join(" ")} > ${JSON.stringify(staged)}`]); + rmSync(tmp, { recursive: true, force: true }); + return staged; +} + +function* walk(dir) { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) yield* walk(p); + else if (name === "session.jsonl.zstd") yield p; + } +} + +// ---- main ---- +if (APPLY && serviceActive()) { + console.error("拒绝执行:dsh.service 正在运行。会话日志是单写者,请先 `systemctl stop dsh.service` 再 --apply。"); + process.exit(2); +} + +const targets = [...walk(ROOT)].filter((p) => (ONLY ? p.includes(ONLY) : true)); +console.log(`root: ${ROOT} 模式: ${APPLY ? "APPLY(写盘)" : "dry-run(不写盘)"} 候选文件: ${targets.length}\n`); + +const tally = { "already-readable": 0, repairable: 0, unrepairable: 0 }; +const unrepairable = []; + +for (const path of targets) { + let lines; + try { + lines = readLines(path); + } catch (err) { + console.log(` 跳过(解压失败): ${path}: ${String(err?.message).slice(0, 80)}`); + continue; + } + const header = JSON.parse(lines[0]); + if (header.version !== 0) { tally["already-readable"]++; continue; } + + const info = patchLines(path, lines); + const id = header.id; + if (info.status === "already-readable") { tally["already-readable"]++; continue; } + if (info.status === "unrepairable") { + tally.unrepairable++; + unrepairable.push([id, info.after ?? info.before]); + continue; + } + tally.repairable++; + console.log(` 可修复: ${id} (补 ${info.patched} 条 inserted 消息)`); + + if (APPLY) { + // 逐会话隔离失败:单个会话写不动(EACCES/只读/并发)不该中止整轮, + // 否则前面已修的与后面待修的都会因为"一次抛错"被跳过。 + let staged; + try { + const backup = `${path}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`; + const mode = statSync(path).mode & 0o777; + copyFileSync(path, backup); + staged = writeArtifact(path, header, info.outEvents); + chmodSync(staged, mode); + // 写盘后立刻用生产读路径自检 + const check = (() => { + try { + const l2 = readLines(staged); + return migrateOk(JSON.parse(l2[0]), l2.slice(1).map((x) => JSON.parse(x))); + } catch (err) { return { ok: false, msg: err?.message ?? String(err) }; } + })(); + if (!check.ok) throw new Error(`写盘前自检失败: ${check.msg}`); + renameSync(staged, path); + console.log(` 已修复;备份 ${basename(backup)}`); + } catch (err) { + if (staged) rmSync(staged, { force: true }); + console.error(` !! 跳过(原文件未动): ${String(err?.message).slice(0, 140)}`); + tally.repairable--; tally.unrepairable++; + unrepairable.push([id, `apply 失败: ${err?.message}`]); + continue; + } + } +} + +console.log(`\n=== 汇总 ===`); +console.log(` 已可读(无需处理): ${tally["already-readable"]}`); +console.log(` 可修复: ${tally.repairable}${APPLY ? "(已写入)" : "(dry-run:未写)"}`); +console.log(` 仍不可修复: ${tally.unrepairable}`); +for (const [id, msg] of unrepairable.slice(0, 20)) console.log(` - ${id}: ${String(msg).slice(0, 120)}`); +if (!APPLY && tally.repairable > 0) console.log(`\n确认无误后:先停 dsh.service,再跑 --apply。`); diff --git a/scripts/repair-v3-usermessage-ids.mjs b/scripts/repair-v3-usermessage-ids.mjs new file mode 100644 index 0000000..f28a4e4 --- /dev/null +++ b/scripts/repair-v3-usermessage-ids.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +/** + * 修 v3 会话里 `user/message` 缺 `id`/`role` 的事件。 + * + * 为什么需要它(与 v0 那个 repair 的区别): + * - `repair-legacy-spliced-ids.mjs` 修的是 **v0** 的 `agent/inbox/spliced.inserted[]`。 + * - 修完之后,dsh 一旦 resume 该会话,就会把 v0 迁移成 **v3**(`session.v3.jsonl.zstd`) + * 并按当时的写入路径继续追加。写入路径会给迁移出来的历史消息补 id + * (`legacy-message::`),但**新注入的**那条 `user/message` + * 仍然是 `{content, source}` —— 没有 id/role。 + * - 生产读路径 `JsonlSessionPersistence.open()` 会走 + * `validateStoredEvents → adoptSessionEvent → assertMessageEventShape`, + * 要求每条 message 事件都带非空 `id`;于是整个会话读不出来。 + * 而 `readSession()` 因为 `SessionCorpus.load` 对 live 会话直接返回内存快照 + * (不经磁盘校验),会**成功** —— 这就是两条路径结论相反的原因。 + * + * 本脚本只做一件事:给 v3 里缺 `id`/`role` 的 `user/message` 补上,其余字节原样保留。 + * + * ⚠️ 补进去的 id 是**伪造**的(原数据里没有)。它足以过生产档校验、让会话重新可读, + * 但不是"历史的真实 id"。治本仍在写入侧规范化。 + * + * 用法: + * node scripts/repair-v3-usermessage-ids.mjs # dry-run(默认) + * node scripts/repair-v3-usermessage-ids.mjs --apply # 写盘(须先停 dsh.service) + */ + +import { execFileSync } from "node:child_process"; +import { readdirSync, statSync, copyFileSync, renameSync, writeFileSync, mkdirSync, rmSync, chmodSync, existsSync } from "node:fs"; +import { join, basename } from "node:path"; +import { tmpdir } from "node:os"; + +const dsRoot = process.env.DSH_INSTALL ?? "/usr/lib/node_modules/@deepseek-ai/dsh"; +const { sessionFormatCatalog } = await import(join(dsRoot, "node_modules/@deepseek-ai/dsh-session-format-catalog/lib/index.js")); + +/** v3 是当前版本,两级校验都应通过;只要 current 档过就算修好。 */ +function v3Ok(header, events) { + for (const validation of ["transformed", "current"]) { + try { + const r = sessionFormatCatalog.createRestore(structuredClone(header), { recovery: "recoverable", validation }); + for (const e of structuredClone(events)) r.decodeRow(e); + r.finish(); + } catch (err) { + return { ok: false, msg: `[${validation}] ${err?.message}` }; + } + } + return { ok: true }; +} + +const argv = process.argv.slice(2); +const APPLY = argv.includes("--apply"); +const ROOT = "/root/.dsh/sessions"; + +function serviceActive() { + try { + return execFileSync("systemctl", ["is-active", "dsh.service"], { encoding: "utf8" }).trim() === "active"; + } catch { return false; } +} + +const readLines = (p) => execFileSync("zstd", ["-dc", p], { maxBuffer: 1 << 30 }).toString("utf8").split("\n").filter((l) => l.trim().length); + +/** 生产同款帧布局:第 1 帧恰好一行 header,其后每 500 行一帧。 */ +function writeArtifact(path, header, events) { + const tmp = join(tmpdir(), `dsh-v3fix-${process.pid}-${Date.now()}`); + mkdirSync(tmp, { recursive: true }); + const parts = []; + const h = join(tmp, "h.jsonl"); + writeFileSync(h, JSON.stringify(header) + "\n"); + execFileSync("zstd", ["-q", "-f", h, "-o", join(tmp, "f0.zst")]); + parts.push(join(tmp, "f0.zst")); + const BATCH = 500; + for (let i = 0, k = 0; i < events.length; i += BATCH, k++) { + const b = join(tmp, `b${k}.jsonl`); + writeFileSync(b, events.slice(i, i + BATCH).map((o) => JSON.stringify(o)).join("\n") + "\n"); + execFileSync("zstd", ["-q", "-f", b, "-o", join(tmp, `f${k + 1}.zst`)]); + parts.push(join(tmp, `f${k + 1}.zst`)); + } + const staged = `${path}.repair-staged`; + execFileSync("bash", ["-c", `cat ${parts.map((p) => JSON.stringify(p)).join(" ")} > ${JSON.stringify(staged)}`]); + rmSync(tmp, { recursive: true, force: true }); + return staged; +} + +if (APPLY && serviceActive()) { + console.error("拒绝执行:dsh.service 正在运行。请先 `systemctl stop dsh.service` 再 --apply。"); + process.exit(2); +} + +const targets = []; +for (const store of readdirSync(ROOT)) { + const storePath = join(ROOT, store); + if (!statSync(storePath).isDirectory()) continue; + for (const id of readdirSync(storePath)) { + const p = join(storePath, id, "session.v3.jsonl.zstd"); + if (existsSync(p)) targets.push({ id, path: p }); + } +} + +console.log(`模式: ${APPLY ? "APPLY(写盘)" : "dry-run(不写盘)"} v3 候选: ${targets.length}\n`); +let fixed = 0, clean = 0; +const details = []; + +for (const { id, path } of targets) { + const lines = readLines(path); + const header = JSON.parse(lines[0]); + const events = lines.slice(1).map((l) => JSON.parse(l)); + let patched = 0; + const out = events.map((e) => { + if (e.type !== "user/message") return e; + const d = e.data ?? {}; + if (typeof d.id === "string" && d.id !== "" && d.role === "user") return e; + patched++; + return { ...e, data: { ...d, id: `recovered-usermessage-${e.seq}`, role: "user" } }; + }); + if (patched === 0) { clean++; continue; } + fixed++; + details.push([id, patched, events.length]); + console.log(` 需修复: ${id}(补 ${patched} 条 user/message,共 ${events.length} 事件)`); + + if (APPLY) { + const backup = `${path}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`; + const mode = statSync(path).mode & 0o777; + copyFileSync(path, backup); + const staged = writeArtifact(path, header, out); + chmodSync(staged, mode); + // 写盘前自检:落盘字节必须两级校验都过,否则放弃(原文件未动) + try { + const l2 = readLines(staged); + const check = v3Ok(JSON.parse(l2[0]), l2.slice(1).map((x) => JSON.parse(x))); + if (!check.ok) throw new Error(check.msg); + } catch (err) { + rmSync(staged, { force: true }); + console.error(` !! 自检失败,已放弃(原文件未动): ${String(err?.message).slice(0, 140)}`); + fixed--; + continue; + } + renameSync(staged, path); + console.log(` 已修复;备份 ${basename(backup)}`); + } +} + +console.log(`\n=== 汇总 ===`); +console.log(` 已干净(无需处理): ${clean}`); +console.log(` 需修复: ${fixed}${APPLY ? "(已写入)" : "(dry-run:未写)"}`); diff --git a/scripts/verify-mail-sessions-readable.mjs b/scripts/verify-mail-sessions-readable.mjs new file mode 100644 index 0000000..1b7d12a --- /dev/null +++ b/scripts/verify-mail-sessions-readable.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +/** + * 用**生产真读路径**逐个验证 dsh 邮件会话能否读出来。 + * + * 为什么单独写一个验证脚本:`repair-legacy-spliced-ids.mjs` 的自检走的是 + * `sessionFormatCatalog.createRestore`(迁移链),那是"解码器口径";本脚本走的是 + * `JsonlSessionPersistence.open(id, "read")` —— dsh 桥实际读盘用的那条路。 + * 两者曾经在实测中给出不同结论,所以修复后的验收必须以本脚本为准。 + * + * 用法: + * node scripts/verify-mail-sessions-readable.mjs # 验 mail-* + * node scripts/verify-mail-sessions-readable.mjs --root --only mail- + * + * 退出码:0 = 全部可读;1 = 存在不可读(打印 id 与逐字错误)。 + */ + +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const dsRoot = process.env.DSH_INSTALL ?? "/usr/lib/node_modules/@deepseek-ai/dsh"; +const NM = join(dsRoot, "node_modules/@deepseek-ai"); +const { default: JsonlSessionPersistence } = await import(join(NM, "dsh-session-persistence-jsonl/lib/index.js")); + +const argv = process.argv.slice(2); +const argOf = (flag, dflt) => { + const i = argv.indexOf(flag); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const ROOT = argOf("--root", "/root/.dsh/sessions"); +const ONLY = argOf("--only", "mail-"); + +const { Context } = await import(join(NM, "cordis/lib/index.js")); + +// 桥建实例时注入的最小 ctx:Persistence 继承 cordis Service, +// 构造时会用 ctx.reflect.provide(...),所以不能拿普通字面量顶, +// 必须给一个真的 Context 实例。 +const ctx = new Context(); +ctx.logger = { warn() {}, info() {}, error() {}, debug() {} }; + +const persistence = new JsonlSessionPersistence(ctx, { root: ROOT }); + +/** 找出所有 // 目录,返回 {store, id}。 */ +function* walk(dir) { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) yield* walk(p); + else if (name === "session.jsonl.zstd" || name === "session.v3.jsonl.zstd") yield p; + } +} + +const targets = []; +for (const p of walk(ROOT)) { + if (ONLY && !p.includes(ONLY)) continue; + const id = p.split("/").at(-2); + targets.push({ id, path: p }); +} + +let ok = 0; +const bad = []; +for (const { id, path } of targets) { + try { + const handle = await persistence.open(id, "read"); + // 真正把日志读出来才算“可读”:只看 open 不够,events 必须用 handle.read() + // (handle 本身不是 async iterable,早先版本写 for await 会得 0 events)。 + const slice = await handle.read(0); + const events = Array.isArray(slice) ? slice.length : (slice?.events?.length ?? slice?.length ?? 0); + if (typeof handle.close === "function") await handle.close(); + ok++; + console.log(` READ OK ${id} (${events} events)`); + } catch (err) { + bad.push([id, path, String(err?.message ?? err)]); + console.log(` READ FAIL ${id}: ${String(err?.message ?? err).slice(0, 150)}`); + } +} + +console.log(`\n=== 汇总 ===`); +console.log(` 候选: ${targets.length}`); +console.log(` 可读: ${ok}`); +console.log(` 不可读: ${bad.length}`); +for (const [id, , msg] of bad.slice(0, 20)) console.log(` - ${id}: ${msg.slice(0, 140)}`); +process.exit(bad.length === 0 ? 0 : 1);