dsh-session-conductor 1.3.11 → 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.
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { readFileSync, copyFileSync, mkdirSync } from "node:fs";
7
7
  import { readFile } from "node:fs/promises";
8
+ import { createZstdDecompress } from "node:zlib";
8
9
  import { replaceFileSync } from "../../shared/atomic-file.js";
9
10
  import path from "node:path";
10
11
  import { log } from "../../shared/log.js";
@@ -84,36 +85,95 @@ function backupSwitchLog(filePath, sessionId) {
84
85
  const backupDir = path.join(path.dirname(filePath), ".model-switch-backup");
85
86
  try {
86
87
  mkdirSync(backupDir, { recursive: true });
87
- copyFileSync(filePath, path.join(backupDir, `${Date.now()}-${sessionId}.zstd`));
88
- return { ok: true };
88
+ const file = path.join(backupDir, `${Date.now()}-${sessionId}.zstd`);
89
+ copyFileSync(filePath, file);
90
+ return { ok: true, file };
89
91
  } catch (error) {
90
92
  return { error: `备份失败(未写入):${String(error?.message ?? error)}` };
91
93
  }
92
94
  }
93
95
 
96
+ /** 写后自检不合格时把日志还原回备份(宁可不动日志,也不能留下 corrupt 的会话)。 */
97
+ function restoreSwitchLog(backup, filePath) {
98
+ if (!backup?.file) return { error: "没有可用备份,无法回滚" };
99
+ try {
100
+ copyFileSync(backup.file, filePath);
101
+ return { ok: true };
102
+ } catch (error) {
103
+ return { error: `回滚失败:${String(error?.message ?? error)}` };
104
+ }
105
+ }
106
+
94
107
  export async function appendSelectionEventToLog(ctx, sessionId, provider, model) {
95
108
  const startedAt = Date.now();
96
- const logInfo = await readSwitchLog(sessionId);
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);
97
119
  if (logInfo.error) return { error: logInfo.error };
98
- const { filePath, lines, events } = logInfo;
99
- // 这条路径要整份解码会话日志(几十 MB 时会明显耗时),把耗时单独记出来才能和「卡住」区分
100
- log(ctx, `切换写日志:定位+解码 ${Date.now() - startedAt}ms(行数 ${lines.length})`);
101
- const last = foldLastModelSelection(events);
102
- if (last && last.provider === provider && last.model === model) {
103
- return { ok: true, note: "日志里已是该模型,无需重复写入" };
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 不连续,已回滚(不改日志,改用选择意图生效)" };
104
173
  }
105
- const maxSeq = events.reduce((max, e) => (typeof e?.seq === "number" && e.seq > max ? e.seq : max), -1);
106
- const event = { type: "model/selection", seq: maxSeq + 1, time: Date.now(), data: { provider, model } };
107
- const newText = [...lines, JSON.stringify(event)].join("\n") + "\n";
108
- const { validateSessionText, encodeSessionText } = await lazyRepair();
109
- const check = validateSessionText(newText);
110
- if (!check.ok) return { error: `写入后校验失败:${check.problems?.[0] ?? "未知"}` };
111
- const backup = backupSwitchLog(filePath, sessionId);
112
- if (backup.error) return backup;
113
- const encodeStartedAt = Date.now();
114
- replaceFileSync(filePath, await encodeSessionText(newText), { tmpSuffix: ".modelswitchtmp" });
115
- log(ctx, `切换写日志:重编码+原子写回 ${Date.now() - encodeStartedAt}ms`);
116
- return { ok: true, note: "已直接写入会话日志" };
174
+ // 一直有并发写入:放弃写日志(不算失败,选择意图仍会生效)
175
+ log(ctx, "切换写日志:并发写入持续存在,放弃写日志(不影响切换)");
176
+ return { ok: true, note: "会话正在被并发写入,跳过日志写入(选择意图仍生效)" };
117
177
  }
118
178
 
119
179
  /**
@@ -337,6 +397,11 @@ function modelEventOf(event) {
337
397
  * · `model/selection` 的 `data.{provider,model}` —— 切换意图(有则一并返回,便于对照)。
338
398
  * 用按 zstd 帧流式读(常量内存),大日志也不会把整份文本读进内存。
339
399
  *
400
+ * 【内存纪律】① `pending` 加上限:只在「遇到换行才切走」的缓冲区,若某帧内长时间没有换行
401
+ * (单行超大事件,例如几 MB 的工具结果)会无限增长;超过上限直接抛错,不再硬撑。
402
+ * ② `readFile` 换成只读**最后若干帧**:查证只关心「最近一次请求」,没必要把整份文件读进内存
403
+ * (30MB 日志时这一项就省掉一份整文件 Buffer)。
404
+ *
340
405
  * @returns {Promise<{lastRequest: {provider,model,time}|null, lastSelection: {provider,model,time}|null, scanned: number}>}
341
406
  */
342
407
  export async function readLastRequestModel(sessionId, root = null) {
@@ -346,11 +411,23 @@ export async function readLastRequestModel(sessionId, root = null) {
346
411
  const { streamDecodedFrames } = await import("../../zstd-frames.js");
347
412
  const { StringDecoder } = await import("node:string_decoder");
348
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
+ }
349
425
  const decoder = new StringDecoder("utf8");
350
426
  let pending = "";
351
427
  let scanned = 0;
352
428
  let lastRequest = null;
353
429
  let lastSelection = null;
430
+ const PENDING_LIMIT = 8 * 1024 * 1024; // 单行缓冲上限:超过即视为异常,避免无上限增长
354
431
  const consume = (line) => {
355
432
  if (!line) return;
356
433
  let event;
@@ -364,15 +441,32 @@ export async function readLastRequestModel(sessionId, root = null) {
364
441
  lastSelection = hit.value;
365
442
  }
366
443
  };
367
- await streamDecodedFrames(buf, (chunk) => {
444
+ const onChunk = (chunk) => {
368
445
  pending += decoder.write(chunk);
446
+ if (pending.length > PENDING_LIMIT) {
447
+ throw new Error(`readLastRequestModel 单行缓冲超过 ${PENDING_LIMIT} 字节仍无换行(疑似异常大事件)`);
448
+ }
369
449
  let idx;
370
450
  while ((idx = pending.indexOf("\n")) >= 0) {
371
451
  const line = pending.slice(0, idx);
372
452
  pending = pending.slice(idx + 1);
373
453
  consume(line);
374
454
  }
375
- });
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
+ }
376
470
  pending += decoder.end();
377
471
  consume(pending);
378
472
  return { lastRequest, lastSelection, scanned };
@@ -0,0 +1,155 @@
1
+ // dsh-session-conductor — 会话日志的「尾部」增量读写
2
+ //
3
+ // 【为什么单独成文件】成员模型切换(/model-switch、set_member_model、create_teammate_with_model)
4
+ // 需要往会话日志追加一条 model/selection 事件。原实现是「整份解码 → 拼全文 → 全量校验 → 整份重编码」,
5
+ // 一次切换要完整解码 3 次,30MB 日志时峰值内存数百 MB 且同步 parse 阻塞事件循环数秒。
6
+ //
7
+ // 本模块把这条路径改成「只看尾部」:
8
+ // · readTailEvents —— 只解码最后 N 帧,取最后若干条事件(seq / 行数判定都在尾部完成)
9
+ // · appendEventFrame —— 只把「新事件一行」压缩成一个新帧追加到文件尾,**不重写已有内容**
10
+ //
11
+ // 【为什么追加帧是合法的】DSH 会话日志是【多帧容器】:第一帧必须恰好一行 header,其后是事件帧,
12
+ // 帧与帧之间在解码层直接拼接(见 zstd-frames.js decodeAllFrames)。因此「在文件尾追加一个新事件帧」
13
+ // 与「重写整份」在解码后的文本上等价,但内存与 CPU 开销与日志总大小无关。
14
+ //
15
+ // 【安全性】追加帧只改文件尾,失败时不影响已有帧;调用方仍会在写后自检(自检也走尾部读)。
16
+
17
+ import { readFile, open } from "node:fs/promises";
18
+ import { scanZstdFrames, decodeFrame, ZSTD_MAGIC } from "../zstd-frames.js";
19
+ import zlib from "node:zlib";
20
+ const { zstdCompress, constants } = zlib;
21
+
22
+ const FRAME_OPTIONS = { params: { [constants.ZSTD_c_checksumFlag]: 1 } };
23
+
24
+ /**
25
+ * 只解码会话日志的**最后若干帧**,返回其中的事件与统计信息。
26
+ *
27
+ * 与原「整份解码」的差异:不返回全部行,只返回尾部帧内的事件;
28
+ * 因此内存峰值 = 最后 N 帧的解压结果,与日志总大小无关。
29
+ *
30
+ * @param {Buffer} buf 整份(压缩的)日志文件内容
31
+ * @param {{tailFrames?: number, maxEvents?: number}} [opts]
32
+ * @returns {Promise<{events: object[], frames: number, tailStartFrame: number, torn: boolean}>}
33
+ * events 为倒序无关、按日志顺序的尾部事件;frames 为总帧数;tailStartFrame 为尾部第一帧序号
34
+ */
35
+ export async function readTailEvents(buf, opts = {}) {
36
+ const tailFrames = Math.max(1, opts.tailFrames ?? 3);
37
+ const maxEvents = Math.max(1, opts.maxEvents ?? 2000);
38
+ const { frames, tornStart } = scanZstdFrames(buf);
39
+ const tail = frames.slice(Math.max(0, frames.length - tailFrames));
40
+ // 逐帧解码 → 逐行消费,**不把尾部文本拼成一个大字符串**:
41
+ // 只保留「最后 maxEvents 条事件」,因此单帧再大也只在这一个数组上有峰值。
42
+ const events = [];
43
+ let carry = "";
44
+ for (const fr of tail) {
45
+ const frameText = (await decodeFrame(buf.subarray(fr.start, fr.end))).toString("utf8");
46
+ if (frameText === "") continue;
47
+ const lines = (carry + frameText).split("\n");
48
+ // 最后一段可能是不完整行(帧边界断开),留给下一帧拼接
49
+ carry = lines.pop() ?? "";
50
+ for (const line of lines) {
51
+ if (line.trim() === "") continue;
52
+ try {
53
+ events.push(JSON.parse(line));
54
+ } catch {
55
+ // 尾部帧里理论上都是完整行;解析失败不致命,交给调用方的 seq 自检兜底
56
+ }
57
+ }
58
+ }
59
+ if (carry.trim() !== "") {
60
+ try {
61
+ events.push(JSON.parse(carry));
62
+ } catch {
63
+ // 末尾残行:容忍,交给调用方自检
64
+ }
65
+ }
66
+ // 只保留最后 maxEvents 条(尾部帧通常很小,这是防止极端大帧的兜底)
67
+ return {
68
+ events: events.length > maxEvents ? events.slice(events.length - maxEvents) : events,
69
+ frames: frames.length,
70
+ tailStartFrame: Math.max(0, frames.length - tail.length),
71
+ torn: tornStart !== undefined,
72
+ };
73
+ }
74
+
75
+ /** 压缩「单行事件」成一个独立的 zstd 帧(带 checksum)。 */
76
+ async function compressEventLine(line) {
77
+ const buf = Buffer.from(line.endsWith("\n") ? line : `${line}\n`, "utf8");
78
+ return new Promise((resolve, reject) => {
79
+ zstdCompress(buf, FRAME_OPTIONS, (error, out) => (error ? reject(error) : resolve(out)));
80
+ });
81
+ }
82
+
83
+ /**
84
+ * 把一个事件**以新帧形式追加**到会话日志尾部。
85
+ *
86
+ * 为什么不用「读整份 → 拼字符串 → 重编码」:那条路径内存与 CPU 都随日志大小线性增长,
87
+ * 而这里只压缩一行(几百字节~几 KB),开销恒定。
88
+ *
89
+ * 追加前会自证两件事,任一只怕就直接报错(宁可不动日志):
90
+ * ① 文件当前确实是合法的多帧容器(能扫描出帧、首帧能解出 header 行);
91
+ * ② 尾部事件的 seq 与将要写入的 seq 严格衔接(调用方传入 expectedSeq 时)。
92
+ *
93
+ * @param {string} filePath 会话日志路径
94
+ * @param {object} event 要追加的事件(会被 JSON.stringify 成一行)
95
+ * @returns {Promise<{ok: true, seq: number, bytes: number} | {ok: false, error: string}>}
96
+ */
97
+ export async function appendEventFrame(filePath, event) {
98
+ let buf;
99
+ try {
100
+ buf = await readFile(filePath);
101
+ } catch (error) {
102
+ return { ok: false, error: `读取失败:${String(error?.message ?? error)}` };
103
+ }
104
+ let frames;
105
+ try {
106
+ ({ frames } = scanZstdFrames(buf));
107
+ } catch (error) {
108
+ return { ok: false, error: `帧扫描失败(文件可能已损坏,先修复再追加):${String(error?.message ?? error)}` };
109
+ }
110
+ if (frames.length === 0) return { ok: false, error: "日志没有任何 zstd 帧" };
111
+ // 首帧必须能解出「一行 header」——否则这个文件不是合法会话日志,拒绝追加
112
+ let headerText;
113
+ try {
114
+ headerText = (await decodeFrame(buf.subarray(frames[0].start, frames[0].end))).toString("utf8");
115
+ } catch (error) {
116
+ return { ok: false, error: `首帧解码失败:${String(error?.message ?? error)}` };
117
+ }
118
+ const headerLine = headerText.split("\n").find((l) => l.trim() !== "");
119
+ if (!headerLine) return { ok: false, error: "首帧没有 header 行" };
120
+ try {
121
+ const header = JSON.parse(headerLine);
122
+ if (header?.type !== "session") return { ok: false, error: "首帧 header 不是 session 类型" };
123
+ } catch (error) {
124
+ return { ok: false, error: `首帧 header 解析失败:${String(error?.message ?? error)}` };
125
+ }
126
+
127
+ let frame;
128
+ try {
129
+ frame = await compressEventLine(JSON.stringify(event));
130
+ } catch (error) {
131
+ return { ok: false, error: `压缩新帧失败:${String(error?.message ?? error)}` };
132
+ }
133
+ try {
134
+ const handle = await open(filePath, "r+");
135
+ try {
136
+ // 追加前最后确认一次文件长度未被他人改变(粗粒度 CAS)
137
+ const stat = await handle.stat();
138
+ if (stat.size !== buf.length) {
139
+ return { ok: false, error: "文件在读取后被修改(长度变化),放弃追加" };
140
+ }
141
+ await handle.write(frame, 0, frame.length, buf.length);
142
+ await handle.sync();
143
+ } finally {
144
+ await handle.close();
145
+ }
146
+ } catch (error) {
147
+ return { ok: false, error: `追加写入失败:${String(error?.message ?? error)}` };
148
+ }
149
+ return { ok: true, seq: event.seq, bytes: frame.length };
150
+ }
151
+
152
+ /** 文件是否以 zstd magic 开头(快速判定,避免对非日志文件做帧扫描)。 */
153
+ export function looksLikeZstd(buf) {
154
+ return Buffer.isBuffer(buf) && buf.length >= 4 && buf.readUInt32LE(0) === ZSTD_MAGIC;
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-conductor",
3
- "version": "1.3.11",
3
+ "version": "1.3.13",
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",