dsh-expert-group 0.1.1
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/LICENSE +38 -0
- package/README.md +355 -0
- package/THIRD_PARTY_NOTICES.md +35 -0
- package/assets/notification-whoosh.wav +0 -0
- package/bin/prompt-inspector.mjs +63 -0
- package/client-bundle.js +14891 -0
- package/client-expert-group-model.js +426 -0
- package/client-expert-group-sidebar.js +5401 -0
- package/client-freshness.js +364 -0
- package/client-model-config.js +2155 -0
- package/client-notifications.js +38 -0
- package/client.js +6487 -0
- package/cordis.patch.yml +10 -0
- package/fanout-host.js +1507 -0
- package/fanout.js +2981 -0
- package/host/composition.js +155 -0
- package/host/expert-group-runtime.js +822 -0
- package/host/expert-group-snapshot.js +450 -0
- package/host/fanout-runtime.js +822 -0
- package/host/handoff.js +80 -0
- package/host/lineage.js +274 -0
- package/host/notifications.js +189 -0
- package/host/persistence-health.js +389 -0
- package/host/recovery.js +242 -0
- package/host/rpc-transport.js +573 -0
- package/host/settings-runtime.js +168 -0
- package/host/settings-source.js +265 -0
- package/host/storage-binding.js +94 -0
- package/host/system-toast.js +916 -0
- package/image-routing.js +263 -0
- package/index.js +9327 -0
- package/install.ps1 +219 -0
- package/orchestrator.js +239 -0
- package/package.json +142 -0
- package/prompt-contracts.js +148 -0
- package/prompt-inspector.js +584 -0
- package/prompt-sidecar.js +610 -0
- package/resource-budget.js +540 -0
- package/role-registry.js +286 -0
- package/rpc-host.js +310 -0
- package/scripts/assemble-client.mjs +605 -0
- package/scripts/build.mjs +124 -0
- package/shared.js +4003 -0
- package/storage.js +190 -0
- package/uninstall.ps1 +47 -0
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expert Group Host 运行时适配器。
|
|
3
|
+
*
|
|
4
|
+
* 本模块只读取 DEG 已有的 root/child/fan-out/goal 状态,并把不稳定的 DSH 对象形状
|
|
5
|
+
* 收敛为 expert-group-snapshot 深模块的输入。它不持久化第二套任务状态,也不参与
|
|
6
|
+
* 任务调度、恢复或 fan-out 结算。
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-expert-group/host/expert-group-runtime
|
|
9
|
+
*/
|
|
10
|
+
import { buildExpertGroupSnapshot } from "./expert-group-snapshot.js";
|
|
11
|
+
|
|
12
|
+
function nonEmpty(value) {
|
|
13
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function firstText(...values) {
|
|
17
|
+
for (const value of values) {
|
|
18
|
+
const text = nonEmpty(value);
|
|
19
|
+
if (text !== null) return text;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function safeCall(callback, fallback = undefined) {
|
|
25
|
+
try {
|
|
26
|
+
const value = callback();
|
|
27
|
+
return value === undefined ? fallback : value;
|
|
28
|
+
} catch {
|
|
29
|
+
return fallback;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* live Session 不暴露 events 数组,只能通过 snapshotEvents 读取已提交事件;
|
|
35
|
+
* cold session 则由 sessionQuery 直接返回 events。一次快照只读取一次,避免标题、
|
|
36
|
+
* 最近命令和状态分别读取时跨提交撕裂。
|
|
37
|
+
*/
|
|
38
|
+
function sessionEvents(session) {
|
|
39
|
+
const projected = safeCall(() => session?.snapshotEvents?.(), undefined);
|
|
40
|
+
if (Array.isArray(projected)) return projected;
|
|
41
|
+
return Array.isArray(session?.events) ? session.events : [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 只提取真实文本片段;工具对象、图片和结构化附件不会被序列化进侧边栏。 */
|
|
45
|
+
function messageText(message) {
|
|
46
|
+
const content = message?.content ?? message;
|
|
47
|
+
if (typeof content === "string") return nonEmpty(content);
|
|
48
|
+
if (!Array.isArray(content)) return null;
|
|
49
|
+
const parts = content
|
|
50
|
+
.map((part) => typeof part === "string" ? part : part?.type === "text" ? part.text : null)
|
|
51
|
+
.filter((part) => typeof part === "string" && part.trim().length > 0);
|
|
52
|
+
return parts.length > 0 ? parts.join("\n") : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 最近请求只接受真实 user source,避免把插件反馈或 manager 注入文本误当用户目标。 */
|
|
56
|
+
function recentUserRequest(session, events = sessionEvents(session)) {
|
|
57
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
58
|
+
const event = events[index];
|
|
59
|
+
if (event?.type !== "user/message" || event?.data?.source?.kind !== "user") continue;
|
|
60
|
+
const text = messageText(event.data?.message ?? event.data?.content);
|
|
61
|
+
if (text !== null) return text;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 冷 child 的首条 user/message 就是 manager 派发 prompt;后续恢复消息不能覆盖它。 */
|
|
67
|
+
function dispatchedPrompt(session, events = sessionEvents(session)) {
|
|
68
|
+
for (const event of events) {
|
|
69
|
+
if (event?.type !== "user/message") continue;
|
|
70
|
+
const text = messageText(event.data?.message ?? event.data?.content);
|
|
71
|
+
if (text !== null) return text;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function latestSessionTitle(session, events = sessionEvents(session)) {
|
|
77
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
78
|
+
if (events[index]?.type === "session/title") return firstText(events[index]?.data?.title);
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function latestTurnReason(session, events = sessionEvents(session)) {
|
|
84
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
85
|
+
if (events[index]?.type === "turn/end") return events[index]?.data?.reason ?? null;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function activityOf(status, events, state = toolCallState(events)) {
|
|
91
|
+
for (const call of state.calls) {
|
|
92
|
+
// turn 已结束的调用不可能还在执行:中断丢结果的孤儿 call 不算 running。
|
|
93
|
+
if (state.turnEnded(call.identity.turn)) continue;
|
|
94
|
+
if (!state.isSettled(call)) return "running";
|
|
95
|
+
}
|
|
96
|
+
if (status === "running" || status === "waiting" || status === "queued" || status === "pending") return "running";
|
|
97
|
+
return status === "unknown" ? "unknown" : "idle";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 普通专家没有 fan-out manager 的权威终态,因此只使用 runtime status、恢复队列和
|
|
102
|
+
* 最新 turn/end。无法证明的旧会话保持 ready/unknown,不把 inactive 猜成成功。
|
|
103
|
+
*/
|
|
104
|
+
function expertStatus({ agent, record, session, events = sessionEvents(session) }) {
|
|
105
|
+
const runtime = firstText(agent?.status, session?.status);
|
|
106
|
+
if (runtime === "running") return "running";
|
|
107
|
+
if (record?.pendingRecoveries instanceof Map && record.pendingRecoveries.size > 0) return "waiting";
|
|
108
|
+
const reason = latestTurnReason(session, events);
|
|
109
|
+
const kind = firstText(reason?.kind)?.toLowerCase();
|
|
110
|
+
if (kind === "completed") return "succeeded";
|
|
111
|
+
if (kind === "aborted" || kind === "cancelled" || kind === "canceled") return "cancelled";
|
|
112
|
+
if (kind === "timeout" || kind === "timed-out" || kind === "timed_out") return "timed_out";
|
|
113
|
+
if (kind === "interrupted") return "interrupted";
|
|
114
|
+
if (["error", "refusal", "blocked", "max-tokens"].includes(kind)) return "failed";
|
|
115
|
+
if (runtime === "idle") return "idle";
|
|
116
|
+
if (agent === undefined || agent === null) return "ready";
|
|
117
|
+
return "unknown";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sessionTitle(session, sessionId, events = sessionEvents(session)) {
|
|
121
|
+
// session/title 才是 DSH 的权威标题;SessionHeader 本身没有 title 字段。
|
|
122
|
+
// sessionId 不作为标题回退,避免把内部身份误展示成用户任务名。
|
|
123
|
+
return firstText(latestSessionTitle(session, events));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 取系统通知第二行使用的 root 任务名称。
|
|
128
|
+
*
|
|
129
|
+
* 通知只能使用 DSH 已发布的 session/title;缺失时返回 null,不能用 session id、prompt
|
|
130
|
+
* 或时间戳猜测任务名称。事件快照由调用方所在 Host Session 提供,不新增持久化状态。
|
|
131
|
+
*/
|
|
132
|
+
export function rootNotificationLabel(session) {
|
|
133
|
+
return sessionTitle(session, session?.id, sessionEvents(session));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function asObject(value) {
|
|
137
|
+
return value !== null && typeof value === "object" ? value : undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseToolArguments(value) {
|
|
141
|
+
if (value !== null && typeof value === "object") return value;
|
|
142
|
+
if (typeof value !== "string" || value.trim() === "") return {};
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(value);
|
|
145
|
+
return parsed !== null && typeof parsed === "object" ? parsed : {};
|
|
146
|
+
} catch {
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 单条 tool 事件的身份归一(真实 DSH 形状 + 旧形状兼容)。
|
|
153
|
+
*
|
|
154
|
+
* - `tool/call`:SessionEventMap 声明 `{ turn, step, callId, name, arguments }`,身份
|
|
155
|
+
* 字段直接挂在 data 上;但模型输出的 tool-call block 也在消息封装里
|
|
156
|
+
* (`message.content[*]`),因此 name/arguments 允许从 block 读取。
|
|
157
|
+
* - `tool/result`:声明 `{ turn, step, message, error?, meta? }`——**顶层没有 callId**,
|
|
158
|
+
* 调用身份只在消息封装里:`message.source.callId`(kind:"tool" 的 ToolMessageSource)。
|
|
159
|
+
* - 扁平 `data.callId` 仍被接受:快照、旧版本与插件自造事件用它。
|
|
160
|
+
* 两处都读不到 callId 时返回 null——绝不按出现顺序猜测配对。
|
|
161
|
+
*/
|
|
162
|
+
function toolEventIdentity(data) {
|
|
163
|
+
const source = asObject(data);
|
|
164
|
+
const message = asObject(source?.message);
|
|
165
|
+
const blocks = (Array.isArray(message?.content) ? message.content : []).filter((part) => asObject(part) !== undefined);
|
|
166
|
+
// 多 block 消息(例如 text + tool-call)优先取真正携带调用身份的那一块。
|
|
167
|
+
const block = blocks.find((part) => firstText(part?.callId, part?.name, part?.toolName) !== null) ?? blocks[0];
|
|
168
|
+
return {
|
|
169
|
+
callId: firstText(source?.callId, message?.source?.callId, block?.callId),
|
|
170
|
+
name: firstText(source?.name, source?.toolName, source?.command, block?.name, block?.toolName),
|
|
171
|
+
args: source?.arguments ?? block?.arguments,
|
|
172
|
+
turn: Number.isSafeInteger(source?.turn) ? source.turn : null,
|
|
173
|
+
step: Number.isSafeInteger(source?.step) ? source.step : null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 定位键 turn:step:callId:不同 step 复用同名 callId 时不会互相顶替。 */
|
|
178
|
+
function toolCallKey(identity) {
|
|
179
|
+
return String(identity.turn ?? "?") + ":" + String(identity.step ?? "?") + ":" + String(identity.callId);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* tool 事件状态:calls/results 都保留原始身份,由 isSettled 决定是否配对成功。
|
|
184
|
+
*
|
|
185
|
+
* 配对优先级(callId 是权威身份,turn/step 只是消歧):
|
|
186
|
+
* 1. 两边都有 turn/step:必须 turn:step:callId 全等;
|
|
187
|
+
* 2. 任一方缺 turn/step(旧快照/部分输入):退化为裸 callId 配对。
|
|
188
|
+
* 另外收集已 `turn/end` 的 turn:该 turn 的调用不可能仍在执行(中断时结果事件
|
|
189
|
+
* 永远不会到达),因此不算 running,避免把已完成调用误判成进行中。
|
|
190
|
+
*/
|
|
191
|
+
function toolCallState(events) {
|
|
192
|
+
const calls = [];
|
|
193
|
+
const results = [];
|
|
194
|
+
const endedTurns = new Set();
|
|
195
|
+
for (const event of events) {
|
|
196
|
+
if (event?.type === "turn/end") {
|
|
197
|
+
const turn = asObject(event.data)?.turn;
|
|
198
|
+
if (Number.isSafeInteger(turn)) endedTurns.add(turn);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (event?.type !== "tool/call" && event?.type !== "tool/result") continue;
|
|
202
|
+
const identity = toolEventIdentity(event.data);
|
|
203
|
+
if (identity.callId === null) continue;
|
|
204
|
+
(event.type === "tool/call" ? calls : results).push({ identity, key: toolCallKey(identity) });
|
|
205
|
+
}
|
|
206
|
+
const resultKeys = new Set();
|
|
207
|
+
const resultCallIds = new Set();
|
|
208
|
+
const looseResultCallIds = new Set();
|
|
209
|
+
for (const result of results) {
|
|
210
|
+
resultKeys.add(result.key);
|
|
211
|
+
resultCallIds.add(result.identity.callId);
|
|
212
|
+
if (result.identity.turn === null || result.identity.step === null) looseResultCallIds.add(result.identity.callId);
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
calls,
|
|
216
|
+
turnEnded: (turn) => turn !== null && endedTurns.has(turn),
|
|
217
|
+
isSettled(call) {
|
|
218
|
+
if (resultKeys.has(call.key)) return true;
|
|
219
|
+
// 调用自己缺位置:按裸 callId 接受任意结果。
|
|
220
|
+
if (call.identity.turn === null || call.identity.step === null) return resultCallIds.has(call.identity.callId);
|
|
221
|
+
// 只有结果缺位置时才退化为裸 callId,避免跨 step 的同名 callId 误配对。
|
|
222
|
+
return looseResultCallIds.has(call.identity.callId);
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function toolCommandText(identity) {
|
|
228
|
+
if (identity.name === null) return null;
|
|
229
|
+
const args = parseToolArguments(identity.args);
|
|
230
|
+
const detail = firstText(
|
|
231
|
+
args.file_path, args.path, args.pattern, args.command, args.description, args.query, args.prompt,
|
|
232
|
+
);
|
|
233
|
+
const labels = { edit: "编辑", write: "写入", read: "读取", grep: "Grep", glob: "Glob", pwsh: "PowerShell", run_code: "运行代码" };
|
|
234
|
+
return detail === null ? (labels[identity.name] || identity.name) : (labels[identity.name] || identity.name) + " " + detail;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 从最新提交的 tool/call 中提取命令;无专用 current-command 事件时不猜测其它文本。 */
|
|
238
|
+
function latestCommand(session, events = sessionEvents(session), state = toolCallState(events)) {
|
|
239
|
+
for (let index = state.calls.length - 1; index >= 0; index -= 1) {
|
|
240
|
+
const call = state.calls[index];
|
|
241
|
+
// 所属 turn 已结束(含中断丢结果)的调用不再等待结果,也不算未完成命令。
|
|
242
|
+
if (state.turnEnded(call.identity.turn)) continue;
|
|
243
|
+
if (!state.isSettled(call)) return toolCommandText(call.identity);
|
|
244
|
+
}
|
|
245
|
+
return state.calls.length === 0 ? null : toolCommandText(state.calls[state.calls.length - 1].identity);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function runSummary(session, status, mode, events = sessionEvents(session)) {
|
|
249
|
+
const state = toolCallState(events);
|
|
250
|
+
return {
|
|
251
|
+
command: latestCommand(session, events, state),
|
|
252
|
+
mode: mode === "continuable" || mode === "one-shot" ? mode : "unknown",
|
|
253
|
+
activity: activityOf(status, events, state),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function modelMetadata(agent, persisted, context) {
|
|
258
|
+
const header = safeCall(() => agent?.session?.requestHeader?.(), null);
|
|
259
|
+
const config = header?.config ?? {};
|
|
260
|
+
return {
|
|
261
|
+
provider: firstText(config.provider, agent?.options?.provider, context?.provider, persisted?.provider),
|
|
262
|
+
model: firstText(config.model, agent?.options?.model, context?.model, persisted?.model),
|
|
263
|
+
effort: firstText(
|
|
264
|
+
config.reasoningEffort,
|
|
265
|
+
config.reasoning_effort,
|
|
266
|
+
agent?.options?.reasoningEffort,
|
|
267
|
+
agent?.options?.__degExpertEffort,
|
|
268
|
+
context?.reasoningEffort,
|
|
269
|
+
persisted?.reasoningEffort,
|
|
270
|
+
),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function createdAtOf(...values) {
|
|
275
|
+
for (const value of values) {
|
|
276
|
+
if (Number.isFinite(value) && value >= 0) return value;
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── 冷 child 会话读取的受控并发 ──────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 冷读并发上限(**全局**,由本次构建的冷读协调器统一执行)。
|
|
285
|
+
*
|
|
286
|
+
* 每个 `sessionQuery.readSession` 都可能完整解析一份大日志(文件 IO + 反序列化),
|
|
287
|
+
* 一次性无界并发会在冷启动瞬间同时占用大量内存与文件句柄,因此只允许固定的小批量并行。
|
|
288
|
+
* root 冷读、普通 expert 阶段、fan-out 的 task/shard/attempt 证明全部计入同一个上限;
|
|
289
|
+
* 普通 expert 阶段的 forEachWithConcurrency 也用这个值作为 worker 数,两者叠加仍不超过 4。
|
|
290
|
+
* 取值 4:足以把一个根下若干普通专家的等待时间从「逐个求和」压回「取最大值」,又明显低于
|
|
291
|
+
* fan-out 的 per-role 默认并发(10)。live/cached 会话不进入这个并发池。
|
|
292
|
+
*/
|
|
293
|
+
const COLD_READ_CONCURRENCY = 4;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 受控并发的遍历(彼此独立的项并行、并发数有上限)。
|
|
297
|
+
*
|
|
298
|
+
* 与 `for...of await` 的区别:互不依赖的项不再互相等待;与裸
|
|
299
|
+
* `Promise.all(items.map(worker))` 的区别:同时进行的 worker 数有上限。
|
|
300
|
+
*
|
|
301
|
+
* 失败生命周期(与串行循环一致:错误原样上抛,且不留下仍在后台进行的读取):
|
|
302
|
+
* - 首个 worker 抛错时**立即停止领取新项**,但已经启动的 worker 不取消、不提前放弃;
|
|
303
|
+
* - 仍然等待所有已启动 worker 全部 settle:迟到的成功照常写回,迟到的失败被收敛在这里
|
|
304
|
+
* (不覆盖首个错误、不重新抛出,因此不会产生未处理的 rejection);
|
|
305
|
+
* - 全部 settle 之后才向调用方**原样抛出首个错误对象**,所以本函数返回或抛出时都不会残留
|
|
306
|
+
* 后台 worker:快照失败后同一 root 的立即重试不会与旧批次的读取重叠;
|
|
307
|
+
* - 成功路径不变:受控并发 + 完成顺序无关(调用方按输入顺序取用结果)。
|
|
308
|
+
*/
|
|
309
|
+
async function forEachWithConcurrency(items, limit, worker) {
|
|
310
|
+
if (items.length === 0) return;
|
|
311
|
+
const size = Math.min(Math.max(1, limit), items.length);
|
|
312
|
+
let cursor = 0;
|
|
313
|
+
let stopped = false;
|
|
314
|
+
let failed = false;
|
|
315
|
+
let firstError;
|
|
316
|
+
const run = async () => {
|
|
317
|
+
while (!stopped) {
|
|
318
|
+
const index = cursor;
|
|
319
|
+
cursor += 1;
|
|
320
|
+
if (index >= items.length) return;
|
|
321
|
+
try {
|
|
322
|
+
await worker(items[index], index);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
// 首个失败决定最终抛出的错误并立即停止领取新项;随后到达的失败只在这里被收敛。
|
|
325
|
+
if (!failed) {
|
|
326
|
+
failed = true;
|
|
327
|
+
firstError = error;
|
|
328
|
+
stopped = true;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
// run 从不在内部抛出:Promise.all 因而总会等到所有已启动 worker settle(含迟到的成功与失败),
|
|
334
|
+
// 既不会留下仍在后台进行的读取,也不会产生未处理的 rejection。
|
|
335
|
+
await Promise.all(Array.from({ length: size }, () => run()));
|
|
336
|
+
if (failed) throw firstError;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 从 Promise.allSettled 的结果数组里取**首个** rejection(按数组顺序,稳定),全部成功时返回 null。
|
|
341
|
+
*
|
|
342
|
+
* 用对象包住 reason,是因为 worker 可以 `throw undefined`:直接返回 reason 无法与「没有失败」
|
|
343
|
+
* 区分。阶段投影统一用它收敛错误:所有已调度分支 settle 之后才向上抛错,且抛出的是同一个
|
|
344
|
+
* 错误对象,重复构建得到的结果稳定(不依赖完成顺序)。
|
|
345
|
+
*/
|
|
346
|
+
function firstRejection(results) {
|
|
347
|
+
for (const result of results) {
|
|
348
|
+
if (result.status === "rejected") return { reason: result.reason };
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* @param {object} dependencies
|
|
355
|
+
* @returns {{snapshotForSession(sessionId:string): Promise<object>}}
|
|
356
|
+
*/
|
|
357
|
+
export function createExpertGroupRuntime({
|
|
358
|
+
agents,
|
|
359
|
+
goals,
|
|
360
|
+
lineage,
|
|
361
|
+
fanoutHost,
|
|
362
|
+
recovery,
|
|
363
|
+
recoveryRecords,
|
|
364
|
+
source,
|
|
365
|
+
sessionQuery,
|
|
366
|
+
now = Date.now,
|
|
367
|
+
} = {}) {
|
|
368
|
+
if (!(recoveryRecords instanceof Map)) throw new TypeError("Expert Group runtime 需要 recoveryRecords Map");
|
|
369
|
+
if (typeof now !== "function") throw new TypeError("Expert Group runtime 需要 now 函数");
|
|
370
|
+
// root 与 child surface 可能同时请求同一份树;只合并重叠的构建期,完成后不保留
|
|
371
|
+
// 旧快照,确保下一次通知仍读取最新状态。
|
|
372
|
+
const snapshotInFlight = new Map();
|
|
373
|
+
|
|
374
|
+
function persistedChildren() {
|
|
375
|
+
const snapshot = safeCall(() => source?.(), null);
|
|
376
|
+
return snapshot?.childModels && typeof snapshot.childModels === "object" ? snapshot.childModels : {};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* 当前根会话拥有的 fan-out 父任务。
|
|
381
|
+
*
|
|
382
|
+
* 归属查询优先问 fanout runtime:它按同一套 parentSessions 语义过滤 manager 的权威
|
|
383
|
+
* 任务,并额外覆盖「活动 ownership 已释放、但持久化仍记录归属」的已终态恢复任务
|
|
384
|
+
* (冷启动后这些任务必须继续出现在自己的根下)。两者都按根过滤,绝不退回全局
|
|
385
|
+
* listTasks()——没有归属证明时保持 fail-closed,不借任务视图枚举别的会话。
|
|
386
|
+
*/
|
|
387
|
+
function fanoutTasksOwnedBy(sessionId) {
|
|
388
|
+
const fromRuntime = safeCall(() => fanoutHost?.tasksOwnedBy?.(sessionId), undefined);
|
|
389
|
+
if (Array.isArray(fromRuntime)) return fromRuntime;
|
|
390
|
+
const fromLineage = safeCall(() => lineage?.tasksOwnedBy?.(sessionId), undefined);
|
|
391
|
+
return Array.isArray(fromLineage) ? fromLineage : [];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function isMissingSessionError(error) {
|
|
395
|
+
const code = firstText(error?.code, error?.name)?.toLowerCase();
|
|
396
|
+
const message = typeof error?.message === "string" ? error.message.toLowerCase() : "";
|
|
397
|
+
return code === "enoent" || code === "not_found" || code === "session_not_found"
|
|
398
|
+
|| message.includes("session not found") || message.includes("session-not-found");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function readColdSession(sessionId, parentSessionId) {
|
|
402
|
+
const query = typeof sessionQuery === "function" ? safeCall(() => sessionQuery(), undefined) : sessionQuery;
|
|
403
|
+
if (typeof query?.readSession !== "function") return undefined;
|
|
404
|
+
let log;
|
|
405
|
+
try {
|
|
406
|
+
log = await query.readSession(sessionId);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
// 只有明确的“不存在”才允许继续 ownership fallback;权限、存储和协议错误必须向上暴露。
|
|
409
|
+
if (isMissingSessionError(error)) return undefined;
|
|
410
|
+
throw error;
|
|
411
|
+
}
|
|
412
|
+
const header = log?.session;
|
|
413
|
+
const actualParent = header?.parentSession ?? null;
|
|
414
|
+
const expectedParent = parentSessionId ?? null;
|
|
415
|
+
if (header?.id !== sessionId || actualParent !== expectedParent) return undefined;
|
|
416
|
+
return {
|
|
417
|
+
id: sessionId,
|
|
418
|
+
header,
|
|
419
|
+
events: Array.isArray(log.events) ? log.events : [],
|
|
420
|
+
status: "ready",
|
|
421
|
+
requestHeader: () => ({ config: header?.config ?? {} }),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* 单次 buildSnapshotForSession 专用的冷读协调器(**绝不跨 root、跨快照复用**)。
|
|
427
|
+
*
|
|
428
|
+
* 同一次投影会从多个入口读取同一份 child 会话:root 冷读、普通 expert 阶段、fan-out 的
|
|
429
|
+
* task/shard/attempt 归属证明。这些入口分属不同阶段且可以并行,因此协调器负责两件事:
|
|
430
|
+
* 1. 按 `parentSessionId + childId` 缓存**读取 promise 本身**:同一快照内同一 child 只会真正
|
|
431
|
+
* 调用一次 `sessionQuery.readSession`;`undefined`(会话不存在/父子不符)与 rejection 同样
|
|
432
|
+
* 被缓存复用,所以第二个调用者拿到的是同一个结果或同一个错误对象,不会触发第二次读取;
|
|
433
|
+
* 2. 真实 readSession 全局最多 COLD_READ_CONCURRENCY 个同时在飞:请求按 FIFO 排队,
|
|
434
|
+
* 各阶段自己再收敛顺序与错误,因此阶段并行也不会突破上限。
|
|
435
|
+
* 缓存随本次构建回收,构建结束后不再被任何后续快照(含失败后的立即重试)共享。
|
|
436
|
+
*/
|
|
437
|
+
function createColdReadCoordinator() {
|
|
438
|
+
const reads = new Map();
|
|
439
|
+
const queue = [];
|
|
440
|
+
let active = 0;
|
|
441
|
+
const pump = () => {
|
|
442
|
+
while (active < COLD_READ_CONCURRENCY && queue.length > 0) {
|
|
443
|
+
const request = queue.shift();
|
|
444
|
+
active += 1;
|
|
445
|
+
// 这两个回调只 settle 请求方自己的 promise,返回的链式 promise 总是 resolve,
|
|
446
|
+
// 因此协调器内部不会产生未处理的 rejection;请求方的 rejection 由调用方负责处理。
|
|
447
|
+
readColdSession(request.childId, request.parentSessionId).then(
|
|
448
|
+
(session) => { active -= 1; request.resolve(session); pump(); },
|
|
449
|
+
(error) => { active -= 1; request.reject(error); pump(); },
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
return {
|
|
454
|
+
/** 读取并校验某个 child(root 时 parentSessionId 传 null)的冷会话;同键只读一次。 */
|
|
455
|
+
read(childId, parentSessionId) {
|
|
456
|
+
const key = (parentSessionId ?? "") + "|" + childId;
|
|
457
|
+
const cached = reads.get(key);
|
|
458
|
+
if (cached !== undefined) return cached;
|
|
459
|
+
const promise = new Promise((resolve, reject) => {
|
|
460
|
+
queue.push({ childId, parentSessionId, resolve, reject });
|
|
461
|
+
pump();
|
|
462
|
+
});
|
|
463
|
+
reads.set(key, promise);
|
|
464
|
+
return promise;
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* 收集 child 的**显式父会话证据**(去重后的 id 集合)。
|
|
471
|
+
*
|
|
472
|
+
* 全部来自既有对象上的显式字段,不新建状态库:
|
|
473
|
+
* - 在线 agent 的 session.header.parentSession(DSH 权威父子关系);
|
|
474
|
+
* - 缓存 session(record.session)的 header.parentSession;
|
|
475
|
+
* - DEG record.parentId / record.parentAgent.id(record 持有的是 exact 父代际);
|
|
476
|
+
* - 持久化 childSnapshot.parentId。
|
|
477
|
+
* 只有能读出非空字符串的字段才算证据;缺失字段不参与判定。
|
|
478
|
+
*/
|
|
479
|
+
function collectParentEvidence({ agent, record, childSnapshot, session } = {}) {
|
|
480
|
+
const evidence = new Set();
|
|
481
|
+
const add = (value) => {
|
|
482
|
+
const id = nonEmpty(value);
|
|
483
|
+
if (id !== null) evidence.add(id);
|
|
484
|
+
};
|
|
485
|
+
add(safeCall(() => agent?.session?.header?.parentSession, undefined));
|
|
486
|
+
add(safeCall(() => session?.header?.parentSession, undefined));
|
|
487
|
+
add(record?.parentId);
|
|
488
|
+
add(safeCall(() => record?.parentAgent?.id, undefined));
|
|
489
|
+
add(childSnapshot?.parentId);
|
|
490
|
+
return evidence;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* 归属判定(fail-closed):
|
|
495
|
+
* - 存在任何不等于 rootId 的显式父证据 → "foreign":任一根查询都不得投影该 child,
|
|
496
|
+
* 也不能读它的会话内容(旧 record 说本根、live session 说别的根属于冲突,必须拒绝);
|
|
497
|
+
* - 证据恰好只有 rootId → "owned";
|
|
498
|
+
* - 没有任何显式父证据 → "unknown":由调用方决定(普通专家拒绝;fan-out 允许继续用
|
|
499
|
+
* 父任务级证据或冷读校验判定)。
|
|
500
|
+
*/
|
|
501
|
+
function parentEvidenceVerdict(evidence, rootId) {
|
|
502
|
+
if (evidence.size === 0) return "unknown";
|
|
503
|
+
for (const id of evidence) {
|
|
504
|
+
if (id !== rootId) return "foreign";
|
|
505
|
+
}
|
|
506
|
+
return "owned";
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* 证明某个 attempt child 属于当前 root,并给出可用于投影的会话。
|
|
511
|
+
*
|
|
512
|
+
* 三条独立证据,任一成立即可(全部读取既有 DEG/DSH 状态,不新建登记):
|
|
513
|
+
* 1. 在线 child agent 的会话头声明 `header.parentSession === rootId`;
|
|
514
|
+
* 2. DEG 自己的 child 记录匹配:普通专家记录用 `parentId === rootId`,fan-out 子代理
|
|
515
|
+
* 记录用 `fanoutParentTaskId === 当前父任务`(父任务本身已由 manager 的 ownership
|
|
516
|
+
* 过滤证明属于本 root,任务 id 也按 root 唯一,因此这不是跨根推断);
|
|
517
|
+
* 3. 冷读 `sessionQuery.readSession(childId)` 的 header.id/parentSession 与预期一致
|
|
518
|
+
* (readColdSession 自带这两项校验)。
|
|
519
|
+
* 三条都不成立时 owned=false:这个 childId 可能属于另一个 root,投影必须丢弃它,
|
|
520
|
+
* 否则客户端一点就会导航到跨根会话,也会把别的根的模型/命令渲染进本根任务树。
|
|
521
|
+
* 结果在同一次快照内按「父任务 + childId」缓存,避免重复的归属计算;真正的冷读一律交给
|
|
522
|
+
* 本次构建的 `coordinator`,因此 fan-out attempt 与普通 expert 阶段对同一个 child 只会
|
|
523
|
+
* 调用一次 readSession(同一 parentSessionId + childId 共享同一个读取 promise)。
|
|
524
|
+
*/
|
|
525
|
+
async function resolveOwnedChild(childId, { rootId, parentTaskId, persisted, cache, coordinator }) {
|
|
526
|
+
if (typeof childId !== "string" || childId.length === 0) {
|
|
527
|
+
return { owned: false, childId: null, agent: undefined, session: undefined, childSnapshot: null };
|
|
528
|
+
}
|
|
529
|
+
// 缓存键带上父任务:同一次快照里 fan-out 归属是任务级的。
|
|
530
|
+
const cacheKey = (typeof parentTaskId === "string" ? parentTaskId : "") + "|" + childId;
|
|
531
|
+
const cached = cache.get(cacheKey);
|
|
532
|
+
if (cached !== undefined) return cached;
|
|
533
|
+
const record = recoveryRecords.get(childId);
|
|
534
|
+
const childSnapshot = persisted[childId] ?? null;
|
|
535
|
+
const agent = safeCall(() => agents?.get?.(childId), undefined) ?? record?.agent;
|
|
536
|
+
// 显式父证据一旦互相矛盾(例如旧 record 说本根、live session 说别的根),
|
|
537
|
+
// 就一律拒绝:ownedByAgent/ownedByRecord 的 OR 语义不得绕过冲突检查。
|
|
538
|
+
const evidence = collectParentEvidence({ agent, record, childSnapshot, session: record?.session });
|
|
539
|
+
const verdict = parentEvidenceVerdict(evidence, rootId);
|
|
540
|
+
const ownedByFanoutRecord = typeof parentTaskId === "string" && parentTaskId.length > 0
|
|
541
|
+
&& (nonEmpty(childSnapshot?.fanoutParentTaskId) === parentTaskId || nonEmpty(record?.fanoutParentTaskId) === parentTaskId);
|
|
542
|
+
let resolved;
|
|
543
|
+
if (verdict === "foreign") {
|
|
544
|
+
resolved = { owned: false, childId, agent: undefined, session: undefined, childSnapshot: null };
|
|
545
|
+
} else if (verdict === "owned" || ownedByFanoutRecord) {
|
|
546
|
+
const session = record?.session ?? agent?.session ?? await coordinator.read(childId, rootId);
|
|
547
|
+
resolved = {
|
|
548
|
+
owned: true,
|
|
549
|
+
childId,
|
|
550
|
+
agent,
|
|
551
|
+
session,
|
|
552
|
+
// 归属已由显式证据或父任务级记录证明,允许该记录提供 provider/model 回退。
|
|
553
|
+
childSnapshot,
|
|
554
|
+
};
|
|
555
|
+
} else {
|
|
556
|
+
const coldSession = await coordinator.read(childId, rootId);
|
|
557
|
+
resolved = coldSession === undefined
|
|
558
|
+
? { owned: false, childId, agent: undefined, session: undefined, childSnapshot: null }
|
|
559
|
+
: { owned: true, childId, agent, session: coldSession, childSnapshot: null };
|
|
560
|
+
}
|
|
561
|
+
cache.set(cacheKey, resolved);
|
|
562
|
+
return resolved;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function collectExperts(root, coordinator) {
|
|
566
|
+
const persisted = persistedChildren();
|
|
567
|
+
const ids = new Set();
|
|
568
|
+
for (const record of recoveryRecords.values()) {
|
|
569
|
+
if (record?.parentId === root.id && typeof record?.childId === "string") ids.add(record.childId);
|
|
570
|
+
}
|
|
571
|
+
for (const [childId, child] of Object.entries(persisted)) {
|
|
572
|
+
if (child?.parentId === root.id && child?.fanout !== true) ids.add(childId);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// 第一步(同步,顺序 = ids):只做筛选与归属判定,把需要投影的 child 按原顺序收进 candidates。
|
|
576
|
+
// 跳过条件与归属语义(显式父证据冲突/缺失一律 fail-closed)与串行实现完全一致。
|
|
577
|
+
const candidates = [];
|
|
578
|
+
for (const childId of ids) {
|
|
579
|
+
const record = recoveryRecords.get(childId);
|
|
580
|
+
const childSnapshot = persisted[childId];
|
|
581
|
+
if (childSnapshot?.fanout === true) continue;
|
|
582
|
+
const agent = safeCall(() => agents?.get?.(childId), undefined) ?? record?.agent;
|
|
583
|
+
const cachedSession = record?.session;
|
|
584
|
+
// 只按 record.parentId 就直接使用 record.session 是不安全的:record 可能停留在旧代际,
|
|
585
|
+
// 而 live/cached session 的 header.parentSession 已经指向别的 root。这里要求所有显式
|
|
586
|
+
// 父证据一致指向本根,否则(冲突或缺失)一律 fail-closed 拒绝投影。
|
|
587
|
+
const evidence = collectParentEvidence({ agent, record, childSnapshot, session: cachedSession });
|
|
588
|
+
if (parentEvidenceVerdict(evidence, root.id) !== "owned") continue;
|
|
589
|
+
// live child(recovery 缓存会话或在线 agent 会话)已经有可投影的会话对象:不进并发池、
|
|
590
|
+
// 不等待任何冷读,保持原有快速路径;两者都缺失时才在第二步补一次冷读。
|
|
591
|
+
candidates.push({ childId, record, childSnapshot, agent, session: cachedSession ?? agent?.session });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// 第二步(受控并发):冷读之间互不依赖(各自只读一份日志,不引用彼此结果),串行会让总等待
|
|
595
|
+
// 时间随 child 数线性增长,因此交给受控并发池。每次读取都走本次构建的共享协调器,与 root
|
|
596
|
+
// 冷读、fan-out attempt 共用同一份 promise 缓存和同一个全局并发上限;这里的
|
|
597
|
+
// forEachWithConcurrency 只负责本阶段的顺序与失败收敛语义。
|
|
598
|
+
// 每个 child 读到的会话写回各自的 candidate,读取内容、归属校验与错误语义都不变;
|
|
599
|
+
// live/cached 会话在上面已被快速跳过。
|
|
600
|
+
// 失败时本阶段会先停止领取新项、等已启动的读取全部 settle,再把首个错误原样抛出,因此
|
|
601
|
+
// 构建失败返回时不会有残留的冷读,snapshotInFlight 清理后同一 root 的立即重试也不会与
|
|
602
|
+
// 旧批次重叠(并发上限仍然只有一个批次的量级)。
|
|
603
|
+
await forEachWithConcurrency(candidates, COLD_READ_CONCURRENCY, async (candidate) => {
|
|
604
|
+
if (candidate.session !== undefined && candidate.session !== null) return;
|
|
605
|
+
candidate.session = await coordinator.read(candidate.childId, root.id);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// 第三步(同步,顺序 = candidates = ids):投影顺序只由 candidates 决定,与冷读完成顺序无关,
|
|
609
|
+
// 因此并发不会改变快照里 expert 的既有顺序。
|
|
610
|
+
const experts = [];
|
|
611
|
+
for (const candidate of candidates) {
|
|
612
|
+
const { childId, record, childSnapshot, agent, session } = candidate;
|
|
613
|
+
const events = sessionEvents(session);
|
|
614
|
+
const context = safeCall(() => recovery?.contextFor?.(record), undefined) ?? record?.recoveryRequest;
|
|
615
|
+
const sessionPrompt = dispatchedPrompt(session, events);
|
|
616
|
+
const metadata = modelMetadata(agent, childSnapshot, context);
|
|
617
|
+
const status = expertStatus({ agent, record, session, events });
|
|
618
|
+
const mode = record?.continuable === true || childSnapshot?.continuable === true
|
|
619
|
+
|| agent?.options?.__degExpertContinuable === true ? "continuable" : "unknown";
|
|
620
|
+
experts.push({
|
|
621
|
+
id: childId,
|
|
622
|
+
kind: "expert",
|
|
623
|
+
sessionId: childId,
|
|
624
|
+
parentSessionId: root.id,
|
|
625
|
+
// context.label 是最新 accepted dispatch 的短描述;冷恢复再读持久 child model。
|
|
626
|
+
taskName: firstText(context?.label, childSnapshot?.lastDispatch?.description, sessionTitle(session, childId, events)),
|
|
627
|
+
content: firstText(context?.promptText, childSnapshot?.lastDispatch?.prompt, sessionPrompt),
|
|
628
|
+
contentSource: context?.promptText ? "dispatch-prompt" : sessionPrompt ? "session-log" : "unavailable",
|
|
629
|
+
role: firstText(record?.role, childSnapshot?.role, agent?.options?.__degExpertRole),
|
|
630
|
+
...metadata,
|
|
631
|
+
status,
|
|
632
|
+
running: firstText(agent?.status, session?.status) === "running",
|
|
633
|
+
runSummary: runSummary(session, status, mode, events),
|
|
634
|
+
continuable: mode === "continuable",
|
|
635
|
+
createdAt: createdAtOf(session?.createdAt, session?.header?.createdAt, childSnapshot?.createdAt),
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
return experts;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* 单个 fan-out shard 的投影。
|
|
643
|
+
*
|
|
644
|
+
* attempt 之间彼此独立,因此先把本 shard 需要的归属证明**全部调度**(同一 child 去重),
|
|
645
|
+
* 再用 allSettled 收敛:任一次读取失败,也要等本 shard 的全部证明 settle 之后才向上抛首个
|
|
646
|
+
* 错误,避免失败返回后仍有 readSession 在后台运行。真实并发由协调器统一限流,shard 内因此
|
|
647
|
+
* 可以放心地一次性调度全部 attempt。输出仍按 attempts 的输入顺序重组,与完成顺序无关。
|
|
648
|
+
* **首错契约**:shard 内的首错按 proofIds 的稳定顺序选取(attempts 输入顺序,末尾是导航回退),
|
|
649
|
+
* 与读取完成时序无关——先调度但迟到的失败,仍然优先于后调度却更早返回的失败。
|
|
650
|
+
*/
|
|
651
|
+
async function projectFanoutShard(shard, task, { rootId, persisted, cache, coordinator }) {
|
|
652
|
+
const attempts = Array.isArray(shard?.attempts) ? shard.attempts : [];
|
|
653
|
+
const latestAttempt = attempts.length > 0 ? attempts[attempts.length - 1] : undefined;
|
|
654
|
+
// 导航目标是最新 attempt 的 child;manager 若在 shard 上直接给了 childId,则作为回退。
|
|
655
|
+
const navigationChildId = firstText(latestAttempt?.childId, shard?.childId);
|
|
656
|
+
const parentTaskId = firstText(task?.parentTaskId);
|
|
657
|
+
// 去重后的归属证明请求:proofIds 决定错误与结果的稳定顺序(attempts 顺序 + 导航回退)。
|
|
658
|
+
const proofIds = [];
|
|
659
|
+
const proofPromises = new Map();
|
|
660
|
+
const requestProof = (childId) => {
|
|
661
|
+
if (childId === null || proofPromises.has(childId)) return;
|
|
662
|
+
proofIds.push(childId);
|
|
663
|
+
proofPromises.set(childId, resolveOwnedChild(childId, { rootId, parentTaskId, persisted, cache, coordinator }));
|
|
664
|
+
};
|
|
665
|
+
for (const attempt of attempts) requestProof(firstText(attempt?.childId));
|
|
666
|
+
requestProof(navigationChildId);
|
|
667
|
+
const settled = await Promise.allSettled(proofIds.map((childId) => proofPromises.get(childId)));
|
|
668
|
+
const failure = firstRejection(settled);
|
|
669
|
+
if (failure !== null) throw failure.reason;
|
|
670
|
+
const proofs = new Map(proofIds.map((childId, index) => [childId, settled[index].value]));
|
|
671
|
+
const navigationProof = navigationChildId === null ? undefined : proofs.get(navigationChildId);
|
|
672
|
+
const ownedChild = navigationProof?.owned === true ? navigationProof : undefined;
|
|
673
|
+
// 归属未证明的 childId 一律从**输出副本**清空:manager 的权威快照保持原样。
|
|
674
|
+
const projectedAttempts = attempts.map((attempt) => {
|
|
675
|
+
const proof = proofs.get(firstText(attempt?.childId));
|
|
676
|
+
return proof === undefined || proof.owned ? attempt : { ...attempt, childId: null };
|
|
677
|
+
});
|
|
678
|
+
const shardStatus = firstText(shard?.status, ownedChild?.agent?.status, ownedChild?.session?.status) ?? "unknown";
|
|
679
|
+
return {
|
|
680
|
+
...shard,
|
|
681
|
+
childId: ownedChild === undefined ? null : navigationChildId,
|
|
682
|
+
role: firstText(shard?.role, task?.role),
|
|
683
|
+
...modelMetadata(ownedChild?.agent, ownedChild?.childSnapshot ?? null, null),
|
|
684
|
+
runSummary: runSummary(ownedChild?.session, shardStatus, "continuable", sessionEvents(ownedChild?.session)),
|
|
685
|
+
continuable: true,
|
|
686
|
+
attempts: projectedAttempts,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* 单个 fan-out 父任务的投影:shards 全部并行调度,等所有 shard settle 之后再抛首个错误;
|
|
692
|
+
* 首错按 shards 的输入顺序选取(稳定,与完成时序无关);成功时按 shards 的输入顺序重组,
|
|
693
|
+
* 输出顺序与旧实现完全一致。
|
|
694
|
+
*/
|
|
695
|
+
async function projectFanoutTask(task, deps) {
|
|
696
|
+
const shards = Array.isArray(task?.shards) ? task.shards : [];
|
|
697
|
+
const settled = await Promise.allSettled(shards.map((shard) => projectFanoutShard(shard, task, deps)));
|
|
698
|
+
const failure = firstRejection(settled);
|
|
699
|
+
if (failure !== null) throw failure.reason;
|
|
700
|
+
return { ...task, shards: settled.map((result) => result.value) };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* fan-out 阶段:所有父任务并行调度(真实冷读由协调器全局限流),等所有已调度分支 settle
|
|
705
|
+
* 之后再抛出**稳定**的首个错误;成功时按 rawTasks 的输入顺序重组,保持既有输出顺序。
|
|
706
|
+
* **首错契约**:按「task → shard → attempt(含导航回退)」的输入顺序取第一个失败的 rejection,
|
|
707
|
+
* 与完成时序无关,因此同一份输入重复构建得到同一个错误对象。
|
|
708
|
+
*/
|
|
709
|
+
async function projectFanoutTasks(rawTasks, deps) {
|
|
710
|
+
const tasks = Array.isArray(rawTasks) ? rawTasks : [];
|
|
711
|
+
const settled = await Promise.allSettled(tasks.map((task) => projectFanoutTask(task, deps)));
|
|
712
|
+
const failure = firstRejection(settled);
|
|
713
|
+
if (failure !== null) throw failure.reason;
|
|
714
|
+
return settled.map((result) => result.value);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function buildSnapshotForSession(sessionId) {
|
|
718
|
+
const id = nonEmpty(sessionId);
|
|
719
|
+
if (id === null) throw new TypeError("expert-group-snapshot 需要 sessionId");
|
|
720
|
+
// 本次构建独有的冷读协调器:root 冷读、普通 expert 阶段与 fan-out 阶段共用同一份读取
|
|
721
|
+
// promise 缓存与同一个全局并发上限;构建结束即回收,不会被后续快照或别的 root 复用。
|
|
722
|
+
const coordinator = createColdReadCoordinator();
|
|
723
|
+
// 没有 ownership 证明时 fail-closed;fanoutHost 的全局 list 不能作为当前根的回退。
|
|
724
|
+
const rawFanoutTasks = fanoutTasksOwnedBy(id);
|
|
725
|
+
const persisted = persistedChildren();
|
|
726
|
+
let root = safeCall(() => lineage?.activeRoot?.(id), undefined) ?? safeCall(() => agents?.get?.(id), undefined);
|
|
727
|
+
|
|
728
|
+
if (root === undefined || root === null || root.id !== id) {
|
|
729
|
+
const coldSession = await coordinator.read(id, null);
|
|
730
|
+
if (coldSession !== undefined) {
|
|
731
|
+
root = { id, status: "ready", session: coldSession, options: coldSession.header?.config ?? {} };
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (root === undefined || root === null || root.id !== id) {
|
|
736
|
+
// sessionQuery 是可选服务。它缺失时,只允许已有 DEG 记录能够证明 ownership 的根;
|
|
737
|
+
// 一个完全陌生的 id 仍返回 404,不能借任务视图枚举别的会话。
|
|
738
|
+
const knownByChild = Object.values(persisted).some((child) => child?.parentId === id);
|
|
739
|
+
const knownByFanout = Array.isArray(rawFanoutTasks) && rawFanoutTasks.length > 0;
|
|
740
|
+
if (!knownByChild && !knownByFanout) {
|
|
741
|
+
const error = new Error("root session not found: " + id);
|
|
742
|
+
error.code = "session-not-found";
|
|
743
|
+
throw error;
|
|
744
|
+
}
|
|
745
|
+
const session = { id, header: { id }, events: [], status: "ready", requestHeader: () => ({ config: {} }) };
|
|
746
|
+
root = { id, status: "ready", session, options: {} };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const session = root.session;
|
|
750
|
+
const rootEvents = sessionEvents(session);
|
|
751
|
+
const goal = safeCall(() => goals?.get?.(root), undefined) ?? safeCall(() => goals?.get?.(id), undefined);
|
|
752
|
+
const userRequest = recentUserRequest(session, rootEvents);
|
|
753
|
+
const content = firstText(goal?.objective, userRequest);
|
|
754
|
+
const header = safeCall(() => session?.requestHeader?.(), null);
|
|
755
|
+
const metadata = modelMetadata(root, null, null);
|
|
756
|
+
const rootStatus = firstText(root?.status, session?.status) ?? "unknown";
|
|
757
|
+
// 每个 shard 的第二行必须描述最新 attempt 对应的实际 child;重试可能更换模型,
|
|
758
|
+
// 因此不能只读取父任务创建时的角色选择,也不能沿用历史 attempt 的 metadata。
|
|
759
|
+
// 同一份投影还必须做归属隔离:只有能证明属于当前 root 的 childId 才允许进入快照,
|
|
760
|
+
// 否则客户端会导航到跨根会话,并看到别的根的模型、命令与状态。
|
|
761
|
+
const childOwnership = new Map();
|
|
762
|
+
// 普通 expert 阶段先启动:它决定首屏的专家列表,理应优先占用冷读队列;fan-out 阶段紧随其后
|
|
763
|
+
// 启动。两个阶段**并行**推进,但共享同一个冷读协调器,因此真实 readSession 全局最多
|
|
764
|
+
// COLD_READ_CONCURRENCY 个同时在飞,同一个 child 也不会被两个阶段读两次。
|
|
765
|
+
const expertsStage = collectExperts(root, coordinator);
|
|
766
|
+
const fanoutStage = projectFanoutTasks(rawFanoutTasks, {
|
|
767
|
+
rootId: id,
|
|
768
|
+
persisted,
|
|
769
|
+
cache: childOwnership,
|
|
770
|
+
coordinator,
|
|
771
|
+
});
|
|
772
|
+
// 两个阶段都用 allSettled 收敛:阶段内部已保证各自已调度的分支全部 settle,这里再保证跨阶段
|
|
773
|
+
// 收敛,因此构建失败返回时不会有残留冷读,同一 root 的立即重试不会与旧批次重叠。
|
|
774
|
+
const settledStages = await Promise.allSettled([expertsStage, fanoutStage]);
|
|
775
|
+
const [expertOutcome, fanoutOutcome] = settledStages;
|
|
776
|
+
// 顶层错误选择显式按 **fan-out 优先**,用于兼容优化前的阶段顺序:改动前 buildSnapshotForSession
|
|
777
|
+
// 先 await fan-out 投影、之后才 await collectExperts,两个阶段都失败时外部只会收到 fan-out 的
|
|
778
|
+
// 错误。并行化以后必须用显式顺序保留这个可观察契约,绝不能依赖谁先失败/谁先完成(否则错误会随
|
|
779
|
+
// 负载抖动)。此处两个阶段都已 settle,所以提前 throw 不会留下后台读取。
|
|
780
|
+
const stageFailure = firstRejection([fanoutOutcome, expertOutcome]);
|
|
781
|
+
if (stageFailure !== null) throw stageFailure.reason;
|
|
782
|
+
const experts = expertOutcome.value;
|
|
783
|
+
const fanoutTasks = fanoutOutcome.value;
|
|
784
|
+
return buildExpertGroupSnapshot({
|
|
785
|
+
sessionId: id,
|
|
786
|
+
generatedAt: now(),
|
|
787
|
+
root: {
|
|
788
|
+
id,
|
|
789
|
+
kind: "root",
|
|
790
|
+
sessionId: id,
|
|
791
|
+
taskName: sessionTitle(session, id, rootEvents),
|
|
792
|
+
content,
|
|
793
|
+
contentSource: goal?.objective ? "goal" : userRequest ? "recent-user-request" : "unavailable",
|
|
794
|
+
role: "orchestrator",
|
|
795
|
+
...metadata,
|
|
796
|
+
status: rootStatus,
|
|
797
|
+
running: rootStatus === "running",
|
|
798
|
+
runSummary: runSummary(session, rootStatus, "one-shot", rootEvents),
|
|
799
|
+
continuable: false,
|
|
800
|
+
createdAt: createdAtOf(session?.createdAt, session?.header?.createdAt, header?.createdAt),
|
|
801
|
+
},
|
|
802
|
+
experts,
|
|
803
|
+
fanoutTasks,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async function snapshotForSession(sessionId) {
|
|
808
|
+
const id = nonEmpty(sessionId);
|
|
809
|
+
if (id === null) return buildSnapshotForSession(sessionId);
|
|
810
|
+
const existing = snapshotInFlight.get(id);
|
|
811
|
+
if (existing !== undefined) return existing;
|
|
812
|
+
const promise = buildSnapshotForSession(id);
|
|
813
|
+
snapshotInFlight.set(id, promise);
|
|
814
|
+
try {
|
|
815
|
+
return await promise;
|
|
816
|
+
} finally {
|
|
817
|
+
if (snapshotInFlight.get(id) === promise) snapshotInFlight.delete(id);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return Object.freeze({ snapshotForSession });
|
|
822
|
+
}
|