fix(dsh): session_update 在插件重启后仍能定位活会话(权限热更新不再静默失效)

sessionMap 是纯内存表,插件重启后为空。而 session_update(人在 WebUI 改权限
档位)原来只查这张表 —— 于是「插件刚重启 + 那条会话还没收到新邮件」时,
那条更新被静默忽略:人在界面上把 full 改回 workspace,DSH 运行时仍按 full 执行。
人以为自己收紧了权限,实际上没有。

邮件新建的会话 id 是确定性的(mail-<邮件会话 id>,模型降级重试时带 -r<i>),
所以重启后可以按前缀在活会话里定位,并顺手把 sessionMap/reverseMap 补回去
—— 不补的话这条会话后续的自动转发也会一起失效。

- lib/mail-session-id.js(dsh 专用,9 例测试):id 派生与匹配的纯函数。
  放宽成前缀匹配会把 mail-abcdef 误判成 mail-abc 的会话;非数字后缀
  (-retry / -rx)也不匹配,避免误改别的会话的档位。
- 接管会话(adopted)仍是例外:其 DSH 会话 id 由平台生成、推不出来,
  重启后无法热更新 —— 已知取舍;下次投递会按邮件里的 permission_mode 重设。
- tsconfig 清理:allowJs 原先写在根层(无效位置),移入 compilerOptions 后
  会让 lib/*.js 进入 TS 程序并违反 rootDir;这些模块已有 .d.ts 提供类型,
  该选项本就不需要,直接移除。

测试:dsh 358(新增 9)/ opencode 316 / pi 405 全绿。
This commit is contained in:
2026-09-11 10:47:30 +08:00
parent f91efd2d8d
commit 1692c615c6
5 changed files with 197 additions and 11 deletions

View File

@ -0,0 +1,3 @@
export declare function dshSessionIdForMail(mailSessionID: any): string;
export declare function matchesMailSession(dshSessionId: any, mailSessionID: any): boolean;
export declare function pickMailSession(dshSessionIds: any[] | undefined, mailSessionID: any): string | undefined;

View File

@ -0,0 +1,72 @@
/**
* 邮件会话 → DSH 会话 id 的确定性派生。
*
* # 为什么需要它
*
* `session_update`(人在 WebUI 里改权限档位)必须找到**正在运行**的那条 DSH
* 会话才能立刻生效。查找原来只走 `sessionMap`,而那张表是纯内存的 ——
* 插件重启后为空。
*
* 于是一个具体的坏情形:人把一条 full 会话在界面上改回 workspace
* 插件恰好刚重启过、那条会话还没收到新邮件 → `sessionMap` 没有它 →
* 这条更新被静默忽略DSH 运行时仍按 full 执行。人以为自己收紧了权限。
*
* # 派生规则
*
* 建会话时(`deliverMail` 的新建分支id 是确定性的:
* - 首次尝试:`mail-<邮件会话 id>`
* - 模型降级重试:`mail-<邮件会话 id>-r<i>`i 从 1 开始)
*
* 接管会话adopted是唯一的例外那条 DSH 会话 id 是平台自己生成的,
* 从邮件会话 id **推不出来**,只能靠内存映射。重启后接管会话的档位热更新
* 确实无法定位 —— 这是已知取舍,不是这里能修的。
*/
/** 首次尝试使用的 DSH 会话 id。 */
export function dshSessionIdForMail(mailSessionID) {
return `mail-${String(mailSessionID ?? '')}`;
}
/**
* 判断一个 DSH 会话 id 是否属于某条邮件会话。
*
* @param {string} dshSessionId DSH 侧会话 id
* @param {string} mailSessionID AgentMail 侧会话 id
* @returns {boolean}
*/
export function matchesMailSession(dshSessionId, mailSessionID) {
const id = String(dshSessionId ?? '');
const base = dshSessionIdForMail(mailSessionID);
if (id === base) return true;
// 模型降级重试mail-<id>-r1 / -r2 / …
const suffix = id.startsWith(`${base}-r`) ? id.slice(base.length + 2) : '';
return suffix.length > 0 && /^\d+$/.test(suffix);
}
/**
* 从一批候选会话里挑出属于该邮件会话的那条。
*
* 优先 `mail-<id>`(首次尝试),其次序号最小的 `-r<i>` —— 与 deliverMail
* 的尝试顺序一致,而不是数组顺序。
*
* @param {string[]} dshSessionIds
* @param {string} mailSessionID
* @returns {string|undefined}
*/
export function pickMailSession(dshSessionIds, mailSessionID) {
const base = dshSessionIdForMail(mailSessionID);
const list = Array.isArray(dshSessionIds) ? dshSessionIds.map(String) : [];
if (list.includes(base)) return base;
let best;
let bestIndex = Infinity;
for (const id of list) {
if (!matchesMailSession(id, mailSessionID)) continue;
const idx = Number(id.slice(base.length + 2));
if (idx < bestIndex) {
bestIndex = idx;
best = id;
}
}
return best;
}

View File

@ -60,6 +60,7 @@ import {
} from '../lib/discovery.js';
import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js';
import { createSSEClient } from '../lib/sse-client.js';
import { pickMailSession } from '../lib/mail-session-id.js';
// 只用 isApprovalDSH 没有 always 语义,免批授权表在这里用不上(见决策处的注释)。
import { isApproval } from '../lib/permission-grants.js';
import {
@ -748,6 +749,53 @@ export function apply(ctx: any, config: PluginConfig): void {
session.append('approval/policy', { policy: approval });
}
/**
* 由邮件会话 id 找一个**活着的** DSH 会话,找到就补回内存映射。
*
* # 为什么不能只查 sessionMap
*
* 那是一张纯内存表,插件重启后为空。而 `session_update`(人在 WebUI 改
* 权限档位)只关心**正在跑**的那条会话 —— 重启后它恰好还没收到新邮件时,
* 映射缺失会让这条更新被静默忽略:人在界面上把 full 改回 workspace
* 运行时依旧按 full 执行。人以为自己收紧了权限。
*
* 邮件新建的会话 id 是确定性的(`mail-<邮件会话 id>`,重试时带 `-r<i>`
* 见 lib/mail-session-id.js所以重启后也能定位并且顺手把映射补回去 ——
* 否则这条会话后续的自动转发reverseMap也会一起失效。
*
* 接管会话adopted是例外那条 DSH 会话 id 由平台生成,从邮件会话 id
* 推不出来,重启后确实无法定位热更新 —— 已知取舍;下次投递时会按邮件里
* 带的 permission_mode 重新 apply档位不会丢。
*/
function findLiveDshSession(mailSessionID: string): { id: string; agent: any } | undefined {
const bound = sessionMap.peek(mailSessionID);
if (bound) {
const live = ctx.agents.get(bound.dshSessionId);
if (live) return { id: bound.dshSessionId, agent: live };
}
let live: any[] = [];
try {
live = ctx.agents?.list?.() ?? [];
} catch {
return undefined;
}
const id = pickMailSession(live.map((a: any) => String(a?.id ?? '')), mailSessionID);
if (!id) return undefined;
const agent = live.find((a: any) => String(a?.id ?? '') === id);
if (!agent) return undefined;
if (!bound) {
const cwd = String(agent?.session?.header?.cwd ?? '');
sessionMap.set(mailSessionID, { dshSessionId: id, directory: cwd });
reverseMap.set(id, mailSessionID);
mailDrivenSessions.add(id);
console.error(`[dsh-mail-bridge] 由确定性 id 恢复会话映射 ${mailSessionID} -> ${id}`);
}
return { id, agent };
}
// ─── 投递邮件到 DSH 会话 ───
/**
@ -1899,16 +1947,16 @@ export function apply(ctx: any, config: PluginConfig): void {
// approval/policy否则模型仍按旧档位执行。
const sid = String(data?.session_id || '');
const pm = String(data?.permission_mode || '');
if (sid && pm) {
const bound = sessionMap.peek(sid);
if (bound) {
const live = ctx.agents.get(bound.dshSessionId);
if (live?.session) {
applyPermissionMode(live.session, pm);
console.error(`[dsh-mail-bridge] session_update ${sid} -> 权限档位 ${pm}`);
}
}
if (!sid || !pm) break;
const found = findLiveDshSession(sid);
if (!found) {
// 会话不在运行(插件重启后尚未收到新邮件、或从未投过)。
// 无需处理:下次投递时 deliverMail 会按邮件里带的 permission_mode
// 重新 applyPermissionMode,档位不会丢。
break;
}
applyPermissionMode(found.agent?.session, pm);
console.error(`[dsh-mail-bridge] session_update ${sid} -> 权限档位 ${pm}(会话 ${found.id}`);
break;
}
case 'session_archived':

View File

@ -0,0 +1,64 @@
/**
* 邮件会话 → DSH 会话 id 的派生约定。
*
* 这组函数存在的原因是一个具体的静默失效:插件重启后 `sessionMap` 为空,
* 人在 WebUI 改档位的 `session_update` 找不到目标会话就被忽略,
* 运行时仍按旧档位执行 —— 人以为自己收紧了权限。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
dshSessionIdForMail,
matchesMailSession,
pickMailSession,
} from '../lib/mail-session-id.js';
test('首次尝试的 id 是 mail-<邮件会话 id>', () => {
assert.equal(dshSessionIdForMail('abc-123'), 'mail-abc-123');
});
test('匹配首次尝试的 id', () => {
assert.equal(matchesMailSession('mail-abc', 'abc'), true);
});
test('匹配模型降级重试的 -r<i>', () => {
assert.equal(matchesMailSession('mail-abc-r1', 'abc'), true);
assert.equal(matchesMailSession('mail-abc-r12', 'abc'), true);
});
test('不匹配别的会话(前缀相同也不行)', () => {
assert.equal(matchesMailSession('mail-abcdef', 'abc'), false, 'mail-abc 的前缀不能吃掉 mail-abcdef');
assert.equal(matchesMailSession('mail-abd', 'abc'), false);
assert.equal(matchesMailSession('other-abc', 'abc'), false);
assert.equal(matchesMailSession('', 'abc'), false);
});
test('不匹配非数字后缀(避免误吞其它会话)', () => {
assert.equal(matchesMailSession('mail-abc-rx', 'abc'), false);
assert.equal(matchesMailSession('mail-abc-r', 'abc'), false);
assert.equal(matchesMailSession('mail-abc-retry', 'abc'), false);
});
test('pickMailSession 优先首次尝试,而不是数组顺序', () => {
// 数组顺序可能来自 ctx.agents.list(),与尝试顺序无关
assert.equal(pickMailSession(['mail-abc-r2', 'mail-abc-r1', 'mail-abc'], 'abc'), 'mail-abc');
});
test('pickMailSession 没有首次尝试时取序号最小的重试', () => {
assert.equal(pickMailSession(['mail-abc-r3', 'mail-abc-r1', 'mail-abc-r2'], 'abc'), 'mail-abc-r1');
});
test('pickMailSession 找不到时返回 undefined调用方据此跳过', () => {
assert.equal(pickMailSession(['other-1', 'mail-def'], 'abc'), undefined);
assert.equal(pickMailSession([], 'abc'), undefined);
assert.equal(pickMailSession(undefined, 'abc'), undefined);
});
test('接管的平台会话推不出 id不能被误判成邮件会话', () => {
// 平台自己生成的 id如 DSH 界面里开的会话)与邮件会话无关,
// 匹配函数必须说「不是」,否则会给错误的会话改档位。
assert.equal(matchesMailSession('session-7f3a91', 'abc'), false);
assert.equal(pickMailSession(['session-7f3a91'], 'abc'), undefined);
});

View File

@ -9,8 +9,7 @@
"esModuleInterop": true,
"declaration": true,
"skipLibCheck": true,
"types": ["node"],
"allowJs": true
"types": ["node"]
},
"include": ["src"]
}