修复: 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:
2026-09-19 12:03:34 +08:00
parent 8a3d66a7c6
commit b4a8f74ae5
9 changed files with 849 additions and 6 deletions

View File

@ -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' };
}

View File

@ -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' },
};

View File

@ -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 直接读它', () => {