diff --git a/plugins/dsh-mail-bridge/lib/mail-session-id.d.ts b/plugins/dsh-mail-bridge/lib/mail-session-id.d.ts new file mode 100644 index 0000000..e3597fa --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/mail-session-id.d.ts @@ -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; diff --git a/plugins/dsh-mail-bridge/lib/mail-session-id.js b/plugins/dsh-mail-bridge/lib/mail-session-id.js new file mode 100644 index 0000000..fe9778a --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/mail-session-id.js @@ -0,0 +1,72 @@ +/** + * 邮件会话 → DSH 会话 id 的确定性派生。 + * + * # 为什么需要它 + * + * `session_update`(人在 WebUI 里改权限档位)必须找到**正在运行**的那条 DSH + * 会话才能立刻生效。查找原来只走 `sessionMap`,而那张表是纯内存的 —— + * 插件重启后为空。 + * + * 于是一个具体的坏情形:人把一条 full 会话在界面上改回 workspace, + * 插件恰好刚重启过、那条会话还没收到新邮件 → `sessionMap` 没有它 → + * 这条更新被静默忽略,DSH 运行时仍按 full 执行。人以为自己收紧了权限。 + * + * # 派生规则 + * + * 建会话时(`deliverMail` 的新建分支)id 是确定性的: + * - 首次尝试:`mail-<邮件会话 id>` + * - 模型降级重试:`mail-<邮件会话 id>-r`(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--r1 / -r2 / … + const suffix = id.startsWith(`${base}-r`) ? id.slice(base.length + 2) : ''; + return suffix.length > 0 && /^\d+$/.test(suffix); +} + +/** + * 从一批候选会话里挑出属于该邮件会话的那条。 + * + * 优先 `mail-`(首次尝试),其次序号最小的 `-r` —— 与 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; +} diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 7ab2122..31a0532 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -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'; // 只用 isApproval:DSH 没有 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`, + * 见 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': diff --git a/plugins/dsh-mail-bridge/test/mail-session-id.test.mjs b/plugins/dsh-mail-bridge/test/mail-session-id.test.mjs new file mode 100644 index 0000000..148083a --- /dev/null +++ b/plugins/dsh-mail-bridge/test/mail-session-id.test.mjs @@ -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', () => { + 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); +}); diff --git a/plugins/dsh-mail-bridge/tsconfig.json b/plugins/dsh-mail-bridge/tsconfig.json index c17791e..03bead5 100644 --- a/plugins/dsh-mail-bridge/tsconfig.json +++ b/plugins/dsh-mail-bridge/tsconfig.json @@ -9,8 +9,7 @@ "esModuleInterop": true, "declaration": true, "skipLibCheck": true, - "types": ["node"], - "allowJs": true + "types": ["node"] }, "include": ["src"] }