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