dsh-session-conductor 1.3.10 → 1.3.13

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.
@@ -3,9 +3,14 @@
3
3
  // 【职责】把一次「切模型」落到实处:持久化 override、必要时等回合暂停、
4
4
  // 写 `model/selection` 事件(可观测 + 官方投影),未挂载时直接改会话日志文件。
5
5
 
6
- import { readFileSync, copyFileSync, writeFileSync, renameSync, mkdirSync, chmodSync, statSync } from "node:fs";
6
+ import { readFileSync, copyFileSync, mkdirSync } from "node:fs";
7
+ import { readFile } from "node:fs/promises";
8
+ import { createZstdDecompress } from "node:zlib";
9
+ import { replaceFileSync } from "../../shared/atomic-file.js";
7
10
  import path from "node:path";
8
11
  import { log } from "../../shared/log.js";
12
+ import { createStageTimer } from "../../shared/timing.js";
13
+ import { cfg } from "../../core/config.js";
9
14
  import { hasOpenTurn } from "../../sessions/turn.js";
10
15
  import { foldLastModelSelection } from "../../sessions/route.js";
11
16
  import { lazyZstd, lazyRepair } from "../../core/lazy.js";
@@ -48,59 +53,127 @@ export async function waitForIdle(agent, timeoutMs = 60000) {
48
53
  *
49
54
  * @returns {Promise<{ok: true, note?: string} | {error: string}>}
50
55
  */
51
- export async function appendSelectionEventToLog(ctx, sessionId, provider, model) {
52
- const persistence = ctx.get("sessionPersistence");
53
- if (!persistence?.listArtifacts) return { error: "sessionPersistence 服务不可用" };
54
- const artifacts = await persistence.listArtifacts();
55
- const artifact = (artifacts ?? []).find((entry) => entry.header?.id === sessionId);
56
- if (!artifact?.path) return { error: `会话日志不存在(sessionId=${sessionId})` };
57
- const filePath = artifact.path;
56
+ /** 读出会话日志的文本行与事件(定位失败/解码失败/空日志返回 error)。 */
57
+ async function readSwitchLog(sessionId) {
58
+ // 0.2.x 已移除 persistence.listArtifacts(原实现因此恒报「服务不可用」,整条写日志路径是死的),
59
+ // 改为按 id 直接定位日志文件 —— 与删除链路用同一套定位(findSessionLogById)。
60
+ const { findSessionLogById, sessionsRootOf } = await import("../../sessions/events.js");
61
+ const filePath = findSessionLogById(sessionId, sessionsRootOf());
62
+ if (!filePath) return { error: `会话日志不存在(sessionId=${sessionId})` };
58
63
  const { decodeAllFrames } = await lazyZstd();
59
64
  let text;
60
65
  try {
61
- text = await decodeAllFrames(readFileSync(filePath));
66
+ text = await decodeAllFrames(await readFile(filePath));
62
67
  } catch (error) {
63
68
  return { error: `会话日志解码失败:${String(error?.message ?? error)}` };
64
69
  }
65
- const lines = String(text).split("\n").filter((l) => l.trim() !== "");
70
+ const lines = String(text).split("\n").filter((line) => line.trim() !== "");
66
71
  if (lines.length === 0) return { error: "会话日志为空" };
67
72
  const events = [];
68
- for (let i = 1; i < lines.length; i++) {
73
+ for (let i = 1; i < lines.length; i += 1) {
69
74
  try {
70
75
  events.push(JSON.parse(lines[i]));
71
76
  } catch {
72
- // 跳过坏行(校验阶段会兜底)
77
+ // 跳过坏行(写入前的 validateSessionText 会兜底)
73
78
  }
74
79
  }
75
- const last = foldLastModelSelection(events);
76
- if (last && last.provider === provider && last.model === model) {
77
- return { ok: true, note: "日志里已是该模型,无需重复写入" };
78
- }
79
- let maxSeq = -1;
80
- for (const e of events) if (typeof e?.seq === "number" && e.seq > maxSeq) maxSeq = e.seq;
81
- const event = { type: "model/selection", seq: maxSeq + 1, time: Date.now(), data: { provider, model } };
82
- const newText = [...lines, JSON.stringify(event)].join("\n") + "\n";
83
- const { validateSessionText, encodeSessionText } = await lazyRepair();
84
- const check = validateSessionText(newText);
85
- if (!check.ok) return { error: `写入后校验失败:${check.problems?.[0] ?? "未知"}` };
86
- // 备份 + 原子写回(与 undo 同款做法)
80
+ return { filePath, lines, events };
81
+ }
82
+
83
+ /** 写回前先备份(备份失败就不写,避免没有回滚点)。 */
84
+ function backupSwitchLog(filePath, sessionId) {
87
85
  const backupDir = path.join(path.dirname(filePath), ".model-switch-backup");
88
86
  try {
89
87
  mkdirSync(backupDir, { recursive: true });
90
- copyFileSync(filePath, path.join(backupDir, `${Date.now()}-${sessionId}.zstd`));
88
+ const file = path.join(backupDir, `${Date.now()}-${sessionId}.zstd`);
89
+ copyFileSync(filePath, file);
90
+ return { ok: true, file };
91
91
  } catch (error) {
92
92
  return { error: `备份失败(未写入):${String(error?.message ?? error)}` };
93
93
  }
94
- const newBuf = await encodeSessionText(newText);
95
- const switchTmpPath = filePath + ".modelswitchtmp";
96
- writeFileSync(switchTmpPath, newBuf);
94
+ }
95
+
96
+ /** 写后自检不合格时把日志还原回备份(宁可不动日志,也不能留下 corrupt 的会话)。 */
97
+ function restoreSwitchLog(backup, filePath) {
98
+ if (!backup?.file) return { error: "没有可用备份,无法回滚" };
97
99
  try {
98
- chmodSync(switchTmpPath, statSync(filePath).mode);
99
- } catch {
100
- // CIFS 无 chmod,尽力
100
+ copyFileSync(backup.file, filePath);
101
+ return { ok: true };
102
+ } catch (error) {
103
+ return { error: `回滚失败:${String(error?.message ?? error)}` };
104
+ }
105
+ }
106
+
107
+ export async function appendSelectionEventToLog(ctx, sessionId, provider, model) {
108
+ const startedAt = Date.now();
109
+ // 这条路径会整份解码会话日志再整份写回,而宿主在同一会话上会并发追加事件
110
+ // (turn/start、agent/inbox/spliced…)。若直接用「读到的 maxSeq+1」写回,就会写入过期序号,
111
+ // 表现为 released v2 row N has seq gap(序号回退),整份会话随即被判 corrupt。
112
+ // 因此这里做三件事:① 写前 CAS(对比快照是否已过期)② 过期就重读重试 ③ 写后自检 + 失败回滚。
113
+ //
114
+ // 【为什么改成「尾部读 + 追加帧」】原实现每次都整份解码(30MB 日志要 1~3s CPU 与数百 MB 内存),
115
+ // 而追加一条事件只需要知道「尾部 seq」。这里只解码最后几帧拿尾部事件,写入也是「把新事件压缩成一个
116
+ // 新帧追加到文件尾」——开销与日志总大小无关,且不回写已有帧(失败不会破坏历史)。
117
+ const { readTailEvents, appendEventFrame } = await import("../../sessions/tail-io.js");
118
+ let logInfo = await readSwitchLog(sessionId);
119
+ if (logInfo.error) return { error: logInfo.error };
120
+ const filePath = logInfo.filePath;
121
+ log(ctx, `切换写日志:定位+首读 ${Date.now() - startedAt}ms(行数 ${logInfo.lines.length})`);
122
+
123
+ const tailOf = (events) => ({
124
+ maxSeq: events.reduce((max, e) => (typeof e?.seq === "number" && e.seq > max ? e.seq : max), -1),
125
+ count: events.length,
126
+ });
127
+
128
+ for (let attempt = 0; attempt < 3; attempt += 1) {
129
+ const { events } = logInfo;
130
+ const last = foldLastModelSelection(events);
131
+ if (last && last.provider === provider && last.model === model) {
132
+ return { ok: true, note: "日志里已是该模型,无需重复写入" };
133
+ }
134
+ const { maxSeq } = tailOf(events);
135
+ const event = { type: "model/selection", seq: maxSeq + 1, time: Date.now(), data: { provider, model } };
136
+
137
+ // ① CAS:写前再读一次尾部,确认快照期间宿主没有追加新事件(尾部 seq 未变才敢写)
138
+ const { readFile } = await import("node:fs/promises");
139
+ let freshTail;
140
+ try {
141
+ freshTail = await readTailEvents(await readFile(filePath), { tailFrames: 3 });
142
+ } catch (error) {
143
+ return { error: `写前重读失败:${String(error?.message ?? error)}` };
144
+ }
145
+ if (tailOf(freshTail.events).maxSeq !== maxSeq) {
146
+ log(ctx, `切换写日志:快照已过期(第 ${attempt + 1} 次,尾部 seq ${maxSeq} → ${tailOf(freshTail.events).maxSeq}),重读重试`);
147
+ logInfo = { ...logInfo, events: freshTail.events };
148
+ continue;
149
+ }
150
+
151
+ // ② 追加帧(appendEventFrame 内部自证首帧契约 + 长度 CAS + fsync)
152
+ const writeStartedAt = Date.now();
153
+ const written = await appendEventFrame(filePath, event);
154
+ log(ctx, `切换写日志:追加帧 ${Date.now() - writeStartedAt}ms(seq ${event.seq})`);
155
+ if (!written.ok) return { error: `写入会话日志失败:${written.error}` };
156
+
157
+ // ③ 写后自检:只读尾部,确认新事件确实落在尾部且 seq 严格 +1 衔接
158
+ try {
159
+ const after = await readTailEvents(await readFile(filePath), { tailFrames: 3 });
160
+ const seqs = after.events.map((e) => e?.seq).filter((n) => typeof n === "number");
161
+ const tailOk = seqs.length > 0 && seqs.at(-1) === event.seq
162
+ && seqs.every((n, i) => i === 0 || n === seqs[i - 1] + 1);
163
+ if (tailOk) return { ok: true, note: "已直接写入会话日志" };
164
+ log(ctx, `切换写日志:写后自检失败(尾部 ${JSON.stringify(seqs.slice(-3))}),回滚`);
165
+ } catch (error) {
166
+ log(ctx, `切换写日志:写后自检读取失败:${String(error?.message ?? error)}`);
167
+ }
168
+ // 追加帧写坏时只能整份回退:用备份整份恢复
169
+ const backup = backupSwitchLog(filePath, sessionId);
170
+ if (backup.error) return { error: `写后自检失败且无备份可回滚:${backup.error}` };
171
+ restoreSwitchLog(backup, filePath);
172
+ return { error: "写入后 seq 不连续,已回滚(不改日志,改用选择意图生效)" };
101
173
  }
102
- renameSync(switchTmpPath, filePath);
103
- return { ok: true, note: "已直接写入会话日志" };
174
+ // 一直有并发写入:放弃写日志(不算失败,选择意图仍会生效)
175
+ log(ctx, "切换写日志:并发写入持续存在,放弃写日志(不影响切换)");
176
+ return { ok: true, note: "会话正在被并发写入,跳过日志写入(选择意图仍生效)" };
104
177
  }
105
178
 
106
179
  /**
@@ -120,21 +193,81 @@ export async function appendSelectionEventToLog(ctx, sessionId, provider, model)
120
193
  * @param {object|null} agent 可选 live agent(不传则内部按 sessionId 查)
121
194
  * @returns {{via: string} | {error: string}}
122
195
  */
123
- export async function switchAgentModel(ctx, sessionId, provider, model, agent = null) {
124
- if (!sessionId) return { error: "缺少 sessionId(无法定位目标会话)" };
125
- // ① 持久化切换意图(不依赖 live agent——成员 inactive/未挂载时也能切,下次运行生效)
126
- let overrideNote = "";
196
+ /**
197
+ * ① 持久化切换意图:不依赖 live agent —— 成员 inactive/未挂载时也能切,下次运行生效。
198
+ * @returns {Promise<{note: string}>} note 直接拼进回执
199
+ */
200
+ async function persistSwitchIntent(ctx, sessionId, provider, model) {
127
201
  try {
128
202
  await setMemberModelOverride(ctx, sessionId, provider, model);
129
- overrideNote = ",已记录切换意图";
203
+ return { note: ",已记录切换意图" };
204
+ } catch (error) {
205
+ return { note: `,意图持久化失败:${String(error?.message ?? error)}` };
206
+ }
207
+ }
208
+
209
+ /**
210
+ * ⑤ 官方接口优先(若该部署暴露):
211
+ * · 新版 ctx.apiProxy.sessions.selectModel(会话级模型选择,持久化到会话)
212
+ * · 旧版 ctx.get("sessionController").selectModel(Typert @Remote,服务端直调可能不可用)
213
+ * 任一成功返回 {via};都不可用/失败返回 null(调用方回退到「写事件」机制)。
214
+ */
215
+ async function tryOfficialSelectModel(ctx, sessionId, provider, model, overrideNote) {
216
+ // 可关:某些部署上 selectModel(Typert @Remote)会长时间不返回,表现为「切模型卡住」。
217
+ // 配置 modelSwitchOfficialApi=false 即只走「写 model/selection 事件」这条实测可用的路径。
218
+ if (cfg.modelSwitchOfficialApi === false) {
219
+ log(ctx, "已按配置跳过官方 selectModel(modelSwitchOfficialApi=false),仅写事件");
220
+ return null;
221
+ }
222
+ const apiProxy = ctx.apiProxy ?? (typeof ctx.get === "function" ? ctx.get("apiProxy") : null);
223
+ const proxySessions = apiProxy?.sessions;
224
+ if (typeof proxySessions?.selectModel === "function") {
225
+ const startedAt = Date.now();
226
+ try {
227
+ await proxySessions.selectModel({ sessionId, provider, model });
228
+ log(ctx, `apiProxy.sessions.selectModel 成功,耗时 ${Date.now() - startedAt}ms`);
229
+ return { via: `ctx.apiProxy.sessions.selectModel(官方,会话级持久)${overrideNote}` };
230
+ } catch (error) {
231
+ log(ctx, `apiProxy.sessions.selectModel 失败(耗时 ${Date.now() - startedAt}ms,回退事件机制): ${String(error?.message ?? error)}`);
232
+ }
233
+ }
234
+ const sc = ctx.get("sessionController");
235
+ if (typeof sc?.selectModel !== "function") return null;
236
+ const remoteStartedAt = Date.now();
237
+ try {
238
+ await sc.selectModel({ sessionId, provider, model });
239
+ log(ctx, `sessionController.selectModel 成功,耗时 ${Date.now() - remoteStartedAt}ms`);
240
+ return { via: `sessionController.selectModel(官方,立即生效)${overrideNote}` };
130
241
  } catch (error) {
131
- overrideNote = `,意图持久化失败:${String(error?.message ?? error)}`;
242
+ // 服务端直调 @Remote 可能不被支持——已走「写事件」机制,这里只留日志
243
+ log(ctx, `selectModel 直调不可用(耗时 ${Date.now() - remoteStartedAt}ms,已用 model/selection 事件机制): ${String(error?.message ?? error)}`);
244
+ return null;
132
245
  }
246
+ }
247
+
248
+ export async function switchAgentModel(ctx, sessionId, provider, model, agent = null) {
249
+ const timer = createStageTimer();
250
+ try {
251
+ return await applyModelSwitch(ctx, sessionId, provider, model, agent, timer);
252
+ } finally {
253
+ // 无论成功失败都留一行分阶段耗时:排查「切模型卡很久」时直接看慢在哪一段
254
+ log(ctx, `模型切换耗时:${timer.summary()}(标记合计 ${timer.total()}ms)`);
255
+ }
256
+ }
257
+
258
+ /** 切换主流程(逐段打点,计时器只观测、不改变行为)。 */
259
+ async function applyModelSwitch(ctx, sessionId, provider, model, agent, timer) {
260
+ if (!sessionId) return { error: "缺少 sessionId(无法定位目标会话)" };
261
+ // ① 持久化切换意图(不依赖 live agent——成员 inactive/未挂载时也能切,下次运行生效)
262
+ const { note: overrideNote } = await persistSwitchIntent(ctx, sessionId, provider, model);
263
+ timer.mark("写意图");
133
264
  const live = agent ?? ctx.get("agents")?.get?.(sessionId) ?? null;
265
+ timer.mark("定位成员");
134
266
  // ② 成员当前未挂载(inactive):直接把事件写进会话日志——官方 selectionFor 读投影即生效。
135
267
  // (Team 成员唤醒走 sendMessage → followup,不经过 resumeSetupFor,只记意图不会被应用。)
136
268
  if (typeof live?.ctx?.on !== "function" || !live?.session) {
137
269
  const written = await appendSelectionEventToLog(ctx, sessionId, provider, model);
270
+ timer.mark("写会话日志");
138
271
  if (written.error) {
139
272
  return { error: `成员未挂载,且写入会话日志失败:${written.error}(切换意图已记录,可稍后重试)` };
140
273
  }
@@ -143,7 +276,9 @@ export async function switchAgentModel(ctx, sessionId, provider, model, agent =
143
276
  // ③ 正在执行回合:等它暂停再切(当前回合已用旧模型发出请求,直接切会「只生效一回合」)
144
277
  const IDLE_WAIT_MS = 60_000;
145
278
  if (live.status === "running" || hasOpenTurn(live.session?.events)) {
279
+ log(ctx, `目标成员在运行中(status=${live.status ?? "?"}),先等其空闲再切(最多 ${IDLE_WAIT_MS}ms)`);
146
280
  const idle = await waitForIdle(live, IDLE_WAIT_MS);
281
+ timer.mark("等空闲");
147
282
  if (!idle) {
148
283
  return { error: `成员正在执行回合(status=${live.status ?? "?"}),已等待 60s 仍未结束——请稍后重试,或先用 interrupt_agent 中断该成员` };
149
284
  }
@@ -154,6 +289,7 @@ export async function switchAgentModel(ctx, sessionId, provider, model, agent =
154
289
  try {
155
290
  live.session.append("model/selection", { provider, model });
156
291
  eventNote = ",已写入 model/selection 事件";
292
+ timer.mark("写事件");
157
293
  } catch (error) {
158
294
  eventNote = `,事件写入失败:${String(error?.message ?? error)}`;
159
295
  }
@@ -162,26 +298,9 @@ export async function switchAgentModel(ctx, sessionId, provider, model, agent =
162
298
  // · 新版:ctx.apiProxy.sessions.selectModel(会话级模型选择,持久化到会话)
163
299
  // · 旧版:ctx.get("sessionController").selectModel(Typert @Remote,服务端直调可能不可用)
164
300
  // 两者任一成功都直接返回——它们比「只写事件」更权威(含模型归一化等官方处理)。
165
- const apiProxy = ctx.apiProxy ?? (typeof ctx.get === "function" ? ctx.get("apiProxy") : null);
166
- const proxySessions = apiProxy?.sessions;
167
- if (typeof proxySessions?.selectModel === "function") {
168
- try {
169
- await proxySessions.selectModel({ sessionId, provider, model });
170
- return { via: `ctx.apiProxy.sessions.selectModel(官方,会话级持久)${overrideNote}` };
171
- } catch (error) {
172
- log(ctx, `apiProxy.sessions.selectModel 失败(回退事件机制): ${String(error?.message ?? error)}`);
173
- }
174
- }
175
- const sc = ctx.get("sessionController");
176
- if (typeof sc?.selectModel === "function") {
177
- try {
178
- await sc.selectModel({ sessionId, provider, model });
179
- via = `sessionController.selectModel(官方,立即生效)${overrideNote}`;
180
- } catch (error) {
181
- // 服务端直调 @Remote 可能不被支持——已走上面的「写事件」机制,这里只留日志
182
- log(ctx, `selectModel 直调不可用(已用 model/selection 事件机制): ${String(error?.message ?? error)}`);
183
- }
184
- }
301
+ const official = await tryOfficialSelectModel(ctx, sessionId, provider, model, overrideNote);
302
+ timer.mark("官方接口");
303
+ if (official) return official;
185
304
  return { via };
186
305
  }
187
306
 
@@ -250,6 +369,26 @@ export function resetSwitchWatchForTest() {
250
369
  pendingOutcomes.clear();
251
370
  }
252
371
 
372
+ /**
373
+ * 把一个会话事件归类成「请求头」或「切换意图」,都不是则返回 null。
374
+ * 抽出来是为了让 readLastRequestModel 的流式解析循环只负责「读一行 → 交给它判」。
375
+ * @returns {{kind:'request'|'selection', value:{provider:string, model:string, time:number|null}}|null}
376
+ */
377
+ function modelEventOf(event) {
378
+ if (event?.type === "request/header") {
379
+ const config = event?.data?.header?.config;
380
+ if (!config?.provider || !config?.model) return null;
381
+ return { kind: "request", value: { provider: config.provider, model: config.model, time: event.time ?? null } };
382
+ }
383
+ if (event?.type === "model/selection" && event?.data?.provider) {
384
+ return {
385
+ kind: "selection",
386
+ value: { provider: event.data.provider, model: event.data.model, time: event.time ?? null },
387
+ };
388
+ }
389
+ return null;
390
+ }
391
+
253
392
  /**
254
393
  * 读会话日志里「最近一次实际请求使用的模型」与「最近一次切换意图」。
255
394
  *
@@ -258,6 +397,11 @@ export function resetSwitchWatchForTest() {
258
397
  * · `model/selection` 的 `data.{provider,model}` —— 切换意图(有则一并返回,便于对照)。
259
398
  * 用按 zstd 帧流式读(常量内存),大日志也不会把整份文本读进内存。
260
399
  *
400
+ * 【内存纪律】① `pending` 加上限:只在「遇到换行才切走」的缓冲区,若某帧内长时间没有换行
401
+ * (单行超大事件,例如几 MB 的工具结果)会无限增长;超过上限直接抛错,不再硬撑。
402
+ * ② `readFile` 换成只读**最后若干帧**:查证只关心「最近一次请求」,没必要把整份文件读进内存
403
+ * (30MB 日志时这一项就省掉一份整文件 Buffer)。
404
+ *
261
405
  * @returns {Promise<{lastRequest: {provider,model,time}|null, lastSelection: {provider,model,time}|null, scanned: number}>}
262
406
  */
263
407
  export async function readLastRequestModel(sessionId, root = null) {
@@ -266,34 +410,63 @@ export async function readLastRequestModel(sessionId, root = null) {
266
410
  if (!file) return { lastRequest: null, lastSelection: null, scanned: 0 };
267
411
  const { streamDecodedFrames } = await import("../../zstd-frames.js");
268
412
  const { StringDecoder } = await import("node:string_decoder");
269
- const buf = readFileSync(file);
413
+ const buf = await readFile(file); // 日志最大可达数十 MB,异步读避免阻塞事件循环
414
+ const { scanZstdFrames } = await import("../../zstd-frames.js");
415
+ // 只流式解码最后若干帧:请求头/选择意图判定只关心最近一次,够用且省内存
416
+ const TAIL_FRAMES = 8;
417
+ let tailFrames;
418
+ try {
419
+ const { frames } = scanZstdFrames(buf);
420
+ tailFrames = frames.slice(Math.max(0, frames.length - TAIL_FRAMES));
421
+ } catch {
422
+ // 帧扫描失败(文件损坏):退回整份流式解码,让上层按损坏处理,不在这里静默丢数据
423
+ tailFrames = null;
424
+ }
270
425
  const decoder = new StringDecoder("utf8");
271
426
  let pending = "";
272
427
  let scanned = 0;
273
428
  let lastRequest = null;
274
429
  let lastSelection = null;
430
+ const PENDING_LIMIT = 8 * 1024 * 1024; // 单行缓冲上限:超过即视为异常,避免无上限增长
275
431
  const consume = (line) => {
276
432
  if (!line) return;
277
433
  let event;
278
434
  try { event = JSON.parse(line); } catch { return; }
279
- if (event?.type === "request/header") {
280
- const cfg = event?.data?.header?.config;
281
- if (cfg?.provider && cfg?.model) { lastRequest = { provider: cfg.provider, model: cfg.model, time: event.time ?? null }; scanned += 1; }
282
- return;
283
- }
284
- if (event?.type === "model/selection" && event?.data?.provider) {
285
- lastSelection = { provider: event.data.provider, model: event.data.model, time: event.time ?? null };
435
+ const hit = modelEventOf(event);
436
+ if (hit === null) return;
437
+ if (hit.kind === "request") {
438
+ lastRequest = hit.value;
439
+ scanned += 1;
440
+ } else {
441
+ lastSelection = hit.value;
286
442
  }
287
443
  };
288
- await streamDecodedFrames(buf, (chunk) => {
444
+ const onChunk = (chunk) => {
289
445
  pending += decoder.write(chunk);
446
+ if (pending.length > PENDING_LIMIT) {
447
+ throw new Error(`readLastRequestModel 单行缓冲超过 ${PENDING_LIMIT} 字节仍无换行(疑似异常大事件)`);
448
+ }
290
449
  let idx;
291
450
  while ((idx = pending.indexOf("\n")) >= 0) {
292
451
  const line = pending.slice(0, idx);
293
452
  pending = pending.slice(idx + 1);
294
453
  consume(line);
295
454
  }
296
- });
455
+ };
456
+ if (tailFrames === null) {
457
+ await streamDecodedFrames(buf, onChunk);
458
+ } else {
459
+ for (const fr of tailFrames) {
460
+ await new Promise((resolve, reject) => {
461
+ const d = createZstdDecompress();
462
+ d.on("data", (c) => { try { onChunk(c); } catch (error) { reject(error); } });
463
+ d.on("end", resolve);
464
+ d.on("error", reject);
465
+ d.write(buf.subarray(fr.start, fr.end));
466
+ d.end();
467
+ });
468
+ }
469
+ }
297
470
  pending += decoder.end();
298
471
  consume(pending);
299
472
  return { lastRequest, lastSelection, scanned };
@@ -20,8 +20,8 @@
20
20
  import { readRecentUserTurns } from "../../sessions/events.js";
21
21
  import { log } from "../../shared/log.js";
22
22
  import { cfg } from "../../core/config.js";
23
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
24
- import { dirname, join } from "node:path";
23
+ import { writeJsonAtomicSync, readJsonSafeSync } from "../../shared/json-store.js";
24
+ import { join } from "node:path";
25
25
 
26
26
  // ── 用量记忆的**持久化**(解决「重启后不触发」)──────────────────────────────
27
27
  // 只用内存 WeakMap 时实例一重启就清空 ⇒ 首个回合没有「上次用量」可比 ⇒
@@ -54,12 +54,9 @@ function loadDiskState() {
54
54
  diskState = {};
55
55
  const file = stateFilePath();
56
56
  if (!file) return diskState;
57
- try {
58
- const parsed = JSON.parse(readFileSync(file, "utf8"));
59
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) diskState = parsed;
60
- } catch {
61
- // 首次运行 / 文件损坏:按空表继续
62
- }
57
+ // 统一读实现:不存在/损坏都返回 null,按空表继续
58
+ const parsed = readJsonSafeSync(file);
59
+ if (parsed && !Array.isArray(parsed)) diskState = parsed;
63
60
  return diskState;
64
61
  }
65
62
 
@@ -71,9 +68,7 @@ function saveDiskStateThrottled(ctx) {
71
68
  const file = stateFilePath();
72
69
  if (!file) return;
73
70
  try {
74
- mkdirSync(dirname(file), { recursive: true });
75
- writeFileSync(`${file}.tmp`, JSON.stringify(diskState), "utf8");
76
- renameSync(`${file}.tmp`, file);
71
+ writeJsonAtomicSync(file, diskState); // 统一原子写(tmp + rename)
77
72
  } catch (error) {
78
73
  log(ctx, `记忆取回:用量状态落盘失败(不影响功能): ${String(error?.message ?? error)}`);
79
74
  }
@@ -254,6 +249,26 @@ const TEXT_OUTPUT = {
254
249
  *
255
250
  * @returns {boolean} 是否成功注册
256
251
  */
252
+ /**
253
+ * 解析 recall_memory 的 `session` 参数:
254
+ * · 缺省 / 空 / 等于自身 ⇒ 取自身(行为与未加该参数时逐字一致)
255
+ * · 多个候选 / 找不到 ⇒ 返回可直接回给用户的 message(不猜一个)
256
+ * @param {Function} resolve 会话解析函数(由工具注册处注入,默认 resolveSessionTarget)
257
+ * @returns {Promise<{targetId: string, fromOther: boolean} | {message: string}>}
258
+ */
259
+ async function resolveRecallTarget(ctx, want, selfId, resolve) {
260
+ if (!want || want === selfId) return { targetId: selfId, fromOther: false };
261
+ const hit = await resolve(ctx, want, selfId);
262
+ if (hit?.candidates) {
263
+ return {
264
+ message: `「${want}」匹配到多个目标,请用更完整的 id 重试:\n`
265
+ + hit.candidates.slice(0, 10).map((c) => `- ${c.id}${c.title ? ` | ${c.title}` : ""}`).join("\n"),
266
+ };
267
+ }
268
+ if (!hit?.id) return { message: `找不到目标会话「${want}」:没有匹配的会话 id / id 前缀 / 标题。` };
269
+ return { targetId: hit.id, fromOther: true };
270
+ }
271
+
257
272
  export function registerMemoryRecallTool(ctx, defineTool, tools, deps = {}) {
258
273
  if (typeof defineTool !== "function" || typeof tools?.register !== "function") return false;
259
274
  const read = deps.readTurns ?? readRecentUserTurns;
@@ -279,19 +294,9 @@ export function registerMemoryRecallTool(ctx, defineTool, tools, deps = {}) {
279
294
  const asked = Number.isSafeInteger(args?.rounds) && args.rounds > 0 ? args.rounds : (config?.retrieveMemoryRounds ?? 3);
280
295
  const rounds = Math.min(asked, maxRounds);
281
296
  const want = typeof args?.session === "string" ? args.session.trim() : "";
282
- // 缺省 / 空 / 等于自身 ⇒ 走自身路径,行为与之前逐字一致
283
- let targetId = selfId;
284
- let fromOther = false;
285
- if (want && want !== selfId) {
286
- const hit = await resolve(ctx, want, selfId);
287
- if (hit?.candidates) {
288
- return `「${want}」匹配到多个目标,请用更完整的 id 重试:\n`
289
- + hit.candidates.slice(0, 10).map((c) => `- ${c.id}${c.title ? ` | ${c.title}` : ""}`).join("\n");
290
- }
291
- if (!hit?.id) return `找不到目标会话「${want}」:没有匹配的会话 id / id 前缀 / 标题。`;
292
- targetId = hit.id;
293
- fromOther = true;
294
- }
297
+ const target = await resolveRecallTarget(ctx, want, selfId, resolve);
298
+ if (target.message) return target.message;
299
+ const { targetId, fromOther } = target;
295
300
  try {
296
301
  const turns = await read(ctx, targetId, {
297
302
  rounds,
@@ -325,16 +330,20 @@ export function registerMemoryRecallTool(ctx, defineTool, tools, deps = {}) {
325
330
  */
326
331
  export async function resolveSessionTarget(ctx, want, selfId) {
327
332
  const { sessionsRootOf } = await import("../../sessions/events.js");
328
- const { readdirSync, existsSync, statSync } = await import("node:fs");
333
+ // 异步遍历:会话目录可能上千个,同步 stat/readdir 会把事件循环钉住
334
+ const { readdir, stat } = await import("node:fs/promises");
329
335
  const { join } = await import("node:path");
330
336
  const root = sessionsRootOf();
331
337
  const ids = [];
332
- if (root && existsSync(root)) {
333
- for (const proj of readdirSync(root)) {
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)) {
334
343
  const pp = join(root, proj);
335
- try { if (!statSync(pp).isDirectory()) continue; } catch { continue; }
336
- for (const dir of readdirSync(pp)) {
337
- try { if (!statSync(join(pp, dir)).isDirectory()) continue; } catch { continue; }
344
+ if (!(await isDirectory(pp))) continue;
345
+ for (const dir of await readdir(pp)) {
346
+ if (!(await isDirectory(join(pp, dir)))) continue;
338
347
  if (dir.startsWith("session-") || dir === selfId) ids.push(dir);
339
348
  }
340
349
  }
@@ -63,29 +63,38 @@ export async function runAnalysis(ctx, sessionId, opts = {}) {
63
63
  * modelCatalog 匹配 provider;匹配不到则借用当前路由的 provider)。
64
64
  * @returns {provider, model} 或 null(无法解析)
65
65
  */
66
- export async function resolveModelOverride(ctx, modelArg, fallbackRoute) {
67
- const raw = String(modelArg ?? "").trim();
68
- if (!raw) return null;
69
- // "provider/model" 显式拆分;不匹配(含纯模型名)→ 走 catalog 匹配/fallback
70
- const explicit = /^([^/]+)\/(.+)$/.exec(raw);
71
- if (explicit) {
72
- const provider = explicit[1].trim();
73
- const model = explicit[2].trim();
74
- if (provider && model) return { provider, model };
75
- }
76
- // 纯模型名:modelCatalog 里找第一个含该 model 的分组
66
+ /** 拆 "provider/model" 形式;不是该形式返回 null(含纯模型名)。 */
67
+ function splitExplicitModel(raw) {
68
+ const matched = /^([^/]+)\/(.+)$/.exec(raw);
69
+ if (!matched) return null;
70
+ const provider = matched[1].trim();
71
+ const model = matched[2].trim();
72
+ return provider && model ? { provider, model } : null;
73
+ }
74
+
75
+ /** 纯模型名:在 modelCatalog 里找第一个包含该模型的分组;catalog 不可用返回 null。 */
76
+ async function providerOfModelName(ctx, raw) {
77
77
  try {
78
78
  const sc = ctx.get("sessionController");
79
79
  const catalog = sc?.modelCatalog ? await sc.modelCatalog() : null;
80
- const groups = catalog?.groups ?? [];
81
- for (const g of groups) {
82
- if ((g.models ?? []).some((m) => m.id === raw || m.name === raw)) {
83
- return { provider: g.id, model: raw };
80
+ for (const group of catalog?.groups ?? []) {
81
+ if ((group.models ?? []).some((m) => m.id === raw || m.name === raw)) {
82
+ return { provider: group.id, model: raw };
84
83
  }
85
84
  }
86
85
  } catch {
87
- // catalog 不可用 → 落到 fallback provider
86
+ // catalog 不可用 → 交给 fallback provider
88
87
  }
88
+ return null;
89
+ }
90
+
91
+ export async function resolveModelOverride(ctx, modelArg, fallbackRoute) {
92
+ const raw = String(modelArg ?? "").trim();
93
+ if (!raw) return null;
94
+ const explicit = splitExplicitModel(raw);
95
+ if (explicit) return explicit;
96
+ const byCatalog = await providerOfModelName(ctx, raw);
97
+ if (byCatalog) return byCatalog;
89
98
  if (fallbackRoute?.provider) return { provider: fallbackRoute.provider, model: raw };
90
99
  return null;
91
100
  }
@@ -52,25 +52,37 @@ export function foldTitle(events) {
52
52
  }
53
53
 
54
54
  /** 从会话 cwd 推导工作区名:优先匹配 workspaceRegistry 分组 title,否则取 cwd basename。 */
55
- export function workspaceNameOf(ctx, cwd) {
56
- if (typeof cwd !== "string" || cwd === "") return "";
57
- const registry = ctx?.get?.("workspaceRegistry");
58
- if (registry) {
59
- try {
60
- for (const workspace of registry.list?.() ?? []) {
61
- const path = String(workspace?.path ?? "").replace(/\/+$/, "");
62
- const normalizedCwd = String(cwd).replace(/\/+$/, "");
63
- if (path !== "" && (normalizedCwd === path || normalizedCwd.startsWith(path + "/"))) {
64
- if (typeof workspace.title === "string" && workspace.title.trim() !== "") return workspace.title.trim();
65
- }
66
- }
67
- } catch {
68
- // 分组服务不可用时回退 basename
55
+ /**
56
+ * 在 workspaceRegistry 里找 cwd 所属工作区的标题(**第一个命中即返回**,与原实现同口径)。
57
+ * 服务不可用/无匹配返回 ""。
58
+ */
59
+ function workspaceTitleOf(registry, normalizedCwd) {
60
+ try {
61
+ for (const workspace of registry.list?.() ?? []) {
62
+ const path = String(workspace?.path ?? "").replace(/\/+$/, "");
63
+ if (path === "") continue;
64
+ if (normalizedCwd !== path && !normalizedCwd.startsWith(path + "/")) continue;
65
+ const title = typeof workspace.title === "string" ? workspace.title.trim() : "";
66
+ if (title !== "") return title;
69
67
  }
68
+ } catch {
69
+ // 分组服务不可用:交给调用方回退 basename
70
70
  }
71
+ return "";
72
+ }
73
+
74
+ /** 取路径最后一段(工作区服务不可用时的兜底显示名)。 */
75
+ function basenameOfPath(cwd) {
71
76
  return String(cwd).split(/[/\\]/).filter(Boolean).pop() ?? "";
72
77
  }
73
78
 
79
+ export function workspaceNameOf(ctx, cwd) {
80
+ if (typeof cwd !== "string" || cwd === "") return "";
81
+ const registry = ctx?.get?.("workspaceRegistry");
82
+ const title = registry ? workspaceTitleOf(registry, cwd.replace(/\/+$/, "")) : "";
83
+ return title !== "" ? title : basenameOfPath(cwd);
84
+ }
85
+
74
86
  /** 给标题加工作区前缀(幂等:已带前缀不叠加)。返回 {title, ws}。 */
75
87
  export function archiveTitleWithWs(title, ws) {
76
88
  const t = String(title ?? "");