dsh-session-conductor 1.3.18 → 1.4.4

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.
@@ -0,0 +1,93 @@
1
+ // dsh-session-conductor — 存储域不可用时的文件后备(降级实现)
2
+ //
3
+ // 【为什么单独成文件】`core/domain.js` 顶层静态依赖宿主包 `@deepseek-ai/dsh-storage-domain`,
4
+ // 工作区单测没有宿主依赖,静态 import 会让整个模块在导入时就 ERR_MODULE_NOT_FOUND。
5
+ // 把降级逻辑放在这里(只依赖 node 内置 + 已抽出的 json-store),就能被单测直接覆盖。
6
+ //
7
+ // 【降级触发条件】storageDomain 域出现下面任一情况时,改读写域自己的落盘 JSON:
8
+ // · 服务拿不到(ctx.get("storageDomain") 为空)
9
+ // · 名字仍被占(already open)而查到的旧句柄已 closed 或停在 disposing
10
+ // 这两种情况下插件所有依赖持久化的功能本会一路 500,只能重启恢复。
11
+
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { readJsonSafeSync, writeJsonAtomicSync } from "../shared/json-store.js";
15
+
16
+ /** DSH 主目录(与 core/config.js 的 resolveDshHome 同规则;此处内联避免底层模块反向依赖上层配置)。 */
17
+ function dshHomePath() {
18
+ if (process.env.DSH_HOME) return process.env.DSH_HOME;
19
+ const home = process.env.HOME || process.env.USERPROFILE || homedir();
20
+ return home ? join(home, ".dsh") : "";
21
+ }
22
+
23
+ /**
24
+ * 判定域句柄是否还能用。
25
+ *
26
+ * 【为什么还要看 disposing】宿主的 DomainImpl 在 close 一开始就置 `disposing = true`,
27
+ * 要等写链排空、存储单元关闭后才置 `closed = true`。如果关闭过程卡住(写链未排空),
28
+ * 句柄会长期停在 `disposing`:`closed` 仍是 false,但任何读写都会抛「domain is closed」。
29
+ * 只看 `closed` 会把它当成活域继续用,于是接口一路 500。
30
+ */
31
+ export function isUsableDomain(domain) {
32
+ return Boolean(domain) && domain.closed !== true && domain.disposing !== true;
33
+ }
34
+
35
+ /**
36
+ * 文件后备域:storageDomain 的域不可用(卡在关闭中 / 名字被占导致无法重新打开)时的降级实现。
37
+ *
38
+ * 【为什么要有它】域卡住时插件所有依赖持久化的功能(列表缓存、模板、各开关、成员模型 override)
39
+ * 会全部 500,只能靠重启宿主恢复。而这些状态本来就落盘在
40
+ * `<DSH_HOME>/storages/dsh_session_conductor.json`,直接读写同一份文件即可继续工作。
41
+ *
42
+ * 【只实现 global.get / global.set】全插件对域的使用面只有这两者(外加 close),没有用到 tables,
43
+ * 因此后备实现只管 global 一节,其余字段(unit / tables)原样保留,保证宿主下次启动能正常加载。
44
+ *
45
+ * 【串行写】域本身有写链保证串行;文件后备没有,这里用一条 Promise 链模拟,
46
+ * 避免并发 `global.set` 互相覆盖(读-改-写之间的丢更新)。
47
+ */
48
+ export function fileFallbackDomain() {
49
+ const home = dshHomePath();
50
+ const file = home ? join(home, "storages", "dsh_session_conductor.json") : "";
51
+ const readAll = () => {
52
+ if (!file) return {};
53
+ try { return readJsonSafeSync(file) ?? {}; } catch { return {}; }
54
+ };
55
+ let chain = Promise.resolve();
56
+ return {
57
+ // 供 isUsableDomain 判断:后备域永远可用
58
+ closed: false,
59
+ disposing: false,
60
+ __fallback: true,
61
+ global: {
62
+ get() {
63
+ return readAll().global ?? {};
64
+ },
65
+ set(value) {
66
+ chain = chain.then(async () => {
67
+ if (!file) throw new Error("DSH_HOME 未知,无法写入后备存储");
68
+ const current = readAll();
69
+ // 保留 unit / tables 原值:它们由宿主维护,后备路径不改动
70
+ const next = { ...current, global: value, tables: current.tables ?? {} };
71
+ if (current.unit !== undefined) next.unit = current.unit;
72
+ await writeJsonAtomicSync(file, next);
73
+ });
74
+ return chain;
75
+ },
76
+ },
77
+ close() { /* 后备域无需关闭 */ },
78
+ };
79
+ }
80
+
81
+ /** 降级只提示一次,避免每个请求都刷一行日志。 */
82
+ let fallbackLogged = false;
83
+ export function logOnceFallback(error, reused) {
84
+ if (fallbackLogged) return;
85
+ fallbackLogged = true;
86
+ try {
87
+ console.warn(
88
+ "[session-conductor] 存储域不可用,已降级为直接读写 storages/dsh_session_conductor.json:"
89
+ + `open 失败=${String(error?.message ?? error)}`
90
+ + `${reused ? ";复用的句柄已关闭/关闭中" : ""}。重启实例可恢复正常域。`,
91
+ );
92
+ } catch { /* 日志失败不影响主流程 */ }
93
+ }
@@ -6,6 +6,9 @@
6
6
 
7
7
  import { defineDomain } from "@deepseek-ai/dsh-storage-domain";
8
8
  import { z } from "zod";
9
+ // 域降级为文件后备时用(与域落盘同一份 JSON,保持 unit/global/tables 结构)
10
+ // 域不可用时的文件后备(抽到独立文件,便于工作区单测直接覆盖)
11
+ import { isUsableDomain, fileFallbackDomain, logOnceFallback } from "./domain-fallback.js";
9
12
 
10
13
  /** 插件自身的持久化域:自动重命名/自动续跑开关 + 记账,存于 <DSH_HOME>/storages/dsh-session-conductor.json。
11
14
  * 版本保持 1:autoContinue 是纯增量字段(带 default),旧域文件可无损加载。 */
@@ -110,9 +113,33 @@ let domainLive = false;
110
113
  * @returns {Promise<object>} 域对象;storageDomain 服务缺失时 reject
111
114
  */
112
115
  export function pluginDomain(ctx) {
113
- if (domainPromise === null) {
116
+ if (domainPromise !== null) {
117
+ return domainPromise.then((cached) => {
118
+ if (isUsableDomain(cached)) return cached;
119
+ // 缓存里的句柄已不可用(关闭中 / 已关闭):丢掉重建
120
+ domainPromise = null;
121
+ domainLive = false;
122
+ if (cached && cached.__fallback) {
123
+ // 后备域本身可用,上面的 isUsableDomain 已放行;走到这里说明它被误标,直接重取
124
+ domainPromise = Promise.resolve(fileFallbackDomain());
125
+ return domainPromise;
126
+ }
127
+ return pluginDomain(ctx);
128
+ }, () => {
129
+ // 上次 open 失败:清掉缓存让它重新尝试(rejected Promise 不能被永久缓存)
130
+ domainPromise = null;
131
+ domainLive = false;
132
+ return pluginDomain(ctx);
133
+ });
134
+ }
135
+ {
114
136
  const storage = ctx.get("storageDomain");
115
- if (!storage) return Promise.reject(new Error("storageDomain 不可用"));
137
+ if (!storage) {
138
+ // 服务都拿不到:直接文件后备(报错只会让所有功能一起不可用)
139
+ domainLive = false;
140
+ domainPromise = Promise.resolve(fileFallbackDomain());
141
+ return domainPromise;
142
+ }
116
143
  domainPromise = storage.open(pluginDomainSpec).then((domain) => {
117
144
  domainLive = true;
118
145
  attachDomainClose(ctx, domain);
@@ -120,22 +147,25 @@ export function pluginDomain(ctx) {
120
147
  }).catch((error) => {
121
148
  // 模块热重载场景:本实例的缓存是空的,但上一份实例打开的域仍被 storageDomain
122
149
  // 按名字占用(facility 用 reserved 集合挡重复 open),于是再 open 必报 already-open。
123
- // 宿主提供了按名字查已开域的口子,这里直接复用那份 handle——
124
- // 否则重载后列表/模板/开关会一路 500,直到整个实例重启为止。
150
+ // 宿主提供了按名字查已开域的口子,这里直接复用那份 handle。
125
151
  const reused = isAlreadyOpenError(error) && typeof storage.get === "function"
126
152
  ? storage.get(pluginDomainSpec.name)
127
153
  : undefined;
128
- if (reused) {
154
+ if (isUsableDomain(reused)) {
129
155
  domainLive = true;
130
156
  attachDomainClose(ctx, reused);
131
157
  return reused;
132
158
  }
159
+ // 复用的句柄也不可用(典型:上一次 close 卡在 disposing)—— 此时既不能再 open
160
+ // (名字仍被占),也没有活句柄可用,只能降级到文件后备,让功能继续可用。
161
+ // 【注意】绝不能在这里递归调用 pluginDomain:它还会走 already-open 分支,形成死循环。
133
162
  domainPromise = null;
134
163
  domainLive = false;
135
- throw error;
164
+ logOnceFallback(error, reused);
165
+ return fileFallbackDomain();
136
166
  });
167
+ return domainPromise;
137
168
  }
138
- return domainPromise;
139
169
  }
140
170
 
141
171
  /** 判定「这个名字已经被占用」的重复打开错误(宿主 DomainError 带 code,保底看文案)。 */
@@ -24,12 +24,12 @@ export function memberDisplay(member, sessionId) {
24
24
  * @param {{provider: string, model: string}} route 目标路由
25
25
  * @returns {Promise<{rows: Array<{sessionId: string, label: string, ok: boolean, detail: string}>, okCount: number, failCount: number}>}
26
26
  */
27
- export async function applyModelToMany(ctx, targets, { provider, model }) {
27
+ export async function applyModelToMany(ctx, targets, { provider, model, override }) {
28
28
  const rows = [];
29
29
  for (const t of targets) {
30
30
  const label = memberDisplay(t.member, t.sessionId);
31
31
  try {
32
- const result = await switchAgentModel(ctx, t.sessionId, provider, model, t.agent ?? null);
32
+ const result = await switchAgentModel(ctx, t.sessionId, provider, model, t.agent ?? null, { override });
33
33
  if (result?.error) {
34
34
  rows.push({ sessionId: t.sessionId, label, ok: false, detail: String(result.error) });
35
35
  } else {
@@ -120,8 +120,8 @@ async function renderMemberDetail(found) {
120
120
  * 二参:切换 + 回显查证快照(与 AI 工具 `set_member_model` 同一套落点:
121
121
  * 写 override / 事件、登记查证任务、按请求级记录给判定)。
122
122
  */
123
- async function switchMember(ctx, found, provider, model, invocation) {
124
- const result = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent);
123
+ async function switchMember(ctx, found, provider, model, invocation, override = false) {
124
+ const result = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent, { override });
125
125
  if (result.error) return { kind: "error", text: `切换失败:${result.error}` };
126
126
  // 登记查证任务:该成员下次发起请求时由 agent/request 事件判定,结果会注入发起方
127
127
  try {
@@ -179,7 +179,9 @@ export async function handleModelSwitchCommand(ctx, invocation) {
179
179
  return { kind: "success", text: await renderMemberDetail(found) };
180
180
  }
181
181
 
182
- // 二参:切换(单个或全部)
182
+ // 二/三参:切换(单个或全部)。可选尾参 `override` = 旧行为(额外写每轮强制改写的
183
+ // override);默认不写 —— 官方 model/selection 链路当轮生效且持久,不压 UI 的选择。
184
+ const wantOverride = parts.some((x) => x.toLowerCase() === "override");
183
185
  const routeRaw = parts[1];
184
186
  let provider;
185
187
  let model;
@@ -203,11 +205,11 @@ export async function handleModelSwitchCommand(ctx, invocation) {
203
205
 
204
206
  // 单个目标:沿用原有路径(带查证快照与查证任务登记)
205
207
  if (!resolved.all && resolved.targets.length === 1) {
206
- return await switchMember(ctx, resolved.targets[0], provider, model, invocation);
208
+ return await switchMember(ctx, resolved.targets[0], provider, model, invocation, wantOverride);
207
209
  }
208
210
 
209
211
  // 全部目标:串行逐个切,回执必须带完整名单
210
- const report = await applyModelToMany(ctx, resolved.targets, { provider, model });
212
+ const report = await applyModelToMany(ctx, resolved.targets, { provider, model, override: wantOverride });
211
213
  const extra = [];
212
214
  if (followedLead) {
213
215
  extra.push(`模型来源:跟随主代理 ${followedLead.sessionId}(${followedLead.modelSource})`);
@@ -144,7 +144,9 @@ export async function createTeammateWithModel(ctx, exec, args) {
144
144
  log(ctx, `创建成员 ${name}:中断后等待空闲 → ${JSON.stringify(idleState)}`);
145
145
 
146
146
  // ③ 写 model/selection:与 set_member_model 完全同一条链路(默认不碰任何 @Remote)
147
- const switchResult = await switchAgentModel(ctx, sessionId, modelProvider, model, null);
147
+ // 建成员即指定模型:成员未挂载、冷恢复走 descriptor,selection 链路管不到首轮 ——
148
+ // 这里保持旧行为(写 override),与「创建时定模型」的工具语义一致。
149
+ const switchResult = await switchAgentModel(ctx, sessionId, modelProvider, model, null, { override: true });
148
150
  log(ctx, `创建成员 ${name}(${sessionId})| 子代理provider=${subagentProvider} | 中断=${JSON.stringify(interrupt)} | 设模型 ${modelProvider}/${model}=${switchResult.error ? `失败 ${switchResult.error}` : "已写入"}`);
149
151
 
150
152
  if (switchResult.error) {
@@ -334,10 +334,14 @@ export async function appendSelectionEventToLog(ctx, sessionId, provider, model)
334
334
  * ① 持久化切换意图:不依赖 live agent —— 成员 inactive/未挂载时也能切,下次运行生效。
335
335
  * @returns {Promise<{note: string}>} note 直接拼进回执
336
336
  */
337
- async function persistSwitchIntent(ctx, sessionId, provider, model) {
337
+ async function persistSwitchIntent(ctx, sessionId, provider, model, options = {}) {
338
+ // override 是**显式参数**(/model-switch … override / set_member_model 的 override:true):
339
+ // 默认不写 —— 官方 model/selection 链路当轮立即生效且跨轮持久(干净会话实测),
340
+ // 每轮强制改写的 override 会压过 UI 的选择,表现为「界面切了、请求不动」。
341
+ if (options?.override !== true) return { note: ",未写 override(走官方 model/selection 链路)" };
338
342
  try {
339
343
  await setMemberModelOverride(ctx, sessionId, provider, model);
340
- return { note: ",已记录切换意图" };
344
+ return { note: ",已写 override(每轮强制改写,旧行为;UI 切换依赖同步机制)" };
341
345
  } catch (error) {
342
346
  return { note: `,意图持久化失败:${String(error?.message ?? error)}` };
343
347
  }
@@ -382,10 +386,40 @@ async function tryOfficialSelectModel(ctx, sessionId, provider, model, overrideN
382
386
  }
383
387
  }
384
388
 
385
- export async function switchAgentModel(ctx, sessionId, provider, model, agent = null) {
389
+ /**
390
+ * 把切换结果同步到 live agent 的 `options`(宿主官方「查看子智能体」视图读的就是它)。
391
+ *
392
+ * 【为什么需要】宿主 experimental/agent-team 的 `memberView()` 里,成员模型取的是
393
+ * `live.options.model` —— 即 agent **创建时**的 AgentOptions,与我们写的 `model/selection`
394
+ * 事件无关。不同步的话会出现「界面一直显示创建时的老模型,而实际请求早已换了模型」,
395
+ * 两边长期对不上,排查时极易误判。
396
+ *
397
+ * 【为什么能改】宿主 ReactLoopAgent 构造里是 `this.options = options` 直接引用、**没有 freeze**
398
+ * (该文件只冻结 boundaryMessages 与请求标记)。这里仍做 `isFrozen` 兜底:
399
+ * 万一将来宿主冻结了,跳过同步即可,绝不影响切换本身。
400
+ *
401
+ * @returns {boolean} 是否真的写进去了
402
+ */
403
+ export function syncLiveAgentOptions(ctx, live, provider, model) {
404
+ if (!live || !live.options) return false;
405
+ try {
406
+ if (Object.isFrozen(live.options)) {
407
+ log(ctx, "成员模型切换:live agent 的 options 已被冻结,跳过视图同步(不影响实际请求)");
408
+ return false;
409
+ }
410
+ live.options.provider = provider;
411
+ live.options.model = model;
412
+ return true;
413
+ } catch (error) {
414
+ log(ctx, `成员模型切换:同步 live options 失败(不影响实际请求): ${String(error?.message ?? error)}`);
415
+ return false;
416
+ }
417
+ }
418
+
419
+ export async function switchAgentModel(ctx, sessionId, provider, model, agent = null, options = {}) {
386
420
  const timer = createStageTimer();
387
421
  try {
388
- return await applyModelSwitch(ctx, sessionId, provider, model, agent, timer);
422
+ return await applyModelSwitch(ctx, sessionId, provider, model, agent, timer, options);
389
423
  } finally {
390
424
  // 无论成功失败都留一行分阶段耗时:排查「切模型卡很久」时直接看慢在哪一段
391
425
  log(ctx, `模型切换耗时:${timer.summary()}(标记合计 ${timer.total()}ms)`);
@@ -393,20 +427,27 @@ export async function switchAgentModel(ctx, sessionId, provider, model, agent =
393
427
  }
394
428
 
395
429
  /** 切换主流程(逐段打点,计时器只观测、不改变行为)。 */
396
- async function applyModelSwitch(ctx, sessionId, provider, model, agent, timer) {
430
+ async function applyModelSwitch(ctx, sessionId, provider, model, agent, timer, options = {}) {
397
431
  if (!sessionId) return { error: "缺少 sessionId(无法定位目标会话)" };
398
- // ① 持久化切换意图(不依赖 live agent——成员 inactive/未挂载时也能切,下次运行生效)
399
- const { note: overrideNote } = await persistSwitchIntent(ctx, sessionId, provider, model);
432
+ // ① 持久化切换意图(不依赖 live agent——成员 inactive/未挂载时也能切,下次运行生效)。
433
+ // 只有显式传 override:true 才写 override(旧行为);默认走官方 model/selection 链路。
434
+ const { note: overrideNote } = await persistSwitchIntent(ctx, sessionId, provider, model, options);
400
435
  timer.mark("写意图");
401
436
  const live = agent ?? ctx.get("agents")?.get?.(sessionId) ?? null;
402
437
  timer.mark("定位成员");
403
- // ② 成员当前未挂载(inactive):直接把事件写进会话日志——官方 selectionFor 读投影即生效。
404
- // (Team 成员唤醒走 sendMessage → followup,不经过 resumeSetupFor,只记意图不会被应用。)
405
- if (typeof live?.ctx?.on !== "function" || !live?.session) {
438
+ // ② 成员真的没有被实例化(agents 里取不到、或没有 session):只能把事件写进会话日志,
439
+ // 官方 selectionFor 读投影即生效。
440
+ //
441
+ // 【判定为什么只看 session】旧条件还要求 `typeof live.ctx.on === "function"`,但 agent 空闲后
442
+ // `ctx.scope` 已 unwind,`live.ctx.on` 不再可用 —— 于是「空闲但仍在内存」的成员被误判成未挂载,
443
+ // 走这条分支就永远拿不到 live.options 去同步(官方 list_agents / 查看子智能体显示的模型会一直
444
+ // 停在创建时的值)。实测:官方 list_agents 对空闲成员仍能返回 model 字段,说明 agents.get() 有对象,
445
+ // 那就是能同步的。写事件与同步 options 都只需要 session,不需要活跃的 ctx。
446
+ if (!live?.session) {
406
447
  const written = await appendSelectionEventToLog(ctx, sessionId, provider, model);
407
448
  timer.mark("写会话日志");
408
449
  if (written.error) {
409
- return { error: `成员未挂载,且写入会话日志失败:${written.error}(切换意图已记录,可稍后重试)` };
450
+ return { error: `成员未挂载,且写入会话日志失败:${written.error}${overrideNote},可稍后重试` };
410
451
  }
411
452
  return { via: `已写入会话日志的 model/selection 事件(成员未挂载)${written.note ? "," + written.note : ""}${overrideNote},该成员下次运行时生效` };
412
453
  }
@@ -430,7 +471,12 @@ async function applyModelSwitch(ctx, sessionId, provider, model, agent, timer) {
430
471
  } catch (error) {
431
472
  eventNote = `,事件写入失败:${String(error?.message ?? error)}`;
432
473
  }
433
- let via = `model/selection 事件(官方投影接管)${eventNote}${overrideNote},下一回合生效`;
474
+ // ④-补:把结果同步进 live agent 的 options —— 官方「查看子智能体」视图显示的是它
475
+ // (memberView 读 live.options.model),不跟着改就会一直显示创建时的老模型。
476
+ const synced = syncLiveAgentOptions(ctx, live, provider, model);
477
+ timer.mark("同步视图");
478
+ let via = `model/selection 事件(官方投影接管)${eventNote}${overrideNote},下一回合生效`
479
+ + (synced ? ";已同步成员视图的模型显示" : "");
434
480
  // ⑤ 官方接口优先(若该部署暴露):
435
481
  // · 新版:ctx.apiProxy.sessions.selectModel(会话级模型选择,持久化到会话)
436
482
  // · 旧版:ctx.get("sessionController").selectModel(Typert @Remote,服务端直调可能不可用)
@@ -549,8 +595,10 @@ export async function readLastRequestModel(sessionId, root = null) {
549
595
  const { StringDecoder } = await import("node:string_decoder");
550
596
  const buf = await readFile(file); // 日志最大可达数十 MB,异步读避免阻塞事件循环
551
597
  const { scanZstdFrames } = await import("../../zstd-frames.js");
552
- // 只流式解码最后若干帧:请求头/选择意图判定只关心最近一次,够用且省内存
553
- const TAIL_FRAMES = 8;
598
+ // 只流式解码尾部若干帧:判定只关心最近一次,够用且省内存。
599
+ // 【为什么不是 8】一个回合常有上百个事件(step/tool/assistant 各占一帧),
600
+ // 请求头往往落在倒数几十帧之外;取 8 帧会把「跑过很多轮」的会话误报成「从未运行过」。
601
+ const TAIL_FRAMES = 400;
554
602
  let tailFrames;
555
603
  try {
556
604
  const { frames } = scanZstdFrames(buf);
@@ -565,10 +613,8 @@ export async function readLastRequestModel(sessionId, root = null) {
565
613
  let lastRequest = null;
566
614
  let lastSelection = null;
567
615
  const PENDING_LIMIT = 8 * 1024 * 1024; // 单行缓冲上限:超过即视为异常,避免无上限增长
568
- const consume = (line) => {
569
- if (!line) return;
570
- let event;
571
- try { event = JSON.parse(line); } catch { return; }
616
+ // 事件级处理(行级与全量兜底共用):只记「最后一次」请求头与选择意图
617
+ const consumeEvent = (event) => {
572
618
  const hit = modelEventOf(event);
573
619
  if (hit === null) return;
574
620
  if (hit.kind === "request") {
@@ -578,6 +624,12 @@ export async function readLastRequestModel(sessionId, root = null) {
578
624
  lastSelection = hit.value;
579
625
  }
580
626
  };
627
+ const consume = (line) => {
628
+ if (!line) return;
629
+ let event;
630
+ try { event = JSON.parse(line); } catch { return; }
631
+ consumeEvent(event);
632
+ };
581
633
  const onChunk = (chunk) => {
582
634
  pending += decoder.write(chunk);
583
635
  if (pending.length > PENDING_LIMIT) {
@@ -606,5 +658,11 @@ export async function readLastRequestModel(sessionId, root = null) {
606
658
  }
607
659
  pending += decoder.end();
608
660
  consume(pending);
661
+ // 尾部窗口内没有任何模型事件:退回全量流式扫一遍(复用公共底座,常量内存),
662
+ // 避免把「跑过很多轮但最近几帧都是工具/正文」的会话误报成「从未运行过」。
663
+ if (lastRequest === null && lastSelection === null) {
664
+ const { streamSessionEvents } = await import("../../sessions/log-stream.js");
665
+ await streamSessionEvents(buf, consumeEvent);
666
+ }
609
667
  return { lastRequest, lastSelection, scanned };
610
668
  }
@@ -19,6 +19,8 @@ import { applyModelToMany, applyPermissionToMany, renderBatchReport } from "./ba
19
19
  import { registerModelSwitchCommand } from "./command.js";
20
20
  import { registerContextUsageTool } from "../context-usage/index.js";
21
21
  import { registerMemoryRecallTool } from "../memory-recall/index.js";
22
+ // 成员轨迹读取(子代理只有最后一条回复会被自动发回主代理)
23
+ import { registerMemberTraceTool } from "../../sessions/member-trace.js";
22
24
  import { registerSessionMessagingTools } from "../session-messaging/index.js";
23
25
  import { registerPermSwitchCommand } from "./permission-command.js";
24
26
  import { cloneMemberAs, renderCloneMemberResult } from "./clone-member.js";
@@ -96,7 +98,8 @@ async function describeSwitchSnapshot(ctx, sessionId, provider, model) {
96
98
  parameters: {
97
99
  target: { type: "string", description: "目标成员:成员名、sessionId 或标题关键字。留空 = 全部成员(排除 Lead)" },
98
100
  provider: { type: "string", description: "provider 分组名(list_models 的顶层分组)。留空 = 跟随主代理的 provider" },
99
- model: { type: "string", description: "该 provider 下的模型 id(含前缀)。留空 = 跟随主代理的 model" }
101
+ model: { type: "string", description: "该 provider 下的模型 id(含前缀)。留空 = 跟随主代理的 model" },
102
+ override: { type: "boolean", description: "可选。true = 额外写持久 override(每轮强制改写请求,旧行为;用于官方 selection 链路不生效的场景)。默认 false = 只写 model/selection 事件,走官方链路(UI 切换不被压制)" }
100
103
  },
101
104
  output: {
102
105
  schema: { type: "string" },
@@ -122,10 +125,11 @@ async function describeSwitchSnapshot(ctx, sessionId, provider, model) {
122
125
  const pair = await validateModelPair(ctx, provider, model);
123
126
  if (pair.error) return `切换失败:${pair.error}`;
124
127
  // target 留空 = 作用于全部成员;与聊天指令 /model-switch 走同一套目标解析
128
+ const wantOverride = args?.override === true;
125
129
  const resolved = resolveTargets(ctx, args?.target, { caller });
126
130
  if (resolved.error) return `切换失败:${resolved.error}`;
127
131
  if (resolved.all) {
128
- const report = await applyModelToMany(ctx, resolved.targets, { provider, model });
132
+ const report = await applyModelToMany(ctx, resolved.targets, { provider, model, override: wantOverride });
129
133
  const extra = [];
130
134
  if (followedLead) {
131
135
  extra.push(`模型来源:跟随主代理 ${followedLead.sessionId}(${followedLead.modelSource})`);
@@ -135,7 +139,7 @@ async function describeSwitchSnapshot(ctx, sessionId, provider, model) {
135
139
  return renderBatchReport(`批量切换模型 → ${provider}/${model}`, report, extra);
136
140
  }
137
141
  const found = resolved.targets[0];
138
- const switchResult = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent);
142
+ const switchResult = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent, { override: wantOverride });
139
143
  if (switchResult.error) return `切换失败:${switchResult.error}`;
140
144
  const memberLabel = found.member?.name ? `${found.member.name}(${found.sessionId})` : found.sessionId;
141
145
  // 查证(注册 job,不阻塞):登记待查证项 → 该成员下次发起请求时由 agent/request 事件判定
@@ -312,6 +316,14 @@ async function describeSwitchSnapshot(ctx, sessionId, provider, model) {
312
316
  } catch (error) {
313
317
  log(ctx, `记忆取回工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
314
318
  }
319
+ try {
320
+ // 成员轨迹:子代理只有最后一条回复会被自动发回主代理,中间的思考与工具调用靠这个工具读
321
+ if (registerMemberTraceTool(ctx, defineTool, tools)) {
322
+ log(ctx, "成员轨迹工具已注册 (read_member_trace)");
323
+ }
324
+ } catch (error) {
325
+ log(ctx, `成员轨迹工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
326
+ }
315
327
  log(ctx, "成员模型工具已注册 (list_models / set_member_model)");
316
328
  // 把成员「换名」成新成员(新建 + 搬会话内容)。
317
329
  // 宿主把成员名钉死为不可变身份字段:`team/member` 投影里 name/provider/context 一变就抛
@@ -18,6 +18,8 @@
18
18
  //
19
19
  // 【失败即静默】日志缺失/损坏、用量读不到、首回合无上次用量 —— 一律返回 null,绝不阻断回合。
20
20
  import { readRecentUserTurns } from "../../sessions/events.js";
21
+ // 会话目标解析的公共底座(id / 前缀 / 标题 / 成员名;与成员轨迹共用)
22
+ import { resolveTargetSession } from "../../sessions/resolve.js";
21
23
  import { log } from "../../shared/log.js";
22
24
  import { cfg } from "../../core/config.js";
23
25
  import { writeJsonAtomicSync, readJsonSafeSync } from "../../shared/json-store.js";
@@ -329,40 +331,12 @@ export function registerMemoryRecallTool(ctx, defineTool, tools, deps = {}) {
329
331
  * @returns {Promise<{id:string,title?:string|null}|{candidates:Array<{id:string,title?:string|null}>}|null>}
330
332
  */
331
333
  export async function resolveSessionTarget(ctx, want, selfId) {
332
- const { sessionsRootOf } = await import("../../sessions/events.js");
333
- // 异步遍历:会话目录可能上千个,同步 stat/readdir 会把事件循环钉住
334
- const { readdir, stat } = await import("node:fs/promises");
335
- const { join } = await import("node:path");
336
- const root = sessionsRootOf();
337
- const ids = [];
338
- const isDirectory = async (p) => {
339
- try { return (await stat(p)).isDirectory(); } catch { return false; }
340
- };
341
- if (root && await isDirectory(root)) {
342
- for (const proj of await readdir(root)) {
343
- const pp = join(root, proj);
344
- if (!(await isDirectory(pp))) continue;
345
- for (const dir of await readdir(pp)) {
346
- if (!(await isDirectory(join(pp, dir)))) continue;
347
- if (dir.startsWith("session-") || dir === selfId) ids.push(dir);
348
- }
349
- }
350
- }
351
- if (ids.includes(want)) return { id: want };
352
- const byPrefix = ids.filter((id) => id.startsWith(want));
353
- if (byPrefix.length === 1) return { id: byPrefix[0] };
354
- if (byPrefix.length > 1) return { candidates: byPrefix.map((id) => ({ id })) };
355
- // 标题子串:只在落盘索引里找(避免为了解析一个参数去逐份读日志)
356
- try {
357
- const { loadListDiskCache } = await import("../../sessions/list-cache.js");
358
- let entries = {};
359
- try { entries = loadListDiskCache(ctx)?.entries ?? {}; } catch { entries = {}; }
360
- const lower = want.toLowerCase();
361
- const byTitle = Object.entries(entries)
362
- .filter(([, e]) => String(e?.title ?? "").toLowerCase().includes(lower))
363
- .map(([id, e]) => ({ id, title: e?.title ?? null }));
364
- if (byTitle.length === 1) return byTitle[0];
365
- if (byTitle.length > 1) return { candidates: byTitle };
366
- } catch { /* 索引不可用:退化为只按 id 匹配 */ }
367
- return null;
334
+ // 实现已抽到公共底座 sessions/resolve.js(成员轨迹与记忆取回共用同一套解析)。
335
+ //
336
+ // 旧实现在这里用 `dir.startsWith("session-")` 判断会话目录,而子代理/成员会话的目录名是
337
+ // **裸 UUID**(不带 session- 前缀),导致那些会话永远进不了候选集 —— 完整 id 与前缀都解析不出来。
338
+ // 公共底座改为「目录里有会话日志文件」作为判据,与目录命名规则解耦。
339
+ //
340
+ // 顺带获得成员名支持:`recall_memory({ session: "writer" })` 这类写法现在也能解析。
341
+ return await resolveTargetSession(ctx, want, selfId);
368
342
  }
@@ -7,6 +7,7 @@
7
7
  * 其余(会话操作实现、标题工具、日志)在本模块内直接 import,不再依赖 apply 的作用域。
8
8
  */
9
9
  import { log } from '../shared/log.js';
10
+ import { restoreSessionsFromExport } from '../sessions/export-restore.js';
10
11
  import { MSG_NEED_SESSION_ID } from '../shared/constants.js';
11
12
  import { titleString, foldTitle, workspaceNameOf, archiveTitleWithWs, stripArchiveWsPrefix } from './rename/title.js';
12
13
  import { unarchiveSession, deleteSession, undoLastMessage, deleteBatchSessions, deleteByRule, emptyTrash, purgeExpiredTrash } from './session-ops.js';
@@ -107,6 +108,26 @@ export function registerSessionOpsRoutes({ webServer, ctx, send, readJson, inval
107
108
  }
108
109
  };
109
110
 
111
+ // ---------- POST /api/session-conductor/restore-from-export ----------
112
+ // 把「会话导出包」(解压后的明文 jsonl 目录)覆盖回运行时格式。
113
+ // 为什么不能直接拷:导出包是明文 session.v4.jsonl,而运行时读的是带校验和的多帧
114
+ // session.v4.jsonl.zstd;直接拷会被宿主判「总和检查码不匹配」或 corrupt。
115
+ // 默认 dryRun(只报告会覆盖哪些、事件多少),body.dryRun=false 才写盘。
116
+ route("/api/session-conductor/restore-from-export", "restore-from-export-failed", async (body, res) => {
117
+ const exportDir = typeof body?.exportDir === "string" ? body.exportDir : "";
118
+ if (exportDir === "") {
119
+ return send(res, 400, { ok: false, error: { code: "bad-request", message: "需要 exportDir(解压后的导出包目录绝对路径)" } });
120
+ }
121
+ const result = await restoreSessionsFromExport({
122
+ exportDir,
123
+ sessionIds: Array.isArray(body?.sessionIds) ? body.sessionIds : undefined,
124
+ dryRun: body?.dryRun !== false,
125
+ eventsPerFrame: typeof body?.eventsPerFrame === "number" ? body.eventsPerFrame : undefined,
126
+ ctx,
127
+ });
128
+ send(res, result.ok ? 200 : 400, result);
129
+ }, { requireSessionId: false });
130
+
110
131
  // ---------- POST /api/session-conductor/archive ----------
111
132
  // 归档时给标题加「[工作区名] 」前缀(数据层带前缀,面板显示剥离,已归档视图按前缀分组)。
112
133
  route("/api/session-conductor/archive", "archive-failed", async (body, res) => {
@@ -22,6 +22,8 @@ import { join } from "node:path";
22
22
  import { StringDecoder } from "node:string_decoder";
23
23
 
24
24
  import { streamDecodedFrames } from "../zstd-frames.js";
25
+ // 流式逐事件读取的公共底座(与成员轨迹共用,避免帧边界/坏行/解码器收尾各写一套)
26
+ import { streamSessionEvents } from "./log-stream.js";
25
27
  import { findSessionLog } from "../session-log.js";
26
28
 
27
29
  let interruptedTurnClosers = null;
@@ -276,15 +278,12 @@ export async function readRecentUserTurns(ctx, sessionId, opts = {}) {
276
278
  const rounds = Number.isSafeInteger(opts.rounds) && opts.rounds > 0 ? opts.rounds : 3;
277
279
  const perItem = Number.isSafeInteger(opts.maxCharsPerItem) && opts.maxCharsPerItem > 0 ? opts.maxCharsPerItem : DEFAULT_MAX_CHARS_PER_ITEM;
278
280
  const buf = await readFile(file);
279
- const decoder = new StringDecoder("utf8");
280
- let pending = "";
281
281
  const turns = [];
282
282
  let current = null;
283
283
 
284
- const consumeLine = (line) => {
285
- if (!line) return;
286
- let event;
287
- try { event = JSON.parse(line); } catch { return; }
284
+ // 流式逐事件读取走公共底座(log-stream.js):帧边界跨行、坏行跳过、解码器收尾这些细节
285
+ // 与「成员轨迹」共用同一份实现,避免两边各写一套后只在某类会话上少读一段。
286
+ const onEvent = (event) => {
288
287
  const type = event?.type;
289
288
  if (type === "turn/end") {
290
289
  // 回合结束:此后到来的用户消息算新的一轮
@@ -312,17 +311,7 @@ export async function readRecentUserTurns(ctx, sessionId, opts = {}) {
312
311
  }
313
312
  };
314
313
 
315
- await streamDecodedFrames(buf, (chunk) => {
316
- pending += decoder.write(chunk);
317
- let idx;
318
- while ((idx = pending.indexOf("\n")) >= 0) {
319
- const line = pending.slice(0, idx);
320
- pending = pending.slice(idx + 1);
321
- consumeLine(line);
322
- }
323
- });
324
- pending += decoder.end();
325
- consumeLine(pending);
314
+ await streamSessionEvents(buf, onEvent);
326
315
 
327
316
  return turns.map((t) => ({ time: t.time, userText: t.userText, agentText: t.agentText }));
328
317
  }