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.
@@ -1,7 +1,7 @@
1
1
  /** 会话级操作:归档恢复、删除(单个/批量/按规则)、撤回最后一条消息。 */
2
2
 
3
3
  import { rm, readdir } from "node:fs/promises";
4
- import { readFileSync, copyFileSync, writeFileSync, renameSync, mkdirSync, chmodSync, statSync } from "node:fs";
4
+ import { readFileSync, copyFileSync, mkdirSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { hasOpenTurn } from "../sessions/turn.js";
7
7
  import { lazyRepair, lazyZstd, lazyValue } from "../core/lazy.js";
@@ -18,6 +18,8 @@ import {
18
18
  } from "../core/config.js";
19
19
  import { sessionEventsOf } from "../sessions/events.js";
20
20
  import { detachSessionAgent } from "../sessions/detach.js";
21
+ import { sessionMetaOf } from "../sessions/lookup.js";
22
+ import { replaceFileSync } from "../shared/atomic-file.js";
21
23
  import { markDeleted, forgetTrashed, expiredIds, loadTrash, saveTrashNow } from "./trash.js";
22
24
  import { invalidateSessionListCache } from "../sessions/list-cache.js";
23
25
  import { buildSessionListCached } from "../sessions/list.js";
@@ -205,17 +207,9 @@ export async function deleteSessionPhysically(ctx, sessionId) {
205
207
  * 预检/快照的成本上限是「读一次内存或索引」,不是「扫一遍所有会话日志」。
206
208
  */
207
209
  async function describeForTrash(ctx, sessionId) {
208
- try {
209
- const live = ctx.get("sessions")?.get?.(sessionId);
210
- if (live) {
211
- return { title: live.title ?? null, cwd: live.header?.cwd ?? null, updatedAt: null, archived: false };
212
- }
213
- const { loadListDiskCache } = await import("../sessions/list-cache.js");
214
- const entry = loadListDiskCache(ctx)?.entries?.[sessionId];
215
- return { title: entry?.title ?? null, cwd: entry?.cwd ?? null, updatedAt: entry?.updatedAt ?? null, archived: false };
216
- } catch {
217
- return {};
218
- }
210
+ const meta = await sessionMetaOf(ctx, sessionId);
211
+ if (!meta) return {};
212
+ return { title: meta.title, cwd: meta.cwd, updatedAt: meta.updatedAt, archived: meta.archived };
219
213
  }
220
214
 
221
215
  /**
@@ -232,32 +226,44 @@ export async function deleteSession(ctx, sessionId) {
232
226
  }
233
227
 
234
228
  /** 到期真删:把超过保留天数的条目从磁盘上删掉(只由定时器/手动清理调用)。 */
235
- export async function purgeExpiredTrash(ctx, now = Date.now()) {
236
- const ids = expiredIds(ctx, now);
229
+ /**
230
+ * 逐条真删:到期清理与一键清空共用同一条链路(单条失败只跳过,不影响其余)。
231
+ * @returns {{deleted: string[], remaining: number}}
232
+ */
233
+ async function purgeMany(ctx, ids) {
237
234
  const deleted = [];
238
235
  for (const id of ids) {
239
236
  try {
240
237
  const result = await deleteSessionPhysically(ctx, id);
241
- if (result?.ok) { forgetTrashed(ctx, [id]); deleted.push(id); }
242
- } catch { /* 单条失败跳过,下次再试 */ }
238
+ if (result?.ok) {
239
+ forgetTrashed(ctx, [id]);
240
+ deleted.push(id);
241
+ }
242
+ } catch {
243
+ // 单条失败跳过:下一次清理会再试,不阻断其余会话
244
+ }
243
245
  }
244
- if (deleted.length > 0) { saveTrashNow(ctx); invalidateSessionListCache(); }
245
246
  return { deleted, remaining: ids.length - deleted.length };
246
247
  }
247
248
 
249
+ /** 到期真删:把超过保留天数的条目从磁盘上删掉(只由定时器/手动清理调用)。 */
250
+ export async function purgeExpiredTrash(ctx, now = Date.now()) {
251
+ const ids = expiredIds(ctx, now);
252
+ const result = await purgeMany(ctx, ids);
253
+ if (result.deleted.length > 0) {
254
+ saveTrashNow(ctx);
255
+ invalidateSessionListCache();
256
+ }
257
+ return result;
258
+ }
259
+
248
260
  /** 一键清空:立即真删回收站里的全部会话(不可恢复)。 */
249
261
  export async function emptyTrash(ctx) {
250
262
  const ids = Object.keys(loadTrash(ctx).entries);
251
- const deleted = [];
252
- for (const id of ids) {
253
- try {
254
- const result = await deleteSessionPhysically(ctx, id);
255
- if (result?.ok) { forgetTrashed(ctx, [id]); deleted.push(id); }
256
- } catch { /* 单条失败跳过 */ }
257
- }
263
+ const result = await purgeMany(ctx, ids);
258
264
  saveTrashNow(ctx);
259
265
  invalidateSessionListCache();
260
- return { deleted, remaining: ids.length - deleted.length };
266
+ return result;
261
267
  }
262
268
 
263
269
  // ---------- 撤回最后一条用户消息 ----------
@@ -376,10 +382,7 @@ export async function undoLastMessage(ctx, sessionId, { dryRun = false } = {}) {
376
382
  // 6. 多帧写回(header 一帧 + body 一帧,带 checksum),原子替换
377
383
  const { encodeSessionText } = await lazyRepair();
378
384
  const newBuf = await encodeSessionText(newText);
379
- const undoTmpPath = filePath + ".undotmp";
380
- writeFileSync(undoTmpPath, newBuf);
381
- try { chmodSync(undoTmpPath, statSync(filePath).mode); } catch { /* CIFS 无 chmod,尽力 */ }
382
- renameSync(undoTmpPath, filePath);
385
+ replaceFileSync(filePath, newBuf, { tmpSuffix: ".undotmp" });
383
386
 
384
387
  return { ok: true, preview, removedLineCount, removedEventCount, remainingEventCount: check.eventCount, backup: bak };
385
388
  });
package/lib/repair.js CHANGED
@@ -280,7 +280,8 @@ export async function repairCorruptSessions(ctx, { dryRun = false } = {}) {
280
280
  report.fixed += 1;
281
281
  report.backups.push(backupPath);
282
282
  report.sessions.push({ ...entry, fixedBlocks: fixedCount, backup: backupPath });
283
- try { ctx.logger?.info?.(`dsh-session-conductor: 已修复会话 ${id}(${fixedCount} 个块)`); } catch {}
283
+ // 日志失败不影响修复结果本身
284
+ try { ctx.logger?.info?.(`dsh-session-conductor: 已修复会话 ${id}(${fixedCount} 个块)`); } catch { /* 日志失败忽略 */ }
284
285
  } catch (error) {
285
286
  report.errors.push({ id, error: `修复失败: ${String(error?.message ?? error)}` });
286
287
  report.sessions.push(entry);
@@ -394,8 +395,9 @@ export async function scanDualFormatSessions() {
394
395
  const zstd = findSessionLog(sessionDir);
395
396
  if (!plain || !zstd) continue;
396
397
  let plainBytes = 0, zstdBytes = 0;
397
- try { plainBytes = statSync(plain).size; } catch {}
398
- try { zstdBytes = statSync(zstd).size; } catch {}
398
+ // stat 失败(文件刚被移走等)按 0 字节展示,不阻断扫描
399
+ try { plainBytes = statSync(plain).size; } catch { /* 读不到按 0 */ }
400
+ try { zstdBytes = statSync(zstd).size; } catch { /* 读不到按 0 */ }
399
401
  report.dual.push({ id: sess, dir: sessionDir, plainBytes, zstdBytes });
400
402
  }
401
403
  }
@@ -7,7 +7,9 @@
7
7
  // 修复:动态定位 GAP/offset → 3 类引用映射(值 ≥ GAP 则 +offset)→ seq 重编号 → 自愈补全缺失引用
8
8
  // → 官方 foldSurface 校验 → token-surface 协议校验 → packChunkRuns → 多帧 zstd 原子写回。
9
9
 
10
- import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
10
+ import { writeFileSync, mkdirSync } from 'node:fs';
11
+ import { replaceFileSync } from "./shared/atomic-file.js";
12
+ import { readFile } from "node:fs/promises";
11
13
  import { join, dirname } from 'node:path';
12
14
  import { findSessionLog } from './session-log.js';
13
15
  import { zstdCompress, constants } from 'node:zlib';
@@ -22,7 +24,7 @@ const SURFACE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result
22
24
 
23
25
  /** 解码会话文件 → { header, events }。返回 null 表示无法解析(非 seq-gap 损坏)。 */
24
26
  async function loadSessionFile(path) {
25
- const orig = readFileSync(path);
27
+ const orig = await readFile(path);
26
28
  const text = await decodeAllFrames(orig);
27
29
  const lines = text.split('\n');
28
30
  const header = JSON.parse(lines[0]);
@@ -192,9 +194,7 @@ export async function repairSeqGap(path, { dryRun = false, backupDir } = {}) {
192
194
  const bodyFrame = await zstdCompressAsync(Buffer.from(bodyText, 'utf8'), CHECKSUM_OPTIONS);
193
195
  const newBuf = Buffer.concat([headerFrame, bodyFrame]);
194
196
  report.sizeAfter = newBuf.length;
195
- const fixedTmpPath = path + '.fixing';
196
- writeFileSync(fixedTmpPath, newBuf);
197
- renameSync(fixedTmpPath, path);
197
+ replaceFileSync(path, newBuf, { tmpSuffix: '.fixing' }); // 统一原子替换(写 tmp → 继承权限 → rename)
198
198
  report.ok = true;
199
199
  return report;
200
200
  }
@@ -16,7 +16,8 @@
16
16
  // 宿主包**懒加载**:顶层 `import` 会让本文件在「工作区自测」环境直接解析失败
17
17
  // (仓库里踩过同类坑),而 events.js 是最需要被单测覆盖的模块之一(冷会话读法)。
18
18
  // 这里改成顶层 await import + 兜底:没有宿主依赖时退化为「不加闭合器」,其余逻辑照常可测。
19
- import { existsSync, readFileSync, readdirSync } from "node:fs";
19
+ import { existsSync, readdirSync } from "node:fs";
20
+ import { readFile } from "node:fs/promises";
20
21
  import { join } from "node:path";
21
22
  import { StringDecoder } from "node:string_decoder";
22
23
 
@@ -43,6 +44,11 @@ function withInterruptedClosers(events) {
43
44
  }
44
45
  }
45
46
 
47
+ /** 只读尾部时默认保留的事件行数(提出来是为了让 2000 这个数字有名字)。 */
48
+ const DEFAULT_TAIL_KEEP = 2000;
49
+ /** 单条消息默认截断字符数。 */
50
+ const DEFAULT_MAX_CHARS_PER_ITEM = 800;
51
+
46
52
  /**
47
53
  * 该子会话是否归属 API remote(不该被插件当普通会话处理)。
48
54
  * 等价判定:header.origin==="subagent",或 parent live agent 拥有该 child(agents.isOwnedBy)。
@@ -166,8 +172,8 @@ export function findSessionLogById(sessionId, root = sessionsRootOf()) {
166
172
  export async function readColdSessionSummary(ctx, sessionId, opts = {}) {
167
173
  const file = findSessionLogById(sessionId, opts.root ?? sessionsRootOf());
168
174
  if (!file) return null;
169
- const tailKeep = Number.isSafeInteger(opts.tailKeep) && opts.tailKeep >= 0 ? opts.tailKeep : 2000;
170
- const buf = readFileSync(file); // 压缩态(最大 ~50 MB),不是解压态
175
+ const tailKeep = Number.isSafeInteger(opts.tailKeep) && opts.tailKeep >= 0 ? opts.tailKeep : DEFAULT_TAIL_KEEP;
176
+ const buf = await readFile(file); // 压缩态(最大 ~50 MB),不是解压态
171
177
  const decoder = new StringDecoder("utf8");
172
178
  let pending = "";
173
179
  let meta = null;
@@ -268,8 +274,8 @@ export async function readRecentUserTurns(ctx, sessionId, opts = {}) {
268
274
  const file = findSessionLogById(sessionId, opts.root ?? sessionsRootOf());
269
275
  if (!file) return null;
270
276
  const rounds = Number.isSafeInteger(opts.rounds) && opts.rounds > 0 ? opts.rounds : 3;
271
- const perItem = Number.isSafeInteger(opts.maxCharsPerItem) && opts.maxCharsPerItem > 0 ? opts.maxCharsPerItem : 800;
272
- const buf = readFileSync(file);
277
+ const perItem = Number.isSafeInteger(opts.maxCharsPerItem) && opts.maxCharsPerItem > 0 ? opts.maxCharsPerItem : DEFAULT_MAX_CHARS_PER_ITEM;
278
+ const buf = await readFile(file);
273
279
  const decoder = new StringDecoder("utf8");
274
280
  let pending = "";
275
281
  const turns = [];
@@ -1,4 +1,4 @@
1
- // dsh-session-conductor — 会话中断判定(自动续跑资格 + 标题状态后缀共用)
1
+ // dsh-session-conductor — 回合中断判定(自动续跑资格 + 标题状态后缀共用)
2
2
  //
3
3
  // 【为什么放在 sessions 域】「这个会话是不是中断了、能不能自动续」是会话自身的属性判定,
4
4
  // 既被续跑域用(是否触发续跑),也被标题域用(是否加「已中断」后缀)——放在中立位置避免相互依赖。
@@ -20,7 +20,7 @@ export function interruptionInfo(events) {
20
20
  const reason = event.data?.reason;
21
21
  if (reason === null || typeof reason !== "object") return null;
22
22
  if (reason.kind === "interrupted") {
23
- return { kind: "interrupted", seq: event.seq, message: "回合因进程/会话中断而未完成(崩溃修复)" };
23
+ return { kind: "interrupted", seq: event.seq, message: "回合因进程中断/运行失败而未完成(崩溃修复)" };
24
24
  }
25
25
  if (reason.kind === "error") {
26
26
  const code = typeof reason.error?.code === "string" ? reason.error.code : "UNKNOWN";
@@ -0,0 +1,50 @@
1
+ // 会话「便宜元信息」的唯一查找入口。
2
+ //
3
+ // 【为什么抽出来】同一件事在两个地方各写了一遍:
4
+ // · send_to_session 的 dryRun 预检(要报「目标是否活跃/在哪个目录」)
5
+ // · 回收站标记删除(要存一份标题/目录快照供回收站列表展示)
6
+ // 两处都是「先看内存里的 live 会话,再看落盘索引」,逻辑重复且口径容易漂。
7
+ //
8
+ // 【铁律】只查这两个来源,**绝不**为了一个标题或目录去跑全量列表构建
9
+ // (冷缓存实测 41~226 秒,曾把 dryRun 预检拖成「发消息前卡死」)。
10
+ // 查不到就返回 null,由调用方如实说「查不到」,不升级成昂贵查询。
11
+
12
+ /**
13
+ * 取会话元信息:live 优先,其次落盘索引。
14
+ * @param {object} ctx 插件上下文
15
+ * @param {string} sessionId 会话 id
16
+ * @returns {Promise<{live:boolean,title:string|null,cwd:string|null,updatedAt:number|null,archived:boolean}|null>}
17
+ */
18
+ export async function sessionMetaOf(ctx, sessionId) {
19
+ if (typeof sessionId !== "string" || sessionId === "") return null;
20
+ try {
21
+ const live = ctx.get?.("sessions")?.get?.(sessionId);
22
+ if (live) {
23
+ return {
24
+ live: true,
25
+ title: live.title ?? null,
26
+ cwd: live.header?.cwd ?? null,
27
+ updatedAt: null,
28
+ archived: false,
29
+ };
30
+ }
31
+ } catch {
32
+ // live 查询不可用:退回落盘索引
33
+ }
34
+ try {
35
+ const { loadListDiskCache } = await import("./list-cache.js");
36
+ const entry = loadListDiskCache(ctx)?.entries?.[sessionId];
37
+ if (entry) {
38
+ return {
39
+ live: false,
40
+ title: entry.title ?? null,
41
+ cwd: entry.cwd ?? null,
42
+ updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : null,
43
+ archived: false,
44
+ };
45
+ }
46
+ } catch {
47
+ // 索引不可用:按「查不到」处理
48
+ }
49
+ return null;
50
+ }
@@ -40,7 +40,7 @@ export function defaultModelSelection(ctx) {
40
40
  }
41
41
 
42
42
  /**
43
- * 折叠会话里最后一次 `model/selection` 事件(切模型意图——官方 selectForNextRequest 也写这个事件)。
43
+ * 折叠日志里最后一次 `model/selection` 事件(切模型意图——官方 selectForNextRequest 也写这个事件)。
44
44
  * 【为什么】resume 时若只用「最后请求头」当选择,会把切换覆盖回原模型
45
45
  * (表现为「切了只生效一个回合」);优先用该事件可与官方 selectionFor 的投影恢复保持一致。
46
46
  * @returns {{provider: string, model: string} | null}
@@ -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
+ }
@@ -0,0 +1,38 @@
1
+ // 文件级原子操作 —— 全仓唯一实现。
2
+ //
3
+ // 【为什么抽出来】「写 tmp → 复制原文件权限 → rename 覆盖」这段在 zstd-frames(帧修复、截断修复)
4
+ // 与 session-ops(撤回消息写回)各写了一遍,共 3 份;备份那段(已存在不覆盖)也重复了 3 次。
5
+ // 这类代码一旦哪份漏了 chmod 或 rename,表现是「文件权限被重置」或「留下半个日志」,
6
+ // 而且都发生在**修复会话日志**这种最不能出错的路径上 —— 收敛成一处才好审。
7
+ //
8
+ // 说明:CIFS/网络盘上 chmod 可能直接失败,因此权限保留是「尽力而为」,失败不影响替换本身。
9
+
10
+ import { writeFileSync, renameSync, chmodSync, statSync, existsSync, copyFileSync } from "node:fs";
11
+
12
+ /**
13
+ * 原子替换文件内容:写 tmp → 继承原文件权限 → rename 覆盖。
14
+ * @param {string} targetPath 目标文件
15
+ * @param {string|Buffer} data 新内容
16
+ * @param {{tmpSuffix?: string}} [opts] tmp 后缀(默认 .tmp,便于排查是哪条路径留下的)
17
+ * @returns {string} 实际使用的 tmp 路径(已被 rename,仅供日志)
18
+ */
19
+ export function replaceFileSync(targetPath, data, { tmpSuffix = ".tmp" } = {}) {
20
+ const tmpPath = targetPath + tmpSuffix;
21
+ writeFileSync(tmpPath, data);
22
+ try {
23
+ chmodSync(tmpPath, statSync(targetPath).mode);
24
+ } catch {
25
+ // CIFS/网络盘无 chmod:权限沿用默认,替换本身照做
26
+ }
27
+ renameSync(tmpPath, targetPath);
28
+ return tmpPath;
29
+ }
30
+
31
+ /**
32
+ * 备份一次(目标已存在则不覆盖,避免二次操作把原始备份冲掉)。
33
+ * @returns {string} 备份路径
34
+ */
35
+ export function ensureBackupSync(srcPath, backupPath) {
36
+ if (!existsSync(backupPath)) copyFileSync(srcPath, backupPath);
37
+ return backupPath;
38
+ }
@@ -0,0 +1,54 @@
1
+ // 原子 JSON 读写 —— 全仓唯一实现。
2
+ //
3
+ // 【为什么抽出来】config / index-store / memory-recall 三处各写了一遍「写 tmp → rename」:
4
+ // · 任何一处漏掉 rename 都会留下半个文件(下次启动读到坏 JSON);
5
+ // · tmp 文件名若不带 pid,两个进程/两次并发写会互相踩;
6
+ // · 每处还要各自处理「目录不存在」「文件损坏」。
7
+ // 现在统一到这里,调用方只管传 file + data。
8
+ //
9
+ // 同步版给「一次性初始化 / CLI」用;异步版给「请求路径」用(避免阻塞事件循环)。
10
+
11
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
12
+ import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
13
+ import path from "node:path";
14
+
15
+ /** tmp 文件名带上 pid,避免并发写互相覆盖。 */
16
+ function tmpPathOf(file) {
17
+ return `${file}.tmp-${process.pid}`;
18
+ }
19
+
20
+ /** 同步原子写:先写 tmp 再 rename(rename 在同一文件系统上是原子的)。失败向上抛。 */
21
+ export function writeJsonAtomicSync(file, data) {
22
+ mkdirSync(path.dirname(file), { recursive: true });
23
+ const tmpPath = tmpPathOf(file);
24
+ writeFileSync(tmpPath, JSON.stringify(data), "utf8");
25
+ renameSync(tmpPath, file);
26
+ }
27
+
28
+ /** 异步原子写:语义同上,给请求路径用。失败向上抛。 */
29
+ export async function writeJsonAtomic(file, data) {
30
+ await mkdir(path.dirname(file), { recursive: true });
31
+ const tmpPath = tmpPathOf(file);
32
+ await writeFile(tmpPath, JSON.stringify(data), "utf8");
33
+ await rename(tmpPath, file);
34
+ }
35
+
36
+ /** 同步读 JSON:文件不存在 / 损坏 / 不是对象 → 返回 null(绝不抛)。 */
37
+ export function readJsonSafeSync(file) {
38
+ try {
39
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
40
+ return parsed && typeof parsed === "object" ? parsed : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** 异步读 JSON:语义同上。 */
47
+ export async function readJsonSafe(file) {
48
+ try {
49
+ const parsed = JSON.parse(await readFile(file, "utf8"));
50
+ return parsed && typeof parsed === "object" ? parsed : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
@@ -0,0 +1,37 @@
1
+ // 分阶段计时 —— 只做观测,不改变任何行为。
2
+ //
3
+ // 【为什么需要】「切模型要等很久」这类问题,光看总耗时没法定位:一次切换里串了
4
+ // 写意图 / 定位成员 / 等 idle / 写会话日志(整份解码+重编码)/ 官方 @Remote 调用 好几段。
5
+ // 每段各自打点,末尾输出一行「写意图 12ms | 等空闲 60000ms | …」,
6
+ // 一眼能看出慢在哪一段。
7
+ //
8
+ // 【为什么不设超时】超时代码会改变行为、还会掩盖真实的慢;这里只测量、只记录。
9
+
10
+ /**
11
+ * 创建分阶段计时器。
12
+ * @returns {{mark:(name:string)=>number, summary:()=>string, total:()=>number, stages:()=>Array<{name:string,ms:number}>}}
13
+ */
14
+ export function createStageTimer() {
15
+ const marks = [];
16
+ let lastAt = Date.now();
17
+ return {
18
+ /** 记录一个阶段(从上次打点到现在)。 */
19
+ mark(name) {
20
+ const now = Date.now();
21
+ marks.push({ name, ms: now - lastAt });
22
+ lastAt = now;
23
+ return now;
24
+ },
25
+ /** 一行汇总,如「写意图 12ms | 等空闲 60000ms」。 */
26
+ summary() {
27
+ return marks.map((m) => `${m.name} ${m.ms}ms`).join(" | ");
28
+ },
29
+ /** 各阶段耗时合计(不含未被标记覆盖的空隙)。 */
30
+ total() {
31
+ return marks.reduce((sum, m) => sum + m.ms, 0);
32
+ },
33
+ stages() {
34
+ return marks.slice();
35
+ },
36
+ };
37
+ }
@@ -6,7 +6,8 @@
6
6
  // fixZstdFile(多帧正确写回,原子+备份)/ scanAllCorruptFrames(全仓扫描损坏)。
7
7
  // 与 fix-zstd-frames-v2.mjs 同逻辑,DSH_HOME 参数化,供插件 API 与 CLI 共用。
8
8
 
9
- import fs, { readFileSync, writeFileSync, mkdirSync, statSync, renameSync, copyFileSync, chmodSync, readdirSync, existsSync } from 'node:fs';
9
+ import fs, { readFileSync, mkdirSync, statSync, readdirSync, existsSync } from 'node:fs';
10
+ import { replaceFileSync, ensureBackupSync } from "./shared/atomic-file.js";
10
11
  import { join, dirname } from 'node:path';
11
12
  import { findSessionLog } from './session-log.js';
12
13
  import { createZstdDecompress, zstdCompress, constants } from 'node:zlib';
@@ -184,11 +185,8 @@ export async function fixZstdFile(path, backupDir = join(dirname(path), '.zstd-f
184
185
  mkdirSync(backupDir, { recursive: true });
185
186
  const rel = path.replace(/^.*\/sessions\//, '').replace(/\//g, '__');
186
187
  const bak = join(backupDir, rel);
187
- if (!existsSync(bak)) copyFileSync(path, bak);
188
- const fixedTmpPath = path + '.fixtmp';
189
- writeFileSync(fixedTmpPath, newBuf);
190
- try { chmodSync(fixedTmpPath, statSync(path).mode); } catch { /* 尽力 */ }
191
- renameSync(fixedTmpPath, path);
188
+ ensureBackupSync(path, bak);
189
+ replaceFileSync(path, newBuf, { tmpSuffix: '.fixtmp' });
192
190
  return { ok: true, id: header.id, eventCount: events.length, fixed: true, backup: bak, origSize: origBuf.length, newSize: newBuf.length };
193
191
  }
194
192
 
@@ -238,11 +236,8 @@ async function truncateToDecodablePrefix(path, buf, originalError, backupDir) {
238
236
  mkdirSync(backupDir, { recursive: true });
239
237
  const rel = path.replace(/^.*\/sessions\//, '').replace(/\//g, '__');
240
238
  const bak = join(backupDir, rel);
241
- if (!existsSync(bak)) copyFileSync(path, bak);
242
- const tmp = path + '.fixtmp';
243
- writeFileSync(tmp, buf.subarray(0, safeEnd));
244
- try { chmodSync(tmp, statSync(path).mode); } catch { /* 尽力 */ }
245
- renameSync(tmp, path);
239
+ ensureBackupSync(path, bak);
240
+ replaceFileSync(path, buf.subarray(0, safeEnd), { tmpSuffix: '.fixtmp' });
246
241
  return {
247
242
  ok: true,
248
243
  id: head.header.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-conductor",
3
- "version": "1.3.10",
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",
@@ -188,7 +188,7 @@ whenToUse: 需要了解 dsh-session-conductor 某个功能怎么用/参数是什
188
188
  2. 建新会话:`list_models()` 查确切 provider/model → `create_session({provider, model, dryRun:true})` 预览 → 确认后去掉 dryRun 真建
189
189
  3. 给已有会话发消息:`send_to_session({sessionId, text, dryRun:true})` 预览 → 确认后真投
190
190
 
191
- - **`create_session` 缺省工作目录 = 调用方会话自己的 cwd**(发起它的那个 AI 在哪干活,新会话就落在哪)。**不要**依赖本体默认:那是 DSH 安装目录(实测 `/volume1/@appstore/DeepSeekHarness-NAS`),新会话落在程序目录里对干活毫无意义。显式给 `cwd` 或 `workspaceId` 可覆盖(二者互斥)。
191
+ - **`create_session` 缺省工作目录 = 调用方会话自己的 cwd**(发起它的那个 AI 在哪干活,新会话就落在哪)。**不要**依赖本体默认:那是 DSH 安装目录(实测 `<DSH 安装目录>`),新会话落在程序目录里对干活毫无意义。显式给 `cwd` 或 `workspaceId` 可覆盖(二者互斥)。
192
192
  - **dryRun(两个写操作工具都支持)**:只做校验与预览、**不产生任何副作用**。`create_session` 的 dryRun 还会**核对 provider/model 是否真在模型目录里**;`send_to_session` 的 dryRun 会**报告目标状态与限流余量**,且**不消耗限流配额**(可以先 dryRun 多次再真投)。dryRun 同样执行全部本地校验(自投/超长/跳数等照常被拒)。
193
193
  - **状态含义**(`list_sessions` 输出):`running` = 正在跑回合;`idle` = 已挂载但空闲;`inactive` = 冷会话(未挂载,投递时会被唤醒)。缺 id 的脏条目会被跳过并如实标注(不会渲染成 `undefined`)。
194
194
  - **会话列表的内存边界(实测,别再踩)**:宿主的 `handle.read(offset, length)` **不能流式读** —— offset/length 只是**内存数组切片**,后端每次调用都会把整份日志读进来解压(`session-persistence-jsonl/src/storage.ts` 的 `readCurrent`)。实测单份日志 49.2 MB 压缩 → **173.3 MB 解压**(3.2~5.0×)。
@@ -128,7 +128,7 @@ pnpm add github:EIGHTfs/dsh-session-conductor#<commit>
128
128
  1. **本机要有 `npm`** —— pnpm 构建 git 来源的包时会在临时目录执行 `npm install`;
129
129
  缺 npm 时报 `ERR_PNPM_PREPARE_PACKAGE` + `spawn ENOENT: npm: command not found`。
130
130
  (本机 2026-10-12 已装 npm 10.9.2:解包到 `<APP>/lib/node_modules/npm`,再链到
131
- `/var/packages/DeepSeekHarness-NAS/target/.local/bin/npm` —— `/usr/local/bin` 不可写。)
131
+ `<DSH 安装目录>/bin/npm` —— 系统级 bin 目录不可写。)
132
132
  2. **pnpm 11 默认禁止 git 源依赖执行构建脚本**(供应链策略):只有带 `prepare`/`prepack` 的插件
133
133
  需要放行,在 profile 的 `pnpm-workspace.yaml` 里加,否则报 `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`:
134
134