修复: 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。两条路径结论相反
是设计使然,不是矛盾。
This commit is contained in:
267
docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md
Normal file
267
docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md
Normal file
@ -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-<seq>-<n>`),原数据里没有。
|
||||
证据:修后在**同样字节**上只换校验档,结果不同——
|
||||
|
||||
```
|
||||
[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 会因此给出
|
||||
相反结论。
|
||||
3
plugins/dsh-mail-bridge/lib/message.d.ts
vendored
3
plugins/dsh-mail-bridge/lib/message.d.ts
vendored
@ -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' };
|
||||
}
|
||||
|
||||
@ -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' },
|
||||
};
|
||||
|
||||
@ -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 直接读它', () => {
|
||||
|
||||
50
scripts/apply-spliced-id-repair.sh
Executable file
50
scripts/apply-spliced-id-repair.sh
Executable file
@ -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"
|
||||
42
scripts/apply-v3-usermessage-repair.sh
Executable file
42
scripts/apply-v3-usermessage-repair.sh
Executable file
@ -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)"
|
||||
212
scripts/repair-legacy-spliced-ids.mjs
Normal file
212
scripts/repair-legacy-spliced-ids.mjs
Normal file
@ -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 <seq> 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 <dir> --only mail-
|
||||
*
|
||||
* 安全性:
|
||||
* - 默认 dry-run;只有显式 `--apply` 才写盘。
|
||||
* - 写盘前逐会话用**生产同款选项**(recovery:"recoverable", validation:"transformed")
|
||||
* 重跑迁移验证,验证不过就跳过、绝不写。
|
||||
* - 原文件先复制为 `<file>.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。`);
|
||||
143
scripts/repair-v3-usermessage-ids.mjs
Normal file
143
scripts/repair-v3-usermessage-ids.mjs
Normal file
@ -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:<sid>:<seq>`),但**新注入的**那条 `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:未写)"}`);
|
||||
81
scripts/verify-mail-sessions-readable.mjs
Normal file
81
scripts/verify-mail-sessions-readable.mjs
Normal file
@ -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 <dir> --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>/ 目录,返回 {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);
|
||||
Reference in New Issue
Block a user