dsh-session-conductor 1.2.0 → 1.2.2
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.
- package/README.md +19 -0
- package/lib/features/session-ops.js +6 -3
- package/lib/group.js +12 -14
- package/lib/sessions/events.js +111 -0
- package/lib/sessions/list.js +43 -11
- package/lib/zstd-frames.js +35 -0
- package/package.json +1 -1
- package/skills/dsh-session-conductor-functions.md +4 -4
package/README.md
CHANGED
|
@@ -36,6 +36,25 @@
|
|
|
36
36
|
|
|
37
37
|

|
|
38
38
|
|
|
39
|
+
## 环境要求(支持的 DSH 版本)
|
|
40
|
+
|
|
41
|
+
| 项 | 要求 |
|
|
42
|
+
|---|---|
|
|
43
|
+
| **DSH 版本** | **0.2.1 及以上(推荐)**;0.2.x 早期版本功能可用,但**列表缓存不生效**(见下) |
|
|
44
|
+
| Node | 与宿主 DSH 自带的 Node 一致(无需额外安装) |
|
|
45
|
+
| 平台 | Linux(群晖 DSM / fnOS 等);纯 Node 实现,无原生依赖 |
|
|
46
|
+
|
|
47
|
+
**为什么 0.2.1 是推荐下限**:本插件的会话列表缓存依赖 `sessionPersistence.list()` 返回的
|
|
48
|
+
**`SessionPersistenceSnapshot`**(`{ header, revision, eventCount?, sizeBytes? }`)里的 `revision`
|
|
49
|
+
作为「日志变过没有」的变更令牌。0.2.1 起该 API 稳定提供 `revision`;更早的版本里
|
|
50
|
+
`list()` 只给裸 header(没有 `revision`),插件会**优雅降级**:功能全部正常,
|
|
51
|
+
只是每次列表构建都要重新解析会话日志(会话多、日志大时会明显变慢、内存占用高)。
|
|
52
|
+
|
|
53
|
+
> 早期代码曾探测 `persistence.listSnapshots()` 并期望它返回 `{ header, revision }` ——
|
|
54
|
+
> 该 session 级方法**在任何 DSH 版本里都不存在**(0.1.6 里同名的 `listSnapshots(profileDir)`
|
|
55
|
+
> 是 profile 快照,另一套东西),所以那个探测永远失败、缓存从未生效过。
|
|
56
|
+
> 1.2.0 起统一改用 `list()` 并按快照解析。
|
|
57
|
+
|
|
39
58
|
## 安装
|
|
40
59
|
|
|
41
60
|
装到哪个 profile 由 `--profile` 指定。`dsh plugin` 会把后面的参数**转发给该 profile 目录下的 pnpm**,
|
|
@@ -72,8 +72,11 @@ import { buildSessionListCached } from "../sessions/list.js";
|
|
|
72
72
|
* 逼近 --max-old-space-size=2048 上限,即 修的那个堆 OOM)。
|
|
73
73
|
*
|
|
74
74
|
* 做法(持久性保存 + 懒加载,内存优先于速度):
|
|
75
|
-
* ① 用 persistence.
|
|
76
|
-
*
|
|
75
|
+
* ① 用 persistence.list() 拿每个会话的 **SessionPersistenceSnapshot**(`{ header, revision, ... }`;
|
|
76
|
+
* list() 只读 header 行 + 一次 stat,**不解析日志**),revision 是「该会话日志内容是否变过」的变更令牌;
|
|
77
|
+
* ⚠️ 不要探测 `listSnapshots()` —— 该 session 级方法在任何 DSH 版本里都不存在
|
|
78
|
+
* (0.1.6 里同名的 `listSnapshots(profileDir)` 是 profile 快照,另一套 API),
|
|
79
|
+
* 探测永远失败会让缓存静默失效(1.2.0 修掉的正是这个)。
|
|
77
80
|
* ② 把上面三个字段连同 revision 落盘到 <DSH_HOME>/storages/dsh-session-conductor/list-cache.json;
|
|
78
81
|
* ③ 下次列列表时 **revision 没变就直接用落盘结果,一次 inspect 都不做**(懒加载核心);
|
|
79
82
|
* 只有新会话、或日志真的追加过(revision 变了)才重新解析那一条;
|
|
@@ -254,7 +257,7 @@ export async function undoLastMessage(ctx, sessionId, { dryRun = false } = {}) {
|
|
|
254
257
|
if (targetLineIdx >= 0) break;
|
|
255
258
|
}
|
|
256
259
|
if (targetLineIdx < 0) {
|
|
257
|
-
return { ok: false, error: { code: "no-user-message", message: "
|
|
260
|
+
return { ok: false, error: { code: "no-user-message", message: "该会话没有可撤回的用户消息" } };
|
|
258
261
|
}
|
|
259
262
|
|
|
260
263
|
// 删除范围 [targetLineIdx, 行尾]:这条消息 + 它触发的整轮回复(文件尾部)
|
package/lib/group.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* GET /api/session-conductor/group/status 状态(分组数/groupRoot/profile)
|
|
8
8
|
* GET /api/session-conductor/group/list 全部分组(含 sessionIds / path / title)
|
|
9
9
|
* POST /api/session-conductor/group/new-session 在分组下新建会话 {workspaceId?, prompt?, handoff?}
|
|
10
|
-
* —— workspaceId 缺省 =
|
|
10
|
+
* —— workspaceId 缺省 = **最近活跃会话的工作区**(该会话 cwd 归属的分组);
|
|
11
11
|
* 找不到则兜底 .dsh 上一层 /workspace 默认工作区(不存在自动创建)。
|
|
12
12
|
*
|
|
13
13
|
* 面板「全部会话按工作区分组」渲染用 group/list(只读数据源);
|
|
@@ -111,7 +111,7 @@ function workspaceIdOfCwd(registry, cwd) {
|
|
|
111
111
|
return ws?.id ?? '';
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
/**
|
|
114
|
+
/** 最近活跃会话的 cwd:updatedAt/createdAt 最大者的工作目录(live + 冷会话合并)。 */
|
|
115
115
|
async function lastSessionCwd(ctx) {
|
|
116
116
|
const sessions = ctx.get('sessions');
|
|
117
117
|
const persistence = ctx.get('sessionPersistence');
|
|
@@ -122,17 +122,15 @@ async function lastSessionCwd(ctx) {
|
|
|
122
122
|
if (updatedAtMs >= best.t && header?.cwd) best = { t: updatedAtMs, cwd: header.cwd };
|
|
123
123
|
}
|
|
124
124
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (updatedAtMs >= best.t && snapshot?.cwd) best = { t: updatedAtMs, cwd: snapshot.cwd };
|
|
135
|
-
}
|
|
125
|
+
// 宿主 list() 返回的是 **SessionPersistenceSnapshot**(`{ header, revision, ... }`),不是裸 header。
|
|
126
|
+
// 旧写法分两支:探测 `listSnapshots()`(宿主从未有过该 session 级方法,探测永远失败),
|
|
127
|
+
// 失败支里直接读 `snapshot.cwd / snapshot.updatedAt` —— 两者都是 undefined,
|
|
128
|
+
// 于是「最近活跃会话的 cwd」在冷会话上**静默失效**(只剩 live 会话参与比较)。
|
|
129
|
+
// 现在统一解包:`entry.header ?? entry` 兼容新快照与旧裸 header 两种形态。
|
|
130
|
+
for (const entry of await persistence?.list?.() ?? []) {
|
|
131
|
+
const header = entry?.header ?? entry ?? {};
|
|
132
|
+
const updatedAtMs = Number(header?.updatedAt ?? header?.createdAt ?? 0);
|
|
133
|
+
if (updatedAtMs >= best.t && header?.cwd) best = { t: updatedAtMs, cwd: header.cwd };
|
|
136
134
|
}
|
|
137
135
|
} catch { /* 冷会话扫描失败不影响 live 结果 */ }
|
|
138
136
|
return best.cwd;
|
|
@@ -198,7 +196,7 @@ export async function registerGroupRoutes(ctx, config = {}, hooks = {}) {
|
|
|
198
196
|
const body = await readJsonBody(req);
|
|
199
197
|
let workspaceId = String(body?.workspaceId ?? '').trim();
|
|
200
198
|
if (!workspaceId) {
|
|
201
|
-
// workspaceId 缺省 =
|
|
199
|
+
// workspaceId 缺省 = 最近活跃会话的工作区(该会话 cwd 归属的分组)
|
|
202
200
|
const lastCwd = await lastSessionCwd(ctx);
|
|
203
201
|
if (lastCwd) workspaceId = workspaceIdOfCwd(registry, lastCwd);
|
|
204
202
|
}
|
package/lib/sessions/events.js
CHANGED
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
// 宿主包**懒加载**:顶层 `import` 会让本文件在「工作区自测」环境直接解析失败
|
|
17
17
|
// (仓库里踩过同类坑),而 events.js 是最需要被单测覆盖的模块之一(冷会话读法)。
|
|
18
18
|
// 这里改成顶层 await import + 兜底:没有宿主依赖时退化为「不加闭合器」,其余逻辑照常可测。
|
|
19
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { StringDecoder } from "node:string_decoder";
|
|
22
|
+
|
|
23
|
+
import { streamDecodedFrames } from "../zstd-frames.js";
|
|
24
|
+
import { findSessionLog } from "../session-log.js";
|
|
25
|
+
|
|
19
26
|
let interruptedTurnClosers = null;
|
|
20
27
|
try {
|
|
21
28
|
const hostSession = await import("@deepseek-ai/dsh-session");
|
|
@@ -89,6 +96,8 @@ export async function sessionEventsOf(ctx, sessionId) {
|
|
|
89
96
|
* 列表侧靠磁盘缓存(revision 命中即跳过);不要为了「省内存」改成多次 read
|
|
90
97
|
* (那只会变成 N 次全量解压,内存峰值不变、耗时变 O(n²))。
|
|
91
98
|
*
|
|
99
|
+
* 列表构建请优先用 `readColdSessionSummary`(按 zstd 帧流式读,常量内存)。
|
|
100
|
+
*
|
|
92
101
|
* 读取失败**向上抛**(buildSessionList 用它带出 inspectError);仅「无 open 能力」返回 null。
|
|
93
102
|
* @returns {Promise<{events:Array, meta:object|null}|null>}
|
|
94
103
|
*/
|
|
@@ -106,3 +115,105 @@ export async function readColdSessionEvents(ctx, sessionId) {
|
|
|
106
115
|
// 崩溃残留的开放回合补合成闭合器(与官方 cold-read 一致,列表中断判定需要)
|
|
107
116
|
return { events: withInterruptedClosers(events), meta: handle?.header ?? null };
|
|
108
117
|
}
|
|
118
|
+
|
|
119
|
+
// ---------- 按 zstd 帧流式读(列表专用,常量内存)----------
|
|
120
|
+
|
|
121
|
+
/** 会话日志根目录:`<DSH_HOME>/sessions`。 */
|
|
122
|
+
function sessionsRootOf() {
|
|
123
|
+
const dshHome = process.env.DSH_HOME || join(process.env.HOME ?? "", ".dsh");
|
|
124
|
+
return join(dshHome, "sessions");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 由会话 id 定位其日志文件。布局:`<sessionsRoot>/<工作区编码目录>/<会话id>/session.v*.jsonl.zstd`。
|
|
129
|
+
* 只做两层 readdir(根目录下的工作区分组通常只有个位数),命中即返回。
|
|
130
|
+
* @returns {string|null} 日志文件绝对路径;找不到返回 null(调用方回退到 handle 读法)
|
|
131
|
+
*/
|
|
132
|
+
export function findSessionLogById(sessionId, root = sessionsRootOf()) {
|
|
133
|
+
if (!sessionId) return null;
|
|
134
|
+
try {
|
|
135
|
+
for (const group of readdirSync(root)) {
|
|
136
|
+
const dir = join(root, group, sessionId);
|
|
137
|
+
if (!existsSync(dir)) continue;
|
|
138
|
+
const file = findSessionLog(dir);
|
|
139
|
+
if (file) return file;
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
// 根目录不可读 → 交给调用方回退
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 冷会话「摘要读」:**直接按 zstd 帧读日志文件**,流式解压 + 逐行增量解析 ⇒ 常量内存。
|
|
149
|
+
*
|
|
150
|
+
* 【为什么需要它】宿主的 `handle.read()` 无法流式读(见文件头):它每次都会把整份日志
|
|
151
|
+
* 解压成 events 数组,本机实测最大会话 49.2 MB 压缩 → **173.3 MB 解压**,再变成对象数组后
|
|
152
|
+
* 单会话峰值数百 MB;列表要遍历上百个冷会话 ⇒ 进程 OOM。
|
|
153
|
+
* 而列表真正需要的只有三个值,都能在「逐行流过」时用 O(1) 状态累加:
|
|
154
|
+
* · title —— 全日志**最后一条** `session/title`(`foldTitle` 是倒序找,等价)
|
|
155
|
+
* · updatedAt —— 全日志**最后一条**事件的 `time`(`lastEventTime` 只取末条)
|
|
156
|
+
* · interruption —— 全日志**最后一个** `turn/end` 的 reason(`interruptionInfo` 倒序找)
|
|
157
|
+
* 因此:读压缩文件(≤ ~50 MB)→ 逐帧流式解压(`streamDecodedFrames`)→ 逐行 JSON.parse
|
|
158
|
+
* → 只更新上述标量 + 保留尾部若干条(供官方闭合器判定开放回合)→ 其余立即丢弃。
|
|
159
|
+
*
|
|
160
|
+
* @param {object} ctx 插件 context(本函数不用 ctx 的服务,保留是为了与其它读法同签名)
|
|
161
|
+
* @param {string} sessionId 会话 id
|
|
162
|
+
* @param {object} [opts] tailKeep(尾部保留条数,默认 2000)/ signal / root(测试用)
|
|
163
|
+
* @returns {Promise<{meta:object|null, events:Array, derived:object, scanned:number, frames:number}|null>}
|
|
164
|
+
* 返回 null 表示「文件没找到」——调用方应回退到 `readColdSessionEvents`
|
|
165
|
+
*/
|
|
166
|
+
export async function readColdSessionSummary(ctx, sessionId, opts = {}) {
|
|
167
|
+
const file = findSessionLogById(sessionId, opts.root ?? sessionsRootOf());
|
|
168
|
+
if (!file) return null;
|
|
169
|
+
const tailKeep = Number.isSafeInteger(opts.tailKeep) && opts.tailKeep >= 0 ? opts.tailKeep : 2000;
|
|
170
|
+
const buf = readFileSync(file); // 压缩态(最大 ~50 MB),不是解压态
|
|
171
|
+
const decoder = new StringDecoder("utf8");
|
|
172
|
+
let pending = "";
|
|
173
|
+
let meta = null;
|
|
174
|
+
let title;
|
|
175
|
+
let updatedAt;
|
|
176
|
+
let openTurn = false;
|
|
177
|
+
let lastSeq;
|
|
178
|
+
let lastTurnEnd = null;
|
|
179
|
+
let tail = [];
|
|
180
|
+
let scanned = 0;
|
|
181
|
+
|
|
182
|
+
const consumeLine = (line) => {
|
|
183
|
+
if (!line) return;
|
|
184
|
+
let event;
|
|
185
|
+
try { event = JSON.parse(line); } catch { return; } // 截断/损坏行跳过,不影响其余
|
|
186
|
+
if (event?.type === "session") { meta = meta ?? event; return; } // 首行是会话 header(含 cwd/createdAt)
|
|
187
|
+
scanned += 1;
|
|
188
|
+
if (event?.type === "session/title" && typeof event.data?.title === "string") title = event.data.title;
|
|
189
|
+
if (typeof event?.time === "number") updatedAt = event.time;
|
|
190
|
+
if (event?.type === "turn/end") { lastTurnEnd = event; openTurn = false; }
|
|
191
|
+
else if (event?.type === "turn/start") openTurn = true;
|
|
192
|
+
if (typeof event?.seq === "number") lastSeq = event.seq;
|
|
193
|
+
if (tailKeep > 0) {
|
|
194
|
+
tail.push(event);
|
|
195
|
+
if (tail.length > tailKeep) tail.splice(0, tail.length - tailKeep);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const stats = await streamDecodedFrames(buf, (chunk) => {
|
|
200
|
+
pending += decoder.write(chunk);
|
|
201
|
+
let idx;
|
|
202
|
+
while ((idx = pending.indexOf("\n")) >= 0) {
|
|
203
|
+
const line = pending.slice(0, idx);
|
|
204
|
+
pending = pending.slice(idx + 1);
|
|
205
|
+
consumeLine(line);
|
|
206
|
+
}
|
|
207
|
+
}, { signal: opts.signal });
|
|
208
|
+
pending += decoder.end();
|
|
209
|
+
consumeLine(pending);
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
meta,
|
|
213
|
+
// 尾部 + 官方闭合器:列表沿用既有 interruptionInfo(events) 语义,行为不变
|
|
214
|
+
events: withInterruptedClosers(tail),
|
|
215
|
+
derived: { title, updatedAt, openTurn, lastSeq, lastTurnEnd },
|
|
216
|
+
scanned,
|
|
217
|
+
frames: stats.frames,
|
|
218
|
+
};
|
|
219
|
+
}
|
package/lib/sessions/list.js
CHANGED
|
@@ -8,7 +8,7 @@ import { log } from "../shared/log.js";
|
|
|
8
8
|
import { cfg, DEFAULTS, autoRenameEnabled, effectiveAutoContinue } from "../core/config.js";
|
|
9
9
|
import { pluginState } from "../core/domain.js";
|
|
10
10
|
import { continueJobs } from "../core/state.js";
|
|
11
|
-
import { sessionEventList, readColdSessionEvents } from "./events.js";
|
|
11
|
+
import { sessionEventList, readColdSessionEvents, readColdSessionSummary } from "./events.js";
|
|
12
12
|
import { hasOpenTurn, lastEventTime } from "./turn.js";
|
|
13
13
|
import { interruptionInfo } from "./interruption.js";
|
|
14
14
|
import { resolveSessionTitle, foldTitle, stripArchiveWsPrefix } from "../features/rename/title.js";
|
|
@@ -19,6 +19,23 @@ import {
|
|
|
19
19
|
} from "./list-cache.js";
|
|
20
20
|
import { isValidSessionId } from "./session-id.js";
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* 开放回合兜底:摘要读只保留有界尾部,若「崩溃残留的开放回合」的 `turn/start` 落在尾部之外,
|
|
24
|
+
* 官方闭合器就不会出现 —— 此时合成一条 `turn/end`(reason.kind="interrupted")再交给
|
|
25
|
+
* `interruptionInfo` 判定。这样中断的**结构与文案只在 interruption.js 维护一处**,
|
|
26
|
+
* 不在列表侧复制字面量(复制会漂移,也会被审计的注释规则拦下)。
|
|
27
|
+
*
|
|
28
|
+
* @returns {object|null} 与 interruptionInfo 同形的中断描述;非开放回合返回 null
|
|
29
|
+
*/
|
|
30
|
+
function openTurnInterruption(events, derived) {
|
|
31
|
+
if (!derived?.openTurn) return null;
|
|
32
|
+
const tail = Array.isArray(events) ? events : [];
|
|
33
|
+
return interruptionInfo([
|
|
34
|
+
...tail,
|
|
35
|
+
{ type: "turn/end", seq: derived.lastSeq, data: { reason: { kind: "interrupted" } } },
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
/**
|
|
23
40
|
* 会话列表项 id 的唯一判据在 `./session-id.js`(零依赖叶子模块,便于单测直接覆盖)。
|
|
24
41
|
* 本文件四处使用它:归档集合净化 / 补全守卫 / persistence.list 回退校验 / emit 兜底。
|
|
@@ -136,24 +153,39 @@ export async function buildSessionList(ctx, opts = {}) { // dsh-skip-func-length
|
|
|
136
153
|
const batch = needInspect.slice(start, start + batchSize);
|
|
137
154
|
const settled = await Promise.allSettled(batch.map(async (entry) => {
|
|
138
155
|
let events = [];
|
|
156
|
+
let summaryDerived = null;
|
|
139
157
|
let inspectError = null;
|
|
140
158
|
let inspected = entry.meta;
|
|
141
159
|
try {
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
160
|
+
// 优先「按 zstd 帧流式读」(readColdSessionSummary):直接读日志文件、逐帧流式解压、
|
|
161
|
+
// 逐行增量解析 ⇒ 常量内存(实测大日志 heap 增量 ~24 MB、峰值 29 MB)。
|
|
162
|
+
// 对比:handle.read 每次都会把整份日志解压成 events 数组(同文件解压后 ~200 MB),
|
|
163
|
+
// 列表要遍历上百个冷会话 ⇒ 进程 OOM(线上实测 RSS 2.33 GB、/list >120s)。
|
|
164
|
+
const summary = await readColdSessionSummary(ctx, entry.meta.id);
|
|
165
|
+
if (summary) {
|
|
166
|
+
events = summary.events;
|
|
167
|
+
summaryDerived = summary.derived;
|
|
168
|
+
if (summary.meta) inspected = summary.meta;
|
|
169
|
+
} else if (persistence?.open) {
|
|
170
|
+
// 回退:日志文件没找到(布局/命名变化)时用官方 handle 读法,保证功能不退化
|
|
171
|
+
const loaded = await readColdSessionEvents(ctx, entry.meta.id);
|
|
172
|
+
events = loaded?.events ?? [];
|
|
173
|
+
if (loaded?.meta) inspected = loaded.meta;
|
|
174
|
+
}
|
|
149
175
|
} catch (cause) {
|
|
150
176
|
// 损坏/不可读的 artifact 以无标题呈现,不影响列表;错误信息带出便于诊断
|
|
151
177
|
inspectError = cause instanceof Error ? cause.message : String(cause);
|
|
152
178
|
}
|
|
153
179
|
const derived = {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
180
|
+
// 标题/时间由按帧读在流式过程中累加,与「全量读后 foldTitle/lastEventTime」等价
|
|
181
|
+
//(已用朴素全量计算对拍验证);拿不到摘要时退回对尾部事件求值。
|
|
182
|
+
title: summaryDerived?.title ?? foldTitle(events) ?? null,
|
|
183
|
+
updatedAt: summaryDerived?.updatedAt ?? lastEventTime(events) ?? inspected.createdAt ?? entry.meta.createdAt,
|
|
184
|
+
// 中断:摘要读已把「尾部 + 官方闭合器」拼好喂给 interruptionInfo;
|
|
185
|
+
// 若尾部不足以覆盖开放回合(闭合器没出现),用累加出的 openTurn 兜底 ——
|
|
186
|
+
// 兜底同样**走 interruptionInfo**(合成一条 turn/end 交给它判定),
|
|
187
|
+
// 不在本文件复制中断文案(文案归 interruption.js 维护,避免两处漂移)。
|
|
188
|
+
interruption: interruptionInfo(events) ?? openTurnInterruption(events, summaryDerived),
|
|
157
189
|
inspectError,
|
|
158
190
|
};
|
|
159
191
|
// 解析结果连同 revision 落盘:下次这条会话只要没被追加过就不再解析。
|
package/lib/zstd-frames.js
CHANGED
|
@@ -68,6 +68,41 @@ export async function decodeAllFrames(buf) {
|
|
|
68
68
|
return Buffer.concat(parts).toString('utf8');
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* 多帧**流式**解码:逐帧解压,把每帧的解压分片**直接交给 onChunk**,不拼接、不驻留。
|
|
73
|
+
*
|
|
74
|
+
* 【为什么需要它】`decodeAllFrames` 会把所有帧的解压结果 `Buffer.concat` 成一个大字符串 ——
|
|
75
|
+
* 本机实测最大会话日志解压后 173 MB,逐帧拼接时内存峰值就是「整份文本 + 所有分片」;
|
|
76
|
+
* 若再 `JSON.parse` 成事件对象数组,峰值会到数百 MB(列表构建反复调用 ⇒ 进程 OOM)。
|
|
77
|
+
* 本函数把「解压」与「消费」解耦:调用方按分片增量处理(例如逐行 JSON.parse 后只留需要的字段),
|
|
78
|
+
* 内存峰值 = 单个分片 + 调用方自己的增量状态,与日志总大小无关。
|
|
79
|
+
*
|
|
80
|
+
* ⚠️ 仍必须**逐帧**喂给解压器:DSH 会话日志是多帧容器,整体流式解码会丢后续帧(见 decodeAllFrames 注释)。
|
|
81
|
+
*
|
|
82
|
+
* @param {Buffer} buf 整份(压缩的)日志文件内容
|
|
83
|
+
* @param {(chunk: Buffer) => void} onChunk 每个解压分片的消费者(同步;抛错会中止并向上传播)
|
|
84
|
+
* @param {{signal?: AbortSignal}} [opts]
|
|
85
|
+
* @returns {Promise<{frames:number, tornStart?:number}>} 帧数;`tornStart` 存在表示尾部有截断帧
|
|
86
|
+
*/
|
|
87
|
+
export async function streamDecodedFrames(buf, onChunk, opts = {}) {
|
|
88
|
+
if (typeof onChunk !== "function") throw new TypeError("streamDecodedFrames 需要 onChunk 回调");
|
|
89
|
+
const { frames, tornStart } = scanZstdFrames(buf);
|
|
90
|
+
for (const fr of frames) {
|
|
91
|
+
opts.signal?.throwIfAborted();
|
|
92
|
+
await new Promise((resolve, reject) => {
|
|
93
|
+
const decompressor = createZstdDecompress();
|
|
94
|
+
decompressor.on("data", (chunk) => {
|
|
95
|
+
try { onChunk(chunk); } catch (error) { reject(error); }
|
|
96
|
+
});
|
|
97
|
+
decompressor.on("end", resolve);
|
|
98
|
+
decompressor.on("error", reject);
|
|
99
|
+
decompressor.write(buf.subarray(fr.start, fr.end));
|
|
100
|
+
decompressor.end();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return tornStart === undefined ? { frames: frames.length } : { frames: frames.length, tornStart };
|
|
104
|
+
}
|
|
105
|
+
|
|
71
106
|
/**
|
|
72
107
|
* 校验第一帧是否恰好一行 header(DSH assertZstdHeaderFrame 语义)。
|
|
73
108
|
* @returns {{ok:boolean, error?:string}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-conductor",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
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,10 +188,10 @@ whenToUse: 需要了解 dsh-session-conductor 某个功能怎么用/参数是什
|
|
|
188
188
|
- **`create_session` 缺省工作目录 = 调用方会话自己的 cwd**(发起它的那个 AI 在哪干活,新会话就落在哪)。**不要**依赖本体默认:那是 DSH 安装目录(实测 `/volume1/@appstore/DeepSeekHarness-NAS`),新会话落在程序目录里对干活毫无意义。显式给 `cwd` 或 `workspaceId` 可覆盖(二者互斥)。
|
|
189
189
|
- **dryRun(两个写操作工具都支持)**:只做校验与预览、**不产生任何副作用**。`create_session` 的 dryRun 还会**核对 provider/model 是否真在模型目录里**;`send_to_session` 的 dryRun 会**报告目标状态与限流余量**,且**不消耗限流配额**(可以先 dryRun 多次再真投)。dryRun 同样执行全部本地校验(自投/超长/跳数等照常被拒)。
|
|
190
190
|
- **状态含义**(`list_sessions` 输出):`running` = 正在跑回合;`idle` = 已挂载但空闲;`inactive` = 冷会话(未挂载,投递时会被唤醒)。缺 id 的脏条目会被跳过并如实标注(不会渲染成 `undefined`)。
|
|
191
|
-
-
|
|
192
|
-
- ⛔
|
|
193
|
-
- ✅
|
|
194
|
-
-
|
|
191
|
+
- **会话列表的内存边界(实测,别再踩)**:宿主的 `handle.read(offset, length)` **不能流式读** —— offset/length 只是**内存数组切片**,后端每次调用都会把整份日志读进来解压(`session-persistence-jsonl/src/storage.ts` 的 `readCurrent`)。实测单份日志 49.2 MB 压缩 → **173.3 MB 解压**(3.2~5.0×)。
|
|
192
|
+
- ⛔ **不要为了省内存改成分块 read**:内存峰值不变(每次都全量物化),耗时变 O(n²)(曾导致 `/list` >120s 超时,已撤回)。
|
|
193
|
+
- ✅ **1.2.2 起的做法:绕开 handle,按 zstd 帧流式读日志文件** —— `lib/sessions/events.js` 的 `readColdSessionSummary()`:`findSessionLogById` 定位文件 → `streamDecodedFrames()`(`lib/zstd-frames.js`,逐帧解压、分片直接交给消费者不拼接)→ `StringDecoder` 增量切行 → 逐行 `JSON.parse`,只累加 `title`/`updatedAt`/`openTurn` 并保留有界尾部。**实测 heap 增量 24 MB、峰值 29 MB**(原路径数百 MB),且与「全量解码 + `lastEventTime`/`hasOpenTurn`/`interruptionInfo`」逐项对拍一致。
|
|
194
|
+
- 另靠列表磁盘缓存的 `revision` 命中跳过重复解析(1.2.0 修掉了「`list()` 返回快照却被当裸 header 用、`revision` 恒 null ⇒ 缓存永不生效」)。
|
|
195
195
|
- **投递语义**:默认 `mode:"queue"` —— 目标空闲时**由本体自动唤醒**它跑一个回合;目标运行中则排到其下一回合边界。`mode:"steer"` 改为注入目标**正在跑**的那个回合。
|
|
196
196
|
- **实现口径(重要)**:全部走本体官方 `sessionController` 的 `@Remote` 方法 —— `create` / `selectModel` / `prompt`。**不自己写会话日志**:inbox 家族只有 `agent/inbox/spliced` 落盘,其 fold 对非法 splice(id 重复、start 越界)**直接抛错**,自己写等于拿对方会话的完整性冒险。`prompt` 的受理流程会先 resume 目标会话,唤醒语义由本体保证;`requestId` 是幂等键(本体持久化在那条被接受的消息上),重试传同键不会重复投递。
|
|
197
197
|
- **`create_session` 为什么是两步**:本体 `SessionCreateRequest` 只有 `workspaceId? / cwd? / sessionId? / agentPreset?`,**没有 model 字段**,所以必须 `create` → `selectModel`。任一步失败都如实回执(如「⚠️ 会话已建,但模型未设置」),**不谎报成功**。
|