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

@ -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':