修复: 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:
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