dsh-session-conductor 1.4.4 → 1.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -63,6 +63,41 @@ export async function setMemberModelOverride(ctx, sessionId, provider, model) {
|
|
|
63
63
|
memberModelCache.set(sessionId, { provider, model });
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* 删除一个会话的 override(domain 与内存镜像同时清)。
|
|
68
|
+
*
|
|
69
|
+
* 【为什么需要】override 一旦存在,`agent/request` 就会**每轮**按它改写请求模型,
|
|
70
|
+
* 优先级压过官方 UI / `model/selection` 链路。因此「不带 override 参数」的切换
|
|
71
|
+
* 不能只是不写 —— 必须把存量条目删掉,官方链路才能接管;否则表现为
|
|
72
|
+
* 「切换成功但请求仍走旧模型」(selection 写入后,请求仍按 override 的值发出)。
|
|
73
|
+
*
|
|
74
|
+
* 幂等:不存在时返回 {removed:false},不报错。
|
|
75
|
+
*
|
|
76
|
+
* @param {object} ctx 插件 context
|
|
77
|
+
* @param {string} sessionId 会话 id
|
|
78
|
+
* @returns {Promise<{removed: boolean}>}
|
|
79
|
+
*/
|
|
80
|
+
export async function deleteMemberModelOverride(ctx, sessionId) {
|
|
81
|
+
if (!sessionId) return { removed: false };
|
|
82
|
+
const hadCache = memberModelCache.delete(sessionId);
|
|
83
|
+
let removedInDomain = false;
|
|
84
|
+
try {
|
|
85
|
+
const domain = await loadPluginDomain(ctx);
|
|
86
|
+
const state = domain.global.get() ?? {};
|
|
87
|
+
const overrides = { ...(state.memberModelOverrides ?? {}) };
|
|
88
|
+
if (Object.prototype.hasOwnProperty.call(overrides, sessionId)) {
|
|
89
|
+
delete overrides[sessionId];
|
|
90
|
+
await domain.global.set({ ...state, memberModelOverrides: overrides });
|
|
91
|
+
removedInDomain = true;
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
// domain 不可用(如工作区自测环境缺宿主包):内存镜像已清,但落盘那条还在。
|
|
95
|
+
// 必须如实把原因带回调用方 —— 否则「切换成功、请求仍走旧模型」无从排查。
|
|
96
|
+
return { removed: hadCache, error: String(error?.message ?? error) };
|
|
97
|
+
}
|
|
98
|
+
return { removed: hadCache || removedInDomain };
|
|
99
|
+
}
|
|
100
|
+
|
|
66
101
|
/**
|
|
67
102
|
* 把成员模型 override 应用到一次请求配置上(`agent/request` hook 的核心逻辑,单独导出便于单测)。
|
|
68
103
|
* 丢掉继承的 reasoningEffort:换模型后应回到目标模型自己的 provider 默认行为。
|
|
@@ -14,7 +14,7 @@ import { cfg } from "../../core/config.js";
|
|
|
14
14
|
import { hasOpenTurn } from "../../sessions/turn.js";
|
|
15
15
|
import { foldLastModelSelection } from "../../sessions/route.js";
|
|
16
16
|
import { lazyZstd, lazyRepair } from "../../core/lazy.js";
|
|
17
|
-
import { setMemberModelOverride } from "./override.js";
|
|
17
|
+
import { setMemberModelOverride, deleteMemberModelOverride } from "./override.js";
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* 等待一个成员进入空闲(当前回合结束/暂停)。
|
|
@@ -171,29 +171,38 @@ async function repairDescriptorIfStale(ctx, filePath, sessionId, provider, model
|
|
|
171
171
|
const descriptor = found.descriptor;
|
|
172
172
|
if (!descriptor || typeof descriptor !== "object") return { skipped: "不是子代理会话(无 descriptor)" };
|
|
173
173
|
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
174
|
+
// 何时改写 descriptor:目标模型 ≠ descriptor 当前值就改 ——
|
|
175
|
+
// 子代理冷恢复(reason=resume)的请求模型直接取 descriptor,不经过官方
|
|
176
|
+
// model/selection 投影链路,selection 事件对它无效(只写 selection 时 resume 仍用
|
|
177
|
+
// descriptor 旧模型)。要「不带 override 也能切子代理」,必须把 descriptor 同步到目标,
|
|
178
|
+
// 两条通道(挂载期 selection / 冷恢复 descriptor)才都生效。
|
|
179
|
+
const driftsFromTarget = descriptor.agentProvider === undefined
|
|
180
|
+
|| descriptor.agentModel === undefined
|
|
181
|
+
|| descriptor.agentProvider !== provider
|
|
182
|
+
|| descriptor.agentModel !== model;
|
|
183
|
+
if (!driftsFromTarget) return { skipped: "descriptor 已指向目标模型" };
|
|
184
|
+
|
|
185
|
+
// modelCatalog 只用于**校验目标合法**(防止把 descriptor 写成一个不存在的路由,
|
|
186
|
+
// 那会让成员永久唤不醒)。它不可用时不再直接放弃同步 —— 早先版本把它当硬前置,
|
|
187
|
+
// 结果「目录服务不可用」= 同步静默失效,成员冷恢复又回落旧模型。
|
|
188
|
+
const sc = ctx.get?.("sessionController");
|
|
189
|
+
if (typeof sc?.modelCatalog === "function") {
|
|
190
|
+
try {
|
|
191
|
+
const catalog = await sc.modelCatalog();
|
|
192
|
+
const groups = catalog?.groups ?? [];
|
|
193
|
+
if (groups.length > 0) {
|
|
194
|
+
const group = groups.find((g) => g.id === provider);
|
|
195
|
+
if (!group) return { skipped: `目标 provider ${provider} 不在模型目录,跳过(避免写入失效路由)` };
|
|
196
|
+
const known = (group.models ?? []).some((m) => m.id === model);
|
|
197
|
+
if (!known) return { skipped: `目标模型 ${model} 不在 ${provider} 目录,跳过(避免写入失效路由)` };
|
|
198
|
+
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
// 目录调用失败不阻断同步:目标值由调用方给出,宿主侧还有组合校验兜底
|
|
201
|
+
ctx.logger?.info?.(`dsh-session-conductor: descriptor 同步:模型目录调用失败,仍按目标写入:${String(error?.message ?? error)}`);
|
|
202
|
+
}
|
|
183
203
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const providerIds = new Set(groups.map((g) => g.id));
|
|
187
|
-
const staleProvider = descriptor.agentProvider !== undefined && !providerIds.has(descriptor.agentProvider);
|
|
188
|
-
const staleModel = (() => {
|
|
189
|
-
if (descriptor.agentProvider === undefined || descriptor.agentModel === undefined) return false;
|
|
190
|
-
const group = groups.find((g) => g.id === descriptor.agentProvider);
|
|
191
|
-
if (!group) return false;
|
|
192
|
-
return !(group.models ?? []).some((m) => m.id === descriptor.agentModel);
|
|
193
|
-
})();
|
|
194
|
-
if (!staleProvider && !staleModel) return { skipped: "descriptor 路由有效" };
|
|
195
|
-
|
|
196
|
-
// 整份重写(descriptor 在文件头部,追加帧改不了它);这条路径只在路由失效时走一次,代价可接受
|
|
204
|
+
|
|
205
|
+
// 整份重写(descriptor 在文件头部,追加帧改不了它);这条路径只在路由失效或目标漂移时走一次,代价可接受
|
|
197
206
|
const { encodeSessionText } = await lazyRepair();
|
|
198
207
|
const { decodeAllFrames } = await import("../../zstd-frames.js");
|
|
199
208
|
const fullText = buf.length > 0 ? await decodeAllFrames(buf) : "";
|
|
@@ -226,7 +235,7 @@ async function repairDescriptorIfStale(ctx, filePath, sessionId, provider, model
|
|
|
226
235
|
const backup = backupSwitchLog(filePath, sessionId);
|
|
227
236
|
if (backup.error) return { error: backup.error };
|
|
228
237
|
replaceFileSync(filePath, await encodeSessionText(lines.join("\n") + "\n"), { tmpSuffix: ".descfixtmp" });
|
|
229
|
-
log(ctx, `切换写日志:descriptor
|
|
238
|
+
log(ctx, `切换写日志:descriptor 已同步为 ${provider}/${model}(原 ${descriptor.agentProvider ?? "?"}/${descriptor.agentModel ?? "?"},防冷恢复回落)`);
|
|
230
239
|
return { repaired: true };
|
|
231
240
|
}
|
|
232
241
|
|
|
@@ -251,6 +260,17 @@ export async function appendSelectionEventToLog(ctx, sessionId, provider, model)
|
|
|
251
260
|
count: events.length,
|
|
252
261
|
});
|
|
253
262
|
|
|
263
|
+
// descriptor 同步放在「selection 是否已相同」的判定**之前**:
|
|
264
|
+
// 两者是独立通道(selection 管挂载期、descriptor 管冷恢复),日志里 selection 已是目标值
|
|
265
|
+
// 并不代表 descriptor 也对齐了。早先放在追加帧之后,遇到「重复切换同一模型」就提前返回,
|
|
266
|
+
// 于是 descriptor 永远修不到,成员下次 resume 仍回落旧模型。
|
|
267
|
+
const descFixEarly = await repairDescriptorIfStale(ctx, filePath, sessionId, provider, model);
|
|
268
|
+
if (descFixEarly.repaired) {
|
|
269
|
+
log(ctx, `切换写日志:descriptor 已同步到 ${provider}/${model}(防冷恢复回落)`);
|
|
270
|
+
} else if (descFixEarly.error) {
|
|
271
|
+
log(ctx, `切换写日志:descriptor 校验失败(不影响本次切换):${descFixEarly.error}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
254
274
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
255
275
|
const { events } = logInfo;
|
|
256
276
|
const last = foldLastModelSelection(events);
|
|
@@ -279,9 +299,8 @@ export async function appendSelectionEventToLog(ctx, sessionId, provider, model)
|
|
|
279
299
|
const written = await appendEventFrame(filePath, event);
|
|
280
300
|
log(ctx, `切换写日志:追加帧 ${Date.now() - writeStartedAt}ms(seq ${event.seq})`);
|
|
281
301
|
if (!written.ok) return { error: `写入会话日志失败:${written.error}` };
|
|
282
|
-
// descriptor
|
|
283
|
-
|
|
284
|
-
if (descFix.error) log(ctx, `切换写日志:descriptor 校验失败(不影响本次切换):${descFix.error}`);
|
|
302
|
+
// descriptor 同步已在进入循环前做过一次(那时 selection 可能是「已是该模型」而提前返回),
|
|
303
|
+
// 这里不再重复整份重写 —— 只有追加帧改变了文件尾部,descriptor 在头部、不受影响。
|
|
285
304
|
|
|
286
305
|
// 附带排查提示:本机实测「成员唤不醒」的真凶是部署侧的「自动审查」开关被关掉,
|
|
287
306
|
// 与 descriptor / 帧校验和 / 写锁全都无关(详见 docs/VERSIONS.md 1.3.15)。
|
|
@@ -335,15 +354,29 @@ export async function appendSelectionEventToLog(ctx, sessionId, provider, model)
|
|
|
335
354
|
* @returns {Promise<{note: string}>} note 直接拼进回执
|
|
336
355
|
*/
|
|
337
356
|
async function persistSwitchIntent(ctx, sessionId, provider, model, options = {}) {
|
|
338
|
-
// override
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
357
|
+
// override 是**显式参数**:带 `override` → 写入/更新(旧行为,每轮强制改写);
|
|
358
|
+
// 不带 → **删除该成员的存量 override**,让官方 model/selection 链路接管。
|
|
359
|
+
// 为什么必须删而不是「不写」:override 一旦存在,agent/request 会每轮按它改写请求,
|
|
360
|
+
// 优先级压过新写的 model/selection,结果就是「selection 写入成功但下一条请求仍用旧模型」。
|
|
361
|
+
// 只删该成员这一条,不做批量清理。
|
|
362
|
+
if (options?.override === true) {
|
|
363
|
+
try {
|
|
364
|
+
await setMemberModelOverride(ctx, sessionId, provider, model);
|
|
365
|
+
return { note: ",已写 override(每轮强制改写,旧行为;UI 切换依赖同步机制)" };
|
|
366
|
+
} catch (error) {
|
|
367
|
+
return { note: `,override 持久化失败:${String(error?.message ?? error)}` };
|
|
368
|
+
}
|
|
369
|
+
}
|
|
342
370
|
try {
|
|
343
|
-
await
|
|
344
|
-
return { note:
|
|
371
|
+
const { removed, error } = await deleteMemberModelOverride(ctx, sessionId);
|
|
372
|
+
if (error) return { note: `,清除 override 失败(旧 override 仍会压制切换):${error}` };
|
|
373
|
+
return {
|
|
374
|
+
note: removed
|
|
375
|
+
? ",已清除该成员的 override(改走官方 model/selection 链路)"
|
|
376
|
+
: ",未写 override(走官方 model/selection 链路)",
|
|
377
|
+
};
|
|
345
378
|
} catch (error) {
|
|
346
|
-
return { note:
|
|
379
|
+
return { note: `,清除 override 失败:${String(error?.message ?? error)}` };
|
|
347
380
|
}
|
|
348
381
|
}
|
|
349
382
|
|
|
@@ -475,6 +508,19 @@ async function applyModelSwitch(ctx, sessionId, provider, model, agent, timer, o
|
|
|
475
508
|
// (memberView 读 live.options.model),不跟着改就会一直显示创建时的老模型。
|
|
476
509
|
const synced = syncLiveAgentOptions(ctx, live, provider, model);
|
|
477
510
|
timer.mark("同步视图");
|
|
511
|
+
// ④-补2:descriptor 同步 —— 挂载中切换只写 selection,但成员空闲卸载后冷恢复(resume)
|
|
512
|
+
// 的请求模型直接取 descriptor,不改它就会「下次唤醒回落旧模型」。与未挂载路径
|
|
513
|
+
// (appendSelectionEventToLog 里的修复)保持同一语义:目标 ≠ descriptor 就改写。
|
|
514
|
+
try {
|
|
515
|
+
const { findSessionLogById, sessionsRootOf } = await import("../../sessions/events.js");
|
|
516
|
+
const logPath = findSessionLogById(sessionId, sessionsRootOf());
|
|
517
|
+
if (logPath) {
|
|
518
|
+
const descFix = await repairDescriptorIfStale(ctx, logPath, sessionId, provider, model);
|
|
519
|
+
if (descFix.repaired) log(ctx, `成员模型切换:descriptor 已同步到 ${provider}/${model}(防冷恢复回落)`);
|
|
520
|
+
}
|
|
521
|
+
} catch (error) {
|
|
522
|
+
log(ctx, `成员模型切换:descriptor 同步失败(不影响本次切换): ${String(error?.message ?? error)}`);
|
|
523
|
+
}
|
|
478
524
|
let via = `model/selection 事件(官方投影接管)${eventNote}${overrideNote},下一回合生效`
|
|
479
525
|
+ (synced ? ";已同步成员视图的模型显示" : "");
|
|
480
526
|
// ⑤ 官方接口优先(若该部署暴露):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-conductor",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.5",
|
|
4
4
|
"description": "DSH session management enhancement: archive/restore sessions, batch & conditional delete, AI auto-rename, interrupted-session auto-resume, release idle sessions, workspace grouping, undo last message, repair damaged session logs, value analysis, full-text search, per-member model switching (list_models / set_member_model), and plan/closing template injection (system-prompt + context).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|