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:
3
plugins/dsh-mail-bridge/lib/mail-session-id.d.ts
vendored
Normal file
3
plugins/dsh-mail-bridge/lib/mail-session-id.d.ts
vendored
Normal 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;
|
||||||
72
plugins/dsh-mail-bridge/lib/mail-session-id.js
Normal file
72
plugins/dsh-mail-bridge/lib/mail-session-id.js
Normal 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;
|
||||||
|
}
|
||||||
@ -60,6 +60,7 @@ import {
|
|||||||
} from '../lib/discovery.js';
|
} from '../lib/discovery.js';
|
||||||
import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js';
|
import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js';
|
||||||
import { createSSEClient } from '../lib/sse-client.js';
|
import { createSSEClient } from '../lib/sse-client.js';
|
||||||
|
import { pickMailSession } from '../lib/mail-session-id.js';
|
||||||
// 只用 isApproval:DSH 没有 always 语义,免批授权表在这里用不上(见决策处的注释)。
|
// 只用 isApproval:DSH 没有 always 语义,免批授权表在这里用不上(见决策处的注释)。
|
||||||
import { isApproval } from '../lib/permission-grants.js';
|
import { isApproval } from '../lib/permission-grants.js';
|
||||||
import {
|
import {
|
||||||
@ -748,6 +749,53 @@ export function apply(ctx: any, config: PluginConfig): void {
|
|||||||
session.append('approval/policy', { policy: approval });
|
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 会话 ───
|
// ─── 投递邮件到 DSH 会话 ───
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1899,16 +1947,16 @@ export function apply(ctx: any, config: PluginConfig): void {
|
|||||||
// approval/policy,否则模型仍按旧档位执行。
|
// approval/policy,否则模型仍按旧档位执行。
|
||||||
const sid = String(data?.session_id || '');
|
const sid = String(data?.session_id || '');
|
||||||
const pm = String(data?.permission_mode || '');
|
const pm = String(data?.permission_mode || '');
|
||||||
if (sid && pm) {
|
if (!sid || !pm) break;
|
||||||
const bound = sessionMap.peek(sid);
|
const found = findLiveDshSession(sid);
|
||||||
if (bound) {
|
if (!found) {
|
||||||
const live = ctx.agents.get(bound.dshSessionId);
|
// 会话不在运行(插件重启后尚未收到新邮件、或从未投过)。
|
||||||
if (live?.session) {
|
// 无需处理:下次投递时 deliverMail 会按邮件里带的 permission_mode
|
||||||
applyPermissionMode(live.session, pm);
|
// 重新 applyPermissionMode,档位不会丢。
|
||||||
console.error(`[dsh-mail-bridge] session_update ${sid} -> 权限档位 ${pm}`);
|
break;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
applyPermissionMode(found.agent?.session, pm);
|
||||||
|
console.error(`[dsh-mail-bridge] session_update ${sid} -> 权限档位 ${pm}(会话 ${found.id})`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'session_archived':
|
case 'session_archived':
|
||||||
|
|||||||
64
plugins/dsh-mail-bridge/test/mail-session-id.test.mjs
Normal file
64
plugins/dsh-mail-bridge/test/mail-session-id.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
@ -9,8 +9,7 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"types": ["node"],
|
"types": ["node"]
|
||||||
"allowJs": true
|
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user