Files
MailUI4Agents/scripts/verify-mail-sessions-readable.mjs
JianFeeeee b4a8f74ae5 修复: 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。两条路径结论相反
   是设计使然,不是矛盾。
2026-09-19 12:03:34 +08:00

82 lines
3.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);