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.
Files changed (45) hide show
  1. package/LICENSE +38 -0
  2. package/README.md +355 -0
  3. package/THIRD_PARTY_NOTICES.md +35 -0
  4. package/assets/notification-whoosh.wav +0 -0
  5. package/bin/prompt-inspector.mjs +63 -0
  6. package/client-bundle.js +14891 -0
  7. package/client-expert-group-model.js +426 -0
  8. package/client-expert-group-sidebar.js +5401 -0
  9. package/client-freshness.js +364 -0
  10. package/client-model-config.js +2155 -0
  11. package/client-notifications.js +38 -0
  12. package/client.js +6487 -0
  13. package/cordis.patch.yml +10 -0
  14. package/fanout-host.js +1507 -0
  15. package/fanout.js +2981 -0
  16. package/host/composition.js +155 -0
  17. package/host/expert-group-runtime.js +822 -0
  18. package/host/expert-group-snapshot.js +450 -0
  19. package/host/fanout-runtime.js +822 -0
  20. package/host/handoff.js +80 -0
  21. package/host/lineage.js +274 -0
  22. package/host/notifications.js +189 -0
  23. package/host/persistence-health.js +389 -0
  24. package/host/recovery.js +242 -0
  25. package/host/rpc-transport.js +573 -0
  26. package/host/settings-runtime.js +168 -0
  27. package/host/settings-source.js +265 -0
  28. package/host/storage-binding.js +94 -0
  29. package/host/system-toast.js +916 -0
  30. package/image-routing.js +263 -0
  31. package/index.js +9327 -0
  32. package/install.ps1 +219 -0
  33. package/orchestrator.js +239 -0
  34. package/package.json +142 -0
  35. package/prompt-contracts.js +148 -0
  36. package/prompt-inspector.js +584 -0
  37. package/prompt-sidecar.js +610 -0
  38. package/resource-budget.js +540 -0
  39. package/role-registry.js +286 -0
  40. package/rpc-host.js +310 -0
  41. package/scripts/assemble-client.mjs +605 -0
  42. package/scripts/build.mjs +124 -0
  43. package/shared.js +4003 -0
  44. package/storage.js +190 -0
  45. package/uninstall.ps1 +47 -0
package/fanout-host.js ADDED
@@ -0,0 +1,1507 @@
1
+ /**
2
+ * Host 侧 fan-out 适配器。
3
+ *
4
+ * fanout.js 只负责可测试的调度状态机,本模块负责把一个 shard attempt
5
+ * 映射到独立的 DSH continuable child。agent/created 只补齐 child/session
6
+ * 句柄;只有 session/event 的 turn/end 才能 resolve/reject completion。
7
+ *
8
+ * close() 与异步 admission 的竞态边界:关闭后不再进入 buildRequest/startContinuable;
9
+ * 还在 buildRequest 阶段的 admission 会同步以关闭错误收敛,已经挂在 startContinuable
10
+ * 上的 launcher 若之后才拿到 childId,只对该 child 发出一次 interrupt,并用显式关闭错误
11
+ * (FanoutClosedError / runtime_closed)一次性收敛 completion。
12
+ *
13
+ * close() 同时复用 fanout.js 的 manager.close 作为共享关闭门:runtime 单独关闭后,
14
+ * 通过公开 runtime.manager.submit/retryShard/restore 发起的新操作一律结构化失败
15
+ * (MANAGER_CLOSED / manager_closed),不入队、不恢复、不新建 attempt。
16
+ */
17
+ import { createFanoutManager } from "./fanout.js";
18
+ import { FANOUT_BUDGET, checkEventBudget, checkEventHistoryBudget, checkTaskInputBudget, measureJsonBytes } from "./resource-budget.js";
19
+ import { messageIdsOfTurn } from "./shared.js";
20
+
21
+ export const FANOUT_MARKER = "__degFanout";
22
+ export const FANOUT_PARENT_TASK_MARKER = "__degFanoutParentTaskId";
23
+ export const FANOUT_PARENT_AGENT_MARKER = "__degFanoutParentAgentId";
24
+ export const FANOUT_SHARD_MARKER = "__degFanoutShardId";
25
+ export const FANOUT_ATTEMPT_MARKER = "__degFanoutAttempt";
26
+ export const FANOUT_EVENT_LOG_LIMIT = 1000;
27
+
28
+ const TERMINAL_TURN_KINDS = new Set(["completed", "success", "succeeded", "ok", "error", "failed", "blocked", "max-tokens", "cancelled", "canceled", "interrupted", "aborted", "timeout", "timed_out"]);
29
+
30
+ function isObject(value) {
31
+ return value !== null && typeof value === "object" && !Array.isArray(value);
32
+ }
33
+
34
+ function cloneJson(value) {
35
+ if (value === undefined) return undefined;
36
+ try {
37
+ return JSON.parse(JSON.stringify(value));
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function textOf(value) {
44
+ return typeof value === "string" ? value : "";
45
+ }
46
+
47
+ function nonEmptyText(value) {
48
+ const text = textOf(value);
49
+ return text.trim().length > 0 ? text : "";
50
+ }
51
+
52
+ function eventData(event) {
53
+ return isObject(event?.data) ? event.data : isObject(event) ? event : {};
54
+ }
55
+
56
+ function turnOf(event) {
57
+ const data = eventData(event);
58
+ return Number.isSafeInteger(data.turn) ? data.turn : undefined;
59
+ }
60
+
61
+ function markerOptions(agent) {
62
+ if (isObject(agent?.options)) return agent.options;
63
+ if (isObject(agent?.agentOptions)) return agent.agentOptions;
64
+ return {};
65
+ }
66
+
67
+ function markerText(options, key) {
68
+ const value = options?.[key];
69
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
70
+ }
71
+
72
+ function attemptValue(value) {
73
+ if (Number.isSafeInteger(value) && value > 0) return value;
74
+ if (typeof value === "string" && /^\d+$/u.test(value)) {
75
+ const parsed = Number(value);
76
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ function markerAttempt(options) {
82
+ return attemptValue(options?.[FANOUT_ATTEMPT_MARKER]);
83
+ }
84
+
85
+ export function fanoutAttemptKey(parentTaskId, shardId, attempt) {
86
+ return [String(parentTaskId), String(shardId), String(attempt)].join("\u001f");
87
+ }
88
+
89
+ function markerFanoutIdentity(options) {
90
+ if (options?.[FANOUT_MARKER] !== true) return undefined;
91
+ const parentTaskId = markerText(options, FANOUT_PARENT_TASK_MARKER);
92
+ const parentAgentId = markerText(options, FANOUT_PARENT_AGENT_MARKER);
93
+ const shardId = markerText(options, FANOUT_SHARD_MARKER);
94
+ const attempt = markerAttempt(options);
95
+ if (parentTaskId === undefined || parentAgentId === undefined || shardId === undefined || attempt === undefined) return undefined;
96
+ return { parentTaskId, parentAgentId, shardId, attempt };
97
+ }
98
+
99
+ /**
100
+ * 只信任显式标记且字段完整的持久化记录;普通 child 的 role/parentId 不足以证明
101
+ * 它来自 fan-out,避免 cold-resume 把普通专家误路由到 fan-out 状态机。
102
+ */
103
+ function persistedFanoutIdentity(record) {
104
+ if (!isObject(record) || record.fanout !== true) return undefined;
105
+ const parentTaskId = nonEmptyText(record.fanoutParentTaskId ?? record.parentTaskId);
106
+ const parentAgentId = nonEmptyText(record.fanoutParentAgentId ?? record.parentId);
107
+ const shardId = nonEmptyText(record.fanoutShardId ?? record.shardId);
108
+ const attempt = attemptValue(record.fanoutAttempt ?? record.attempt);
109
+ if (parentTaskId === "" || parentAgentId === "" || shardId === "" || attempt === undefined) return undefined;
110
+ return { parentTaskId, parentAgentId, shardId, attempt };
111
+ }
112
+
113
+ export function isFanoutIdentity(record) {
114
+ return persistedFanoutIdentity(record) !== undefined;
115
+ }
116
+
117
+ function fanoutIdentityOf(agent, persistedIdentity) {
118
+ return markerFanoutIdentity(markerOptions(agent)) ?? persistedFanoutIdentity(persistedIdentity);
119
+ }
120
+
121
+ /** 判断 agent 是否携带完整 fan-out marker 或可信 cold-resume 身份。 */
122
+ export function isFanoutAgent(agent, persistedIdentity) {
123
+ return fanoutIdentityOf(agent, persistedIdentity) !== undefined;
124
+ }
125
+
126
+ function resultContent(message) {
127
+ const content = message?.content;
128
+ if (Array.isArray(content) && content.length > 0) return cloneJson(content);
129
+ if (typeof content === "string" && nonEmptyText(content) !== "") return [{ type: "text", text: content }];
130
+ return undefined;
131
+ }
132
+
133
+ function chunkText(chunk) {
134
+ if (!isObject(chunk)) return "";
135
+ const direct = typeof chunk.text === "string" ? chunk.text : "";
136
+ if (direct !== "") return direct;
137
+ const delta = typeof chunk.delta === "string" ? chunk.delta : "";
138
+ if (delta !== "") return delta;
139
+ const value = typeof chunk.textDelta === "string" ? chunk.textDelta : "";
140
+ return value;
141
+ }
142
+
143
+ function sessionEventLog(session) {
144
+ // DSH 的真实 Session 通过 snapshotEvents() 暴露不可变日志;测试夹具与旧兼容对象
145
+ // 可能仍提供 events 数组。续作归属与结果提取必须共用同一事实源,否则真实 child
146
+ // 的 turn/end 会被看到,但找不到本轮 user/message,attempt 就会永久保持 running。
147
+ if (typeof session?.snapshotEvents === "function") {
148
+ const snapshot = session.snapshotEvents();
149
+ if (Array.isArray(snapshot)) return snapshot;
150
+ }
151
+ return Array.isArray(session?.events) ? session.events : [];
152
+ }
153
+
154
+ function eventsForTurn(session, turn) {
155
+ const events = sessionEventLog(session);
156
+ if (!Number.isSafeInteger(turn)) return events;
157
+ let start = -1;
158
+ for (let index = 0; index < events.length; index += 1) {
159
+ const eventTurn = turnOf(events[index]);
160
+ if (eventTurn === turn && (events[index]?.type === "turn/start" || events[index]?.type === "request/header")) start = index;
161
+ }
162
+ return start < 0 ? events.filter((event) => turnOf(event) === turn) : events.slice(start);
163
+ }
164
+
165
+ /**
166
+ * 按 DSH 最终 assistant 输出规则提取结果:最后一个非空 assistant/message
167
+ * 优先;没有 assembled message 时再拼接本轮 assistant/chunk 文本。
168
+ */
169
+ export function extractFanoutResult(session, turn) {
170
+ const events = eventsForTurn(session, turn);
171
+ let lastMessage;
172
+ let chunks = "";
173
+ for (const event of events) {
174
+ if (event?.type === "assistant/message") {
175
+ const message = eventData(event).message;
176
+ const content = resultContent(message);
177
+ if (content !== undefined) lastMessage = content;
178
+ continue;
179
+ }
180
+ if (event?.type === "assistant/chunk") chunks += chunkText(eventData(event).chunk);
181
+ }
182
+ if (lastMessage !== undefined) return lastMessage;
183
+ return chunks.trim().length > 0 ? [{ type: "text", text: chunks }] : null;
184
+ }
185
+
186
+ function turnReason(event) {
187
+ const data = eventData(event);
188
+ return data.reason ?? data.outcome ?? data.error ?? data;
189
+ }
190
+
191
+ function reasonKind(reason) {
192
+ if (typeof reason === "string") return reason.toLowerCase();
193
+ if (!isObject(reason)) return "error";
194
+ const kind = reason.kind ?? reason.type ?? reason.reason;
195
+ return typeof kind === "string" ? kind.toLowerCase() : "error";
196
+ }
197
+
198
+ function reasonMessage(reason) {
199
+ if (typeof reason === "string") return reason;
200
+ if (isObject(reason)) {
201
+ for (const value of [reason.message, reason.error, reason.detail]) {
202
+ if (typeof value === "string" && value.trim().length > 0) return value;
203
+ if (isObject(value) && typeof value.message === "string" && value.message.trim().length > 0) return value.message;
204
+ }
205
+ }
206
+ return "fan-out child turn 未正常完成";
207
+ }
208
+
209
+ function fanoutCompletionError(reason, event) {
210
+ const error = new Error(reasonMessage(reason));
211
+ const kind = reasonKind(reason);
212
+ error.name = "FanoutChildCompletionError";
213
+ error.reason = kind;
214
+ error.event = cloneJson(event);
215
+ if (isObject(reason) && typeof reason.code === "string") error.code = reason.code;
216
+ return error;
217
+ }
218
+
219
+ /**
220
+ * 运行时关闭的显式错误。
221
+ *
222
+ * 与 fanout.js 的 manager_closed 语义对齐:runtime.close() 可能晚于 manager 关闭,
223
+ * 也可能在 manager 仍开放时单独到达;launcher 拒绝与 completion 结算都使用可结构化
224
+ * 分支的 name/code/reason,调用方不需要解析文案。
225
+ */
226
+ function fanoutRuntimeClosedError(message = "fan-out runtime 已关闭") {
227
+ const error = new Error(message);
228
+ error.name = "FanoutClosedError";
229
+ error.code = "RUNTIME_CLOSED";
230
+ error.reason = "runtime_closed";
231
+ return error;
232
+ }
233
+
234
+ function createDeferred() {
235
+ let resolve;
236
+ let reject;
237
+ const promise = new Promise((resolvePromise, rejectPromise) => {
238
+ resolve = resolvePromise;
239
+ reject = rejectPromise;
240
+ });
241
+ // manager 会消费 completion,但 launch 失败时可能没有 handle;预先挂载 noop
242
+ // rejection handler,避免取消/销毁竞态制造 unhandled rejection。
243
+ promise.catch(() => {});
244
+ return { promise, resolve, reject };
245
+ }
246
+
247
+ /**
248
+ * 在途 admission 句柄(可控 thenable)。
249
+ *
250
+ * manager 只按 Promise 语义消费 launcher 的返回值(fanout.js 的 startShard 会调用
251
+ * `launched.then(onFulfilled, onRejected)`),而原生 Promise 的结算只能异步投递。
252
+ * runtime.close() 必须在**同一个同步栈内**把关闭错误交给 manager:否则紧随其后的
253
+ * manager.close()(共享关闭门)会先把仍处于 running 的 attempt 扫成
254
+ * cancelled(manager_closed),本模块的关闭原因(runtime_closed / FanoutClosedError)
255
+ * 就丢了终态。
256
+ *
257
+ * 因此这里返回一个最小 thenable:
258
+ * - 正常结算与守卫失败走 resolve/reject,按 Promise 语义(microtask)异步投递,
259
+ * 与原来的 async launcher 时序一致;
260
+ * - 关闭路径走 settleNow(),同步投递,保证仍在 buildRequest 阶段的 attempt 在 manager
261
+ * 关闭前先进入 failed(runtime_closed) 终态;
262
+ * - 同时提供 then/catch:await、Promise.race 与 assert.rejects 都按函数存在性识别
263
+ * Promise-like(assert.rejects 额外要求 catch 存在),缺少任何一个都会让调用方报错。
264
+ */
265
+ function createAdmissionHandle() {
266
+ let settled = false;
267
+ let fulfilled = false;
268
+ let value;
269
+ let failure;
270
+ let flushScheduled = false;
271
+ /** 已注册的订阅者(manager 的启动结果回调)。 */
272
+ const reactions = [];
273
+
274
+ function deliver(reaction, ok, payload) {
275
+ try {
276
+ if (ok) {
277
+ if (typeof reaction.onFulfilled === "function") reaction.onFulfilled(payload);
278
+ } else if (typeof reaction.onRejected === "function") {
279
+ reaction.onRejected(payload);
280
+ }
281
+ } catch {
282
+ // 订阅者异常属于 manager 侧;不能反向破坏 runtime 的关闭流程。
283
+ }
284
+ }
285
+
286
+ /** synchronous=true 立即投递(关闭路径),否则按 Promise 语义排到 microtask。 */
287
+ function flush(synchronous) {
288
+ if (!settled || reactions.length === 0) return;
289
+ if (!synchronous) {
290
+ if (flushScheduled) return;
291
+ flushScheduled = true;
292
+ queueMicrotask(() => {
293
+ flushScheduled = false;
294
+ flush(true);
295
+ });
296
+ return;
297
+ }
298
+ const pending = reactions.splice(0, reactions.length);
299
+ for (const reaction of pending) deliver(reaction, fulfilled, fulfilled ? value : failure);
300
+ }
301
+
302
+ function settle(ok, payload) {
303
+ if (settled) return false;
304
+ settled = true;
305
+ fulfilled = ok;
306
+ if (ok) value = payload;
307
+ else failure = payload;
308
+ flush(false);
309
+ return true;
310
+ }
311
+
312
+ return {
313
+ then(onFulfilled, onRejected) {
314
+ reactions.push({ onFulfilled, onRejected });
315
+ flush(false);
316
+ },
317
+ catch(onRejected) {
318
+ return this.then(undefined, onRejected);
319
+ },
320
+ resolve(payload) {
321
+ return settle(true, payload);
322
+ },
323
+ reject(error) {
324
+ return settle(false, error);
325
+ },
326
+ /**
327
+ * 关闭路径专用:同步投递关闭错误。
328
+ * 只在 admission 还没有把任何 child 句柄交给 manager 时调用(record.phase === "build"),
329
+ * 否则会改变「迟到 childId 仍返回句柄」的既有语义。已结算时是幂等 no-op。
330
+ */
331
+ settleNow(error) {
332
+ if (settled) return false;
333
+ settled = true;
334
+ fulfilled = false;
335
+ failure = error;
336
+ flush(true);
337
+ return true;
338
+ },
339
+ };
340
+ }
341
+
342
+ function childIdOf(value) {
343
+ if (!isObject(value)) return undefined;
344
+ const childId = typeof value.childId === "string" ? value.childId : value.subagentId;
345
+ return typeof childId === "string" && childId.length > 0 ? childId : undefined;
346
+ }
347
+
348
+ /**
349
+ * 续作启动阶段的结构化失败(无有效 messageId / messageId 与旧轮重复 / 找不到既有 child)。
350
+ *
351
+ * 这些失败与 launcher 的启动失败走同一条路径(manager 记为本轮 attempt 的启动失败),
352
+ * 但错误本身带 code/reason,便于 Host、RPC 与测试区分「没有真正开始的一轮」。
353
+ */
354
+ function continuationStartupError(reason, childId, message) {
355
+ const error = new Error(message + "(childId: " + String(childId) + ")");
356
+ error.name = "FanoutContinuationStartupError";
357
+ error.code = "FANOUT_CONTINUATION_STARTUP_FAILED";
358
+ error.reason = reason;
359
+ error.childId = childId;
360
+ return error;
361
+ }
362
+
363
+ function messageIdOf(value) {
364
+ return isObject(value) && typeof value.messageId === "string" && value.messageId.length > 0
365
+ ? value.messageId
366
+ : undefined;
367
+ }
368
+
369
+ function sessionIdOf(agent) {
370
+ const sessionId = agent?.session?.id;
371
+ if (typeof sessionId === "string" && sessionId.length > 0) return sessionId;
372
+ const agentId = agent?.id;
373
+ return typeof agentId === "string" && agentId.length > 0 ? agentId : undefined;
374
+ }
375
+
376
+ function eventIsTurnEnd(event) {
377
+ return event?.type === "turn/end" || event?.type === "turn/ended";
378
+ }
379
+
380
+ function completionIsSuccess(event) {
381
+ const reason = turnReason(event);
382
+ const kind = reasonKind(reason);
383
+ return kind === "completed" || kind === "success" || kind === "succeeded" || kind === "ok";
384
+ }
385
+
386
+ function hasTerminalTurnReason(event) {
387
+ const reason = turnReason(event);
388
+ const kind = reasonKind(reason);
389
+ return kind === "" || TERMINAL_TURN_KINDS.has(kind) || isObject(reason) && (reason.error !== undefined || reason.code !== undefined);
390
+ }
391
+
392
+ function normalizeEvent(event) {
393
+ return cloneJson(event) ?? {};
394
+ }
395
+
396
+ /**
397
+ * 创建 Host 适配器和共享 fan-out manager。
398
+ *
399
+ * buildRequest 必须返回 { request, parentAgent?, role?, parentId? },request 会
400
+ * 原样交给 startContinuable。startContinuable 的返回值可用 childId 或 subagentId。
401
+ *
402
+ * 多轮复用(continuation)另需:
403
+ * - `continueMessage({ childId, parentAgent, input, signal })`:把本轮 prompt 投递给**同一
404
+ * child** 并返回新的 messageId(生产走 subagents.sendMessage);缺失时续作 admission
405
+ * 一律失败,绝不静默新建 child;
406
+ * - `resolveChildSession(childId)`:解析既有 child 的 session(用于把本轮 attempt 绑定到
407
+ * 该 session,让它的 turn/end 结算本轮)。缺失时退回 runtime 内部的有界索引;
408
+ * - `parentAgentOf(parentTaskId)`:解析父任务所属的 exact root Agent(投递需要父权限)。
409
+ */
410
+ export function createFanoutRuntime(options = {}) {
411
+ if (!isObject(options)) throw new TypeError("createFanoutRuntime 需要配置对象");
412
+ if (typeof options.buildRequest !== "function") throw new TypeError("fan-out buildRequest 必须是函数");
413
+ if (typeof options.startContinuable !== "function") throw new TypeError("fan-out startContinuable 必须是函数");
414
+ if (options.continueMessage !== undefined && options.continueMessage !== null && typeof options.continueMessage !== "function") {
415
+ throw new TypeError("fan-out continueMessage 必须是函数");
416
+ }
417
+ if (options.resolveChildSession !== undefined && options.resolveChildSession !== null && typeof options.resolveChildSession !== "function") {
418
+ throw new TypeError("fan-out resolveChildSession 必须是函数");
419
+ }
420
+
421
+ const records = new Map();
422
+ const childRecords = new Map();
423
+ const sessionRecords = new Map();
424
+ /**
425
+ * childId -> session 的有界索引(多轮复用解析既有 child 用)。
426
+ *
427
+ * 与 childRecords 的区别:childRecords 只覆盖**活动 attempt**(终态即摘除),而复用需要在
428
+ * 上一轮已结算之后仍然找到同一个 child 的 session。索引有界:LRU 淘汰最旧的 child,
429
+ * 淘汰后若宿主提供 resolveChildSession 仍可解析,否则续作 fail-closed。
430
+ */
431
+ const childSessions = new Map();
432
+ const CHILD_SESSION_LIMIT = FANOUT_EVENT_LOG_LIMIT;
433
+ // 终态后仍保留最近见过的 fan-out session/child id,确保迟到 turn/end
434
+ // 继续短路旧 recovery listener,而不会因 record 已结算而误路由。
435
+ const knownIds = new Set();
436
+ const knownIdOrder = [];
437
+ const KNOWN_ID_LIMIT = FANOUT_EVENT_LOG_LIMIT * 4;
438
+ // id 索引有界:长时间会话里最早 child 的 id 会被 LRU 驱逐,驱逐后仅靠 id 无法再识别
439
+ // 迟到事件(fail-open:旧 recovery listener 会把终态 fan-out child 当成普通恢复 child)。
440
+ // session 实例是 WeakSet 的键——弱引用不阻止 GC、不随会话数量增长内存,只要事件还在
441
+ // 投递,session 实例就一定可达,成员关系因此仍然成立。
442
+ const fanoutSessions = new WeakSet();
443
+ const events = [];
444
+ /**
445
+ * 事件日志是否已知按 sequence 升序(差集读取的快路径前提)。
446
+ *
447
+ * 正常路径下升序成立:实时事件来自 manager 的单一递增 sequence 计数器(按序投递),
448
+ * restoreEvents 会先按 sequence 排序再整体替换。但存在一种真实的降级序列:restoreEvents
449
+ * 已接受历史事件(例如 sequence 100+),而 manager 侧 restore 被推迟或校验失败,
450
+ * 此时新代事件会从 1 重新开始并追加到日志尾部——日志不再升序。
451
+ * 一旦检测到这种乱序,差集读取退回"全量扫描"(与旧实现逐字节等价,只是不再享受
452
+ * 二分定位快路径),因此差集只是快路径,不是新的事实源。
453
+ */
454
+ let eventsAscending = true;
455
+ /** 最近一次 restoreEvents 预算拒绝的结构化违规(只读诊断)。 */
456
+ let lastEventViolation = null;
457
+ let closed = false;
458
+
459
+ // 终态/关闭后 record 会被删除,但 manager 的迟到 canceller、或 admission 恢复路径
460
+ // 仍可能带着同一个 childId 再次请求取消;用有界的 attempt key 集合维持
461
+ // “同一 attempt 最多 interrupt 一次”的边界(record 存活时仍以 record 上的标记为准)。
462
+ const interruptedKeys = new Set();
463
+ const interruptedKeyOrder = [];
464
+
465
+ function rememberInterruptedKey(key) {
466
+ if (interruptedKeys.has(key)) return;
467
+ interruptedKeys.add(key);
468
+ interruptedKeyOrder.push(key);
469
+ while (interruptedKeyOrder.length > KNOWN_ID_LIMIT) interruptedKeys.delete(interruptedKeyOrder.shift());
470
+ }
471
+
472
+ /** 从 launcher/canceller 的 input 还原 attempt key;字段不完整时返回 undefined。 */
473
+ function attemptKeyFromInput(input) {
474
+ const parentTaskId = nonEmptyText(input?.parentTaskId);
475
+ const shardId = nonEmptyText(input?.shardId);
476
+ const attempt = attemptValue(input?.attempt);
477
+ return parentTaskId === "" || shardId === "" || attempt === undefined
478
+ ? undefined
479
+ : fanoutAttemptKey(parentTaskId, shardId, attempt);
480
+ }
481
+
482
+ function rememberKnownId(value) {
483
+ if (typeof value !== "string" || value.length === 0 || knownIds.has(value)) return;
484
+ knownIds.add(value);
485
+ knownIdOrder.push(value);
486
+ while (knownIdOrder.length > KNOWN_ID_LIMIT) knownIds.delete(knownIdOrder.shift());
487
+ }
488
+
489
+ /** 登记 childId -> session(有界 LRU),供多轮复用解析既有 child。 */
490
+ function rememberChildSession(childId, session) {
491
+ if (typeof childId !== "string" || childId.length === 0 || !isObject(session)) return;
492
+ if (childSessions.has(childId)) childSessions.delete(childId);
493
+ childSessions.set(childId, session);
494
+ while (childSessions.size > CHILD_SESSION_LIMIT) {
495
+ const oldest = childSessions.keys().next();
496
+ if (oldest.done === true) break;
497
+ childSessions.delete(oldest.value);
498
+ }
499
+ }
500
+
501
+ /**
502
+ * 解析既有 child 的 session:优先宿主注入的解析器(覆盖 cold-resume 换实例的形态),
503
+ * 其次 runtime 自己的有界索引。都拿不到时返回 undefined——续作路径不因此失败,
504
+ * 而是先按 childId 绑定会话路由,由 sendMessage 决定该 child 是否真的可继续。
505
+ */
506
+ function continuationSession(childId) {
507
+ if (typeof options.resolveChildSession === "function") {
508
+ let resolved;
509
+ try {
510
+ resolved = options.resolveChildSession(childId);
511
+ } catch (error) {
512
+ log("fan-out resolveChildSession failed", error);
513
+ resolved = undefined;
514
+ }
515
+ if (isObject(resolved)) return resolved;
516
+ }
517
+ const indexed = childSessions.get(childId);
518
+ return isObject(indexed) ? indexed : undefined;
519
+ }
520
+
521
+ /**
522
+ * 首轮(launcher)attempt 的 turn 归属判定:**保持既有宽松 fallback**。
523
+ *
524
+ * 一个 child 只有一个 launcher attempt,会话级 turn/end 足以认领它;messageId 缺失或该
525
+ * turn 没有可读 user/message 记录时按旧行为结算(不因为新增防线而让既有路径失效)。
526
+ */
527
+ function turnMatchesRecordMessage(record, session, event) {
528
+ const messageId = nonEmptyText(record?.messageId);
529
+ if (messageId === "") return true;
530
+ const turn = turnOf(event);
531
+ if (!Number.isSafeInteger(turn)) return true;
532
+ const ids = messageIdsOfTurn(sessionEventLog(session), turn);
533
+ if (ids.size === 0) return true;
534
+ return ids.has(messageId);
535
+ }
536
+
537
+ /**
538
+ * continuation attempt 的 turn 归属判定:**严格证明,不允许任何 fallback**。
539
+ *
540
+ * 多轮复用让多个 attempt 落在同一个 child/session 上,因此必须同时满足:
541
+ * 1. event turn 是有效安全整数;
542
+ * 2. 该 turn 的 user/message id 集合非空(可读的归属证据);
543
+ * 3. 集合明确包含本轮 admission 返回的 messageId;
544
+ * 4. 该 messageId 与该 attempt 在 manager 里绑定的 messageId 完全一致。
545
+ *
546
+ * 任何一条不成立都返回结构化原因,由调用方保持 attempt 未结算并记录诊断——
547
+ * 旧轮迟到 turn/end、缺 messageId、归属不可读都不会被当成新一轮的终态。
548
+ */
549
+ function continuationAttribution(record, session, event) {
550
+ const turn = turnOf(event);
551
+ if (!Number.isSafeInteger(turn) || turn <= 0) return { ok: false, reason: "invalid_turn" };
552
+ const messageId = nonEmptyText(record?.messageId);
553
+ if (messageId === "") return { ok: false, reason: "continuation_message_id_missing", turn };
554
+ const ids = messageIdsOfTurn(sessionEventLog(session), turn);
555
+ if (ids.size === 0) return { ok: false, reason: "turn_message_ids_unavailable", turn };
556
+ if (!ids.has(messageId)) return { ok: false, reason: "message_id_mismatch", turn, messageId };
557
+ // manager 是 attempt 绑定值的权威来源:读不到或读到别的 id 都按「无法证明归属」处理。
558
+ const bound = typeof manager.attemptMessageIdOf === "function"
559
+ ? manager.attemptMessageIdOf(record.input?.parentTaskId, record.input?.shardId, record.input?.attempt)
560
+ : null;
561
+ if (bound === null || bound !== messageId) return { ok: false, reason: "attempt_message_id_mismatch", turn, messageId };
562
+ return { ok: true, turn, messageId };
563
+ }
564
+
565
+ /**
566
+ * continuation 的真实执行前置条件(在**任何 manager 状态修改之前**判定)。
567
+ *
568
+ * 只有「投递路由 + 每个分片既有 child 的 session 都可解析」时,续作才可能真正执行;
569
+ * 否则必须先结构化拒绝,不能先返回 accepted 再让 attempt 异步失败(那会破坏回执语义:
570
+ * 用户看到的是一轮被接受的续作,实际上一轮都没跑起来)。
571
+ * 注意:本函数只读,不产生任何副作用。
572
+ */
573
+ function continuationReadiness(task) {
574
+ if (task === null || typeof task !== "object") return { ok: false, reason: "unknown_parent_task", shardIds: [] };
575
+ if (typeof options.continueMessage !== "function") {
576
+ return { ok: false, reason: "continue_route_unavailable", shardIds: [] };
577
+ }
578
+ const shardIds = [];
579
+ for (const shard of Array.isArray(task.shards) ? task.shards : []) {
580
+ const attempts = Array.isArray(shard?.attempts) ? shard.attempts : [];
581
+ const latest = attempts[attempts.length - 1];
582
+ const childId = nonEmptyText(latest?.childId);
583
+ if (childId === "" || continuationSession(childId) === undefined) {
584
+ shardIds.push(typeof shard?.shardId === "string" ? shard.shardId : "unknown");
585
+ }
586
+ }
587
+ return shardIds.length === 0
588
+ ? { ok: true, shardIds: [] }
589
+ : { ok: false, reason: "child_session_unavailable", shardIds };
590
+ }
591
+ const onEvent = typeof options.onEvent === "function" ? options.onEvent : undefined;
592
+ const log = typeof options.log === "function" ? options.log : () => {};
593
+
594
+ /**
595
+ * continuation admission 期间暂存的终态事件上限。
596
+ *
597
+ * 为什么需要暂存:续作的 messageId 只有 `continueMessage()` resolve 之后才知道,而新轮的
598
+ * turn/end 可能在那之前到达。没有暂存时这类事件会被 knownIds 当成旧 child 的迟到事件吞掉,
599
+ * 且没有任何回放 → 新 attempt 永久 running。
600
+ *
601
+ * 上限语义(有界 + 只保留最新的终态事实):超过上限时丢弃**最旧**的一条。理由是最新的
602
+ * 终态事件更可能属于新轮(旧轮的迟到事件通常更早到达);丢弃是 fail-closed 方向——
603
+ * attempt 保持 running,等下一次真正的 turn/end 或超时/取消收敛,绝不猜测归属。
604
+ */
605
+ const CONTINUATION_ADMISSION_EVENT_LIMIT = 8;
606
+
607
+ /**
608
+ * 安装/更新 record 的会话实例(**latest-session 规则**)。
609
+ *
610
+ * 只有两个可信来源会推进 `record.sessionGeneration`:
611
+ * - activation 边界 `handleAgentCreated()`:cold-resume/replacement 带来的新 session 实例;
612
+ * - admission 起点 `continueShard()`:预解析到的 session(作为初始代次)。
613
+ *
614
+ * `runContinuation()` 在 await 之后**只能在同一代次内补写**自己预解析到的实例,绝不能用
615
+ * 旧实例覆盖 admission 期间安装的新实例(终审 session replacement 竞态的根因)。
616
+ *
617
+ * 路由一致性:每次安装都把「最新 sessionId」写进 sessionRecords(并保留历史 id 用于
618
+ * finishRecord 清理),childId → childRecords 始终指向同一个 record,因此替换 session
619
+ * 不会让事件路由到别的 record。
620
+ *
621
+ * session 不是对象(冷 child 暂无实例)时不做任何安装,返回 false:路由由调用方按 childId
622
+ * 兜底登记(continueShard),之后 activation 边界会带来真实实例。
623
+ *
624
+ * @returns {boolean} 是否真的安装了新实例(代次 +1)。
625
+ */
626
+ function installRecordSession(record, session, childId) {
627
+ if (!isObject(session)) return false;
628
+ const fallbackChildId = typeof childId === "string" && childId.length > 0 ? childId : record.childId;
629
+ const sessionId = sessionIdOf(session) ?? fallbackChildId;
630
+ if (session === record.session && sessionId === record.sessionId) return false;
631
+ record.session = session;
632
+ record.sessionGeneration = (Number.isSafeInteger(record.sessionGeneration) ? record.sessionGeneration : 0) + 1;
633
+ if (typeof sessionId === "string" && sessionId.length > 0) {
634
+ record.sessionId = sessionId;
635
+ const ids = record.sessionIds instanceof Set ? record.sessionIds : (record.sessionIds = new Set());
636
+ ids.add(sessionId);
637
+ }
638
+ if (typeof fallbackChildId === "string" && fallbackChildId.length > 0) {
639
+ record.childId = fallbackChildId;
640
+ rememberRecord(record, fallbackChildId, record.sessionId);
641
+ rememberChildSession(fallbackChildId, session);
642
+ }
643
+ return true;
644
+ }
645
+
646
+ /**
647
+ * 暂存一条 admission 期间的终态事件(只保留判定所需的终态事实与**来源 session**)。
648
+ *
649
+ * 只记录 turn / reason / success / session:`extractFanoutResult` 读的是该 session 的日志,
650
+ * 因此暂存不必复制消息正文;持有 session 引用是必要的——session replacement 之后事件与
651
+ * 日志分属不同实例,回放必须按"事件来自哪个实例"判定,否则会去旧实例里找新 messageId。
652
+ * 每个 record 上限 CONTINUATION_ADMISSION_EVENT_LIMIT 条,随 finishRecord 一并释放。
653
+ */
654
+ function stageAdmissionEvent(record, session, event) {
655
+ const queue = Array.isArray(record.pendingAdmissionEvents)
656
+ ? record.pendingAdmissionEvents
657
+ : (record.pendingAdmissionEvents = []);
658
+ if (queue.length >= CONTINUATION_ADMISSION_EVENT_LIMIT) queue.shift();
659
+ queue.push({
660
+ turn: turnOf(event),
661
+ reason: turnReason(event),
662
+ success: completionIsSuccess(event),
663
+ session: isObject(session) ? session : undefined,
664
+ });
665
+ }
666
+
667
+ /** 取出并清空暂存(生命周期终点:绑定成功回放、失败、取消、关闭都走这里)。 */
668
+ function takeAdmissionEvents(record) {
669
+ const staged = Array.isArray(record.pendingAdmissionEvents) ? record.pendingAdmissionEvents : [];
670
+ record.pendingAdmissionEvents = undefined;
671
+ return staged;
672
+ }
673
+
674
+ /**
675
+ * 绑定新 messageId 之后,对 admission 期间暂存的终态事件做**受限回放**。
676
+ *
677
+ * 严格复用 continuationAttribution:只有「有效 turn + 该 turn 的 message id 集合非空 +
678
+ * 包含本轮 messageId + 与 manager 中该 attempt 绑定的 messageId 一致」才允许结算;
679
+ * 其余一律只记 `shard_attribution_unproven` 诊断并保持 running(fail-closed)。
680
+ *
681
+ * session 选择:逐事件使用**该事件来源的 session**(admission 期间最后一次可信 session,
682
+ * 通常就是 replacement 之后的新实例),因此冷恢复换实例不会让回放去旧日志里找新 messageId;
683
+ * 没有记录来源实例时退回 `record.session`。
684
+ *
685
+ * 调用时机必须在 manager 消费 admission 句柄之后(microtask 内),否则
686
+ * `manager.attemptMessageIdOf()` 还读不到本轮 messageId,严格判定必然失败。
687
+ */
688
+ function replayAdmissionEvents(record) {
689
+ const staged = takeAdmissionEvents(record);
690
+ if (staged.length === 0 || record.finished === true) return;
691
+ for (const stagedEvent of staged) {
692
+ if (record.finished === true) break;
693
+ const session = isObject(stagedEvent.session) ? stagedEvent.session : record.session;
694
+ const event = { type: "turn/end", data: { turn: stagedEvent.turn, reason: stagedEvent.reason } };
695
+ const verdict = continuationAttribution(record, session, event);
696
+ if (verdict.ok !== true) {
697
+ reportAttributionViolation(record, verdict);
698
+ continue;
699
+ }
700
+ if (stagedEvent.success === true) finishRecord(record, true, extractFanoutResult(session, stagedEvent.turn));
701
+ else finishRecord(record, false, fanoutCompletionError(stagedEvent.reason, event));
702
+ }
703
+ }
704
+
705
+ function rememberEvent(event) {
706
+ const normalized = normalizeEvent(event);
707
+ // 维护升序不变式:仅当新事件不小于末条时才继续保证升序(乱序即永久关闭快路径,
708
+ // 见 eventsAscending 的说明;不重排日志,保持"事件按到达顺序返回"的既有语义)。
709
+ if (eventsAscending && events.length > 0) {
710
+ const last = events[events.length - 1].sequence;
711
+ const next = normalized.sequence;
712
+ if (Number.isSafeInteger(last) && Number.isSafeInteger(next) && next < last) eventsAscending = false;
713
+ }
714
+ events.push(normalized);
715
+ while (events.length > FANOUT_EVENT_LOG_LIMIT) events.shift();
716
+ try { onEvent?.(normalized); } catch (error) { log("fan-out event callback failed", error); }
717
+ }
718
+
719
+ function rememberRecord(record, childId, sessionId) {
720
+ if (childId !== undefined) {
721
+ record.childId = childId;
722
+ childRecords.set(childId, record);
723
+ }
724
+ if (sessionId !== undefined) {
725
+ record.sessionId = sessionId;
726
+ sessionRecords.set(sessionId, record);
727
+ }
728
+ }
729
+
730
+ function forgetRecord(record) {
731
+ if (records.get(record.key) === record) records.delete(record.key);
732
+ if (record.childId !== undefined && childRecords.get(record.childId) === record) childRecords.delete(record.childId);
733
+ // session replacement 会让同一个 record 关联过多个 session id(实例换了、id 可能同也可能
734
+ // 不同):终态时必须把**全部**历史 id 都摘掉,否则旧 id 的迟到事件还能找回一个已结算的
735
+ // record(既留下悬挂引用,又会在严格判定里产生无意义诊断)。
736
+ if (record.sessionIds instanceof Set) {
737
+ for (const sessionId of record.sessionIds) {
738
+ if (sessionRecords.get(sessionId) === record) sessionRecords.delete(sessionId);
739
+ }
740
+ record.sessionIds.clear();
741
+ }
742
+ if (record.sessionId !== undefined && sessionRecords.get(record.sessionId) === record) sessionRecords.delete(record.sessionId);
743
+ }
744
+
745
+ function finishRecord(record, success, value) {
746
+ if (record.finished) return false;
747
+ record.finished = true;
748
+ // 生命周期终点:无论成功/失败/取消/关闭,admission 暂存与 disposal 待决标记都必须立刻
749
+ // 释放(不持有 session 与终态事实,避免悬挂记录与内存泄漏)。
750
+ takeAdmissionEvents(record);
751
+ record.pendingDisposal = undefined;
752
+ forgetRecord(record);
753
+ if (success) record.deferred.resolve(cloneJson(value));
754
+ else record.deferred.reject(value instanceof Error ? value : fanoutCompletionError(value));
755
+ return true;
756
+ }
757
+
758
+ function requestChildInterrupt(record, input, childId) {
759
+ if (childId === undefined || typeof options.interrupt !== "function") return false;
760
+ if (record?.interruptRequested === true) return false;
761
+ // close 会清空 records;此时同一 attempt 的判重只能靠 key 集合,否则迟到的
762
+ // canceller(manager 仍持有 attempt.childId)会重复 interrupt 同一个 child。
763
+ const key = record?.key ?? attemptKeyFromInput(input);
764
+ if (key !== undefined && interruptedKeys.has(key)) return false;
765
+ if (record !== undefined) record.interruptRequested = true;
766
+ if (key !== undefined) rememberInterruptedKey(key);
767
+ const parentAgent = record?.parentAgent;
768
+ try {
769
+ const result = options.interrupt({ childId, parentAgent, input, handle: record?.handle });
770
+ if (result !== null && typeof result === "object" && typeof result.catch === "function") {
771
+ result.catch((error) => log("fan-out interrupt failed", error));
772
+ }
773
+ } catch (error) {
774
+ log("fan-out interrupt failed", error);
775
+ }
776
+ return true;
777
+ }
778
+
779
+ /**
780
+ * 把一个 shard attempt 交给 buildRequest/startContinuable。
781
+ *
782
+ * 返回值是可控 admission 句柄(thenable)而不是原生 Promise:manager 只按 Promise
783
+ * 语义消费 launcher 的返回值(见 fanout.js 的 startShard),可控句柄让 close() 能在
784
+ * 同一个同步栈内把关闭错误交给 manager(见 createAdmissionHandle)。守卫失败(重复
785
+ * attempt / 已关闭 / 超预算)仍以 rejection 表达,与原来的 async launcher 一致。
786
+ *
787
+ * record.phase 记录 admission 阶段,供 close() 区分「还没有产生 child 的在途 admission」:
788
+ * build = 正在等 buildRequest,start = 正在等 startContinuable,done = 已结算。
789
+ */
790
+ function launcher(input) {
791
+ const admission = createAdmissionHandle();
792
+ try {
793
+ const key = fanoutAttemptKey(input.parentTaskId, input.shardId, input.attempt);
794
+ if (records.has(key)) throw new Error("fan-out attempt 已存在: " + key);
795
+ // close 已经封锁 admission:manager 在 runtime 单独关闭时未必同时关闭,仍可能派发
796
+ // queued shard。此时绝不能再进入 buildRequest/startContinuable——关闭后启动的 child
797
+ // 已不在 records 里,没有任何路径能 interrupt 或结算它(会变成永久悬挂的 live child)。
798
+ if (closed) throw fanoutRuntimeClosedError();
799
+ // admission 前的输入预算复核:prompt 超限必须显式失败,绝不以截断后的正文
800
+ // 派发专家(manager 已在 submit 阶段拒绝;这里防的是运行期二次装配的差异)。
801
+ const inputVerdict = checkTaskInputBudget({
802
+ basePrompt: input.basePrompt,
803
+ shardPrompt: input.prompt,
804
+ subject: input.parentTaskId + "/" + input.shardId + "#" + input.attempt,
805
+ });
806
+ if (!inputVerdict.ok) {
807
+ const error = new Error(inputVerdict.violation.message);
808
+ error.name = "BudgetExceededError";
809
+ error.code = inputVerdict.violation.code;
810
+ error.resource = inputVerdict.violation.resource;
811
+ error.receivedBytes = inputVerdict.violation.receivedBytes;
812
+ error.maxBytes = inputVerdict.violation.maxBytes;
813
+ error.complete = false;
814
+ error.recoverable = inputVerdict.violation.recoverable;
815
+ throw error;
816
+ }
817
+ const record = {
818
+ key,
819
+ input: cloneJson(input),
820
+ deferred: createDeferred(),
821
+ controller: new AbortController(),
822
+ finished: false,
823
+ cancelRequested: false,
824
+ phase: "build",
825
+ admission,
826
+ };
827
+ records.set(key, record);
828
+ // admission 主体异步推进,成功句柄与失败错误都通过可控句柄投递。
829
+ runAdmission(record, input).catch((error) => log("fan-out admission failed", error));
830
+ } catch (error) {
831
+ admission.reject(error);
832
+ }
833
+ return admission;
834
+ }
835
+
836
+ /**
837
+ * admission 主体:与原来的 async launcher 等价,只是结算改由可控句柄投递。
838
+ *
839
+ * close 落在任一步骤都必须停止 admission:buildRequest 之后还有一道 closed 检查,
840
+ * 绝不为已关闭的 runtime 启动新 child。
841
+ */
842
+ async function runAdmission(record, input) {
843
+ try {
844
+ const prepared = await options.buildRequest(input, record.controller.signal);
845
+ // close 可能落在 buildRequest 等待期间:此处必须停止 admission,否则会为一个已经
846
+ // 关闭的 runtime 启动新 child。record 若已被 close 结算,finishRecord 不会再结算一次;
847
+ // 关闭错误最终由 manager 按一次性的启动失败/取消语义收敛本次 attempt。
848
+ if (closed) throw fanoutRuntimeClosedError();
849
+ record.phase = "start";
850
+ const request = isObject(prepared) && Object.prototype.hasOwnProperty.call(prepared, "request") ? prepared.request : prepared;
851
+ record.parentAgent = prepared?.parentAgent ?? request?.parent;
852
+ record.parentId = prepared?.parentId ?? record.parentAgent?.id;
853
+ record.role = prepared?.role ?? input.role;
854
+ const started = await options.startContinuable({
855
+ request,
856
+ input,
857
+ parentAgent: record.parentAgent,
858
+ signal: record.controller.signal,
859
+ });
860
+ const childId = childIdOf(started);
861
+ if (childId === undefined) throw new Error("startContinuable 未返回 childId");
862
+ const messageId = messageIdOf(started);
863
+ const handle = {
864
+ childId,
865
+ ...(messageId === undefined ? {} : { messageId }),
866
+ completion: record.deferred.promise,
867
+ };
868
+ record.handle = handle;
869
+ record.phase = "done";
870
+ // close/cancel 可能在 startContinuable 等待期间先发生;accepted child 仍必须
871
+ // 由保存下来的 parentAgent 发出一次 parent-authorized interrupt,不能只 abort admission。
872
+ if (!record.finished) rememberRecord(record, childId, record.sessionId);
873
+ if (record.cancelRequested === true || record.finished === true) {
874
+ requestChildInterrupt(record, { ...input, reason: record.finished ? "runtime_closed" : "cancelled" }, childId);
875
+ }
876
+ // 关闭已经把 admission 结算过时,这里是一次幂等的 no-op。
877
+ record.admission.resolve(handle);
878
+ return handle;
879
+ } catch (error) {
880
+ finishRecord(record, false, error);
881
+ record.admission.reject(error);
882
+ return undefined;
883
+ }
884
+ }
885
+
886
+ function canceller(input) {
887
+ const record = records.get(fanoutAttemptKey(input.parentTaskId, input.shardId, input.attempt));
888
+ if (record !== undefined) record.cancelRequested = true;
889
+ try { record?.controller?.abort(); } catch (error) { log("fan-out admission abort failed", error); }
890
+ requestChildInterrupt(record, input, input.childId ?? record?.childId);
891
+ }
892
+
893
+ /**
894
+ * 多轮复用:把一个 continuation attempt 投递给**同一 child**。
895
+ *
896
+ * 与 launcher 的差别有三点:
897
+ * 1. 不新建 child,通过注入的 `continueMessage` 把本轮 prompt 发给 input.childId;
898
+ * 2. **admission 之前**就登记会话路由(childId/sessionId → 本 record),让本轮 turn/end
899
+ * 有明确投递目标;若等 messageId 落地后再登记,新轮的 turn/end 会落在"无人认领"窗口里
900
+ * 被 knownIds 吞掉且无法回放 → 新 attempt 永久 running(终审问题 2);
901
+ * 3. admission 期间的终态事件进**有界暂存**,绑定 messageId 后严格回放(见 replayAdmissionEvents)。
902
+ * 守卫(重复 attempt / 已关闭 / 未接线 / 输入超预算)与 launcher 一致,都以 rejection 表达,
903
+ * 由 manager 记为本轮 attempt 的启动失败(历史一致,不半写)。
904
+ */
905
+ function continueShard(input) {
906
+ const admission = createAdmissionHandle();
907
+ try {
908
+ const key = fanoutAttemptKey(input.parentTaskId, input.shardId, input.attempt);
909
+ if (records.has(key)) throw new Error("fan-out attempt 已存在: " + key);
910
+ if (closed) throw fanoutRuntimeClosedError();
911
+ const childId = nonEmptyText(input.childId);
912
+ if (childId === "") throw new Error("fan-out continuation 需要既有 childId");
913
+ if (typeof options.continueMessage !== "function") {
914
+ throw new Error("fan-out continuation 未接线 continueMessage,拒绝复用既有 child");
915
+ }
916
+ const inputVerdict = checkTaskInputBudget({
917
+ basePrompt: input.basePrompt,
918
+ shardPrompt: input.prompt,
919
+ subject: input.parentTaskId + "/" + input.shardId + "#" + input.attempt,
920
+ });
921
+ if (!inputVerdict.ok) {
922
+ const error = new Error(inputVerdict.violation.message);
923
+ error.name = "BudgetExceededError";
924
+ error.code = inputVerdict.violation.code;
925
+ error.resource = inputVerdict.violation.resource;
926
+ error.receivedBytes = inputVerdict.violation.receivedBytes;
927
+ error.maxBytes = inputVerdict.violation.maxBytes;
928
+ error.complete = false;
929
+ error.recoverable = inputVerdict.violation.recoverable;
930
+ throw error;
931
+ }
932
+ // session 可能尚未加载(冷 child):按 childId 绑定路由即可(DSH continuable child 的
933
+ // session id 就是 childId);真正的可继续性由 sendMessage 显式决定。
934
+ const session = continuationSession(childId);
935
+ const record = {
936
+ key,
937
+ input: cloneJson(input),
938
+ deferred: createDeferred(),
939
+ controller: new AbortController(),
940
+ finished: false,
941
+ cancelRequested: false,
942
+ phase: "continue",
943
+ continuation: true,
944
+ childId,
945
+ // sessionId 先按 childId 兜底,随后由 installRecordSession 归一化;初始化 0 代次。
946
+ sessionId: childId,
947
+ sessionGeneration: 0,
948
+ admission,
949
+ pendingAdmissionEvents: [],
950
+ };
951
+ records.set(key, record);
952
+ // 关键顺序:先登记路由(含 session 实例),再进入异步投递——否则 admission 期间的
953
+ // turn/end 无人认领(既不会被暂存也不会被回放),新 attempt 会永久 running。
954
+ // 冷 child 没有实例时先按 childId 兜底登记,随后由 installRecordSession 用真实实例归一化。
955
+ rememberRecord(record, childId, record.sessionId);
956
+ installRecordSession(record, session, childId);
957
+ // 记下本 record 的初始 session 代次:runContinuation 在 await 之后只有代次未推进时
958
+ // 才允许补写自己预解析到的实例(最新 session 优先,见 installRecordSession)。
959
+ const admissionSessionGeneration = record.sessionGeneration;
960
+ runContinuation(record, input, childId, session, admissionSessionGeneration)
961
+ .catch((error) => log("fan-out continuation failed", error));
962
+ } catch (error) {
963
+ admission.reject(error);
964
+ }
965
+ return admission;
966
+ }
967
+
968
+ /** continuation admission 主体:复用同一 child 发送本轮 prompt,并等待该轮 turn/end。 */
969
+ async function runContinuation(record, input, childId, resolvedSession, admissionSessionGeneration) {
970
+ try {
971
+ const parentAgent = typeof options.parentAgentOf === "function"
972
+ ? options.parentAgentOf(input.parentTaskId)
973
+ : undefined;
974
+ record.parentAgent = parentAgent;
975
+ record.parentId = parentAgent?.id;
976
+ record.role = input.role;
977
+ // 本轮正文由 Host 装配(与首轮 buildRequest 同一套文案/persona 纪律):runtime 只
978
+ // 把「同一 child 的新一轮」这件事连同分片/attempt/round 身份交给 Host。
979
+ const sent = await options.continueMessage({
980
+ childId,
981
+ parentAgent,
982
+ input,
983
+ signal: record.controller.signal,
984
+ });
985
+ // 投递结果兼容两种形态:Host 直接回传 messageId 字符串(生产 sendMessage 契约),
986
+ // 或回传 { messageId } 句柄;两种都归一为「本轮该结算的 turn」。
987
+ const messageId = typeof sent === "string" && sent.length > 0 ? sent : messageIdOf(sent);
988
+ // 严格启动门(fail-closed):没有有效 messageId 就无法证明后续 turn 归属,
989
+ // 绝不允许把本轮 attempt 挂成 running 再等一个可能属于旧轮的 turn/end。
990
+ if (typeof messageId !== "string" || messageId.length === 0) {
991
+ throw continuationStartupError("continuation_message_id_missing", childId, "续作投递未返回有效 messageId");
992
+ }
993
+ // 与同一 child 之前各轮的 messageId 重复:旧轮的 turn/end 会直接冒充新一轮,
994
+ // 必须在启动阶段拒绝(同一 shard 的每轮投递必须是新的消息身份)。
995
+ const previousMessageIds = Array.isArray(input.previousMessageIds) ? input.previousMessageIds : [];
996
+ if (previousMessageIds.includes(messageId)) {
997
+ throw continuationStartupError("continuation_message_id_reused", childId, "续作投递返回的 messageId 与旧轮重复");
998
+ }
999
+ record.messageId = messageId;
1000
+ // DSH continuable child 的 session id 就是 childId;admission 之前已按同一 sessionId
1001
+ /**
1002
+ * session 写回规则(终审 session replacement 竞态的核心修复):
1003
+ * 只有**本 record 的 session 代次没有在 await 期间推进**时,才允许补写自己预解析到的
1004
+ * 实例;若 `handleAgentCreated()`(cold-resume/replacement)已安装更新的 session,则
1005
+ * 保持新实例——用旧实例覆盖会让后续严格判定去旧日志里找新 messageId,attempt 永久
1006
+ * running。
1007
+ */
1008
+ if (record.sessionGeneration === admissionSessionGeneration) {
1009
+ installRecordSession(record, resolvedSession, childId);
1010
+ } else if (!isObject(record.session) && isObject(resolvedSession)) {
1011
+ // 代次推进但当前没有可用实例(异常形态):仍以最新代次补一个可用实例,绝不落空。
1012
+ installRecordSession(record, resolvedSession, childId);
1013
+ }
1014
+ const handle = {
1015
+ childId,
1016
+ messageId,
1017
+ completion: record.deferred.promise,
1018
+ continuation: true,
1019
+ };
1020
+ record.handle = handle;
1021
+ record.phase = "done";
1022
+ /**
1023
+ * admission 阶段观察到的旧实例 disposal 到此已不再是「未决」:messageId 已绑定,后续
1024
+ * 收敛由本轮 turn/end、取消、close 或对**当前** session 的 disposal 决定(迟到的旧实例
1025
+ * disposal 会被 stale-instance 规则忽略)。这里清掉待决标记,避免它跨阶段残留。
1026
+ */
1027
+ record.pendingDisposal = undefined;
1028
+ if (!record.finished) {
1029
+ // 路由保持一致:按 childId + 最新 sessionId 重新登记(id 可能因替换而变化)。
1030
+ if (typeof record.sessionId === "string" && record.sessionId.length > 0) {
1031
+ rememberRecord(record, childId, record.sessionId);
1032
+ }
1033
+ }
1034
+ if (record.cancelRequested === true || record.finished === true) {
1035
+ requestChildInterrupt(record, { ...input, reason: record.finished ? "runtime_closed" : "cancelled" }, childId);
1036
+ }
1037
+ record.admission.resolve(handle);
1038
+ /**
1039
+ * 绑定后受限回放:必须等 manager 消费 admission 句柄(attempt.messageId 落地)之后再
1040
+ * 做严格判定,否则 `manager.attemptMessageIdOf()` 还读不到本轮 messageId。
1041
+ * 排队顺序保证:admission.resolve 触发的 manager `.then` 回调先于本 microtask 执行。
1042
+ */
1043
+ if (record.finished !== true) queueMicrotask(() => replayAdmissionEvents(record));
1044
+ return handle;
1045
+ } catch (error) {
1046
+ // 失败同样释放暂存(finishRecord 内部 takeAdmissionEvents):不留下悬挂的终态事实。
1047
+ finishRecord(record, false, error);
1048
+ record.admission.reject(error);
1049
+ return undefined;
1050
+ }
1051
+ }
1052
+
1053
+ const manager = createFanoutManager({
1054
+ launcher,
1055
+ // 多轮复用走 continuer(同一 child 的新一轮 prompt),其余 attempt 仍走 launcher。
1056
+ continuer: continueShard,
1057
+ canceller,
1058
+ persistence: options.persistence,
1059
+ conflictChecker: options.conflictChecker,
1060
+ writeRoles: options.writeRoles,
1061
+ allowOverlap: options.allowOverlap,
1062
+ concurrencyLimit: options.concurrencyLimit,
1063
+ roleLimits: options.roleLimits,
1064
+ });
1065
+ const unsubscribeManager = manager.subscribe((event) => rememberEvent(event));
1066
+
1067
+ function handleAgentCreated(agent, persistedIdentity) {
1068
+ if (!isFanoutAgent(agent) && !isFanoutIdentity(persistedIdentity)) return false;
1069
+ const marker = markerOptions(agent);
1070
+ const identity = markerFanoutIdentity(marker) ?? persistedFanoutIdentity(persistedIdentity);
1071
+ if (identity === undefined) return false;
1072
+ const { parentTaskId, parentAgentId, shardId, attempt } = identity;
1073
+ const key = fanoutAttemptKey(parentTaskId, shardId, attempt);
1074
+ const sessionId = sessionIdOf(agent);
1075
+ rememberKnownId(childIdOf(agent) ?? agent?.id);
1076
+ rememberKnownId(sessionId);
1077
+ // 弱引用登记 fan-out session 实例:id 索引被 LRU 驱逐后,同一个 session 实例的
1078
+ // 迟到事件仍能识别为 fan-out(见 handleSessionEvent 的 fail-closed 兜底)。
1079
+ // 只在标记/持久化身份完整可信时登记,因此不会把普通 child 的 session 误收进来。
1080
+ if (isObject(agent?.session)) {
1081
+ fanoutSessions.add(agent.session);
1082
+ rememberChildSession(childIdOf(agent) ?? agent.id, agent.session);
1083
+ }
1084
+ const record = records.get(key);
1085
+ if (record !== undefined) {
1086
+ record.agent = agent;
1087
+ /**
1088
+ * latest-session:activation 边界安装的新 session 实例优先于 admission 起点预解析的
1089
+ * 实例(cold-resume/replacement 期间发生的就是这种替换)。installRecordSession 会推进
1090
+ * sessionGeneration,因此 `runContinuation()` 之后的写回不可能覆盖它。
1091
+ */
1092
+ const agentChildId = childIdOf(agent) ?? agent.id;
1093
+ installRecordSession(record, agent.session, typeof agentChildId === "string" ? agentChildId : record.childId);
1094
+ /**
1095
+ * 新 activation 到位即证明「旧实例 disposal → cold-resume」替换窗口已经落地:
1096
+ * 把 admission 期间的 disposal 待决标记降级为「已知被替换」(只保留计数供诊断),
1097
+ * 后续由 runContinuation 的成功/失败结果收敛。
1098
+ */
1099
+ if (record.pendingDisposal !== undefined && isObject(agent?.session) && agent.session !== record.pendingDisposal.session) {
1100
+ record.replacedAfterDisposal = (Number.isSafeInteger(record.replacedAfterDisposal) ? record.replacedAfterDisposal : 0) + 1;
1101
+ record.pendingDisposal = undefined;
1102
+ }
1103
+ record.parentAgent ??= agent.parent;
1104
+ record.parentId ??= parentAgentId;
1105
+ rememberRecord(record, agentChildId, sessionId);
1106
+ try {
1107
+ options.onAgentCreated?.({
1108
+ agent,
1109
+ record,
1110
+ parentTaskId,
1111
+ shardId,
1112
+ attempt,
1113
+ parentAgentId,
1114
+ role: markerText(marker, "__degExpertRole") ?? persistedIdentity?.role,
1115
+ });
1116
+ } catch (error) {
1117
+ log("fan-out agent/created callback failed", error);
1118
+ }
1119
+ }
1120
+ // marker 或可信持久化身份都必须在旧 recovery listener 之前被识别;即使对应
1121
+ // attempt 已经在 terminal 后清理,也不能让旧 handler 误认为它是普通恢复 child。
1122
+ return true;
1123
+ }
1124
+
1125
+ function recordForSession(session) {
1126
+ if (typeof session?.id !== "string") return undefined;
1127
+ rememberChildSession(childIdForSessionIndex(session), session);
1128
+ return sessionRecords.get(session.id) ?? childRecords.get(session.id);
1129
+ }
1130
+
1131
+ /** session 上的 child 身份:优先 session.header.id(DSH child session 的稳定 id)。 */
1132
+ function childIdForSessionIndex(session) {
1133
+ const headerId = nonEmptyText(session?.header?.id);
1134
+ if (headerId !== "") return headerId;
1135
+ return sessionIdOf(session);
1136
+ }
1137
+
1138
+ function handleSessionEvent(session, event) {
1139
+ const sessionId = typeof session?.id === "string" ? session.id : undefined;
1140
+ const record = recordForSession(session);
1141
+ // record 在成功/失败/超时后会立即删除,但该 child 的后续事件仍属于
1142
+ // fan-out;用 bounded known id 集合保持旧 recovery 路由的 fail-closed 边界。
1143
+ if (record === undefined) {
1144
+ if (sessionId === undefined) return false;
1145
+ // 1) 有界 id 索引:覆盖最近见过的 child,包括 cold-resume 后换了新 session
1146
+ // 实例但 id 相同的形态。
1147
+ if (knownIds.has(sessionId)) return true;
1148
+ // 2) 弱引用 session 索引:id 被 LRU 驱逐后,同一个 session 实例的迟到事件
1149
+ // 仍然 fail-closed(返回 true 由 fan-out 吞掉),不回落成普通恢复 child。
1150
+ // 顺序不可交换:id 判定在前,保住 id 索引能覆盖的、形态更宽的识别面。
1151
+ // 残余边界:驱逐后同 id 的全新 session 实例(重建/cold-resume)无从识别,
1152
+ // 此时仍返回 false——这是有界索引的固有代价,由回归测试显式固定。
1153
+ return isObject(session) && fanoutSessions.has(session);
1154
+ }
1155
+ if (!eventIsTurnEnd(event)) return true;
1156
+ if (!hasTerminalTurnReason(event)) return true;
1157
+ /**
1158
+ * 归属判定使用的 session:优先**事件来源实例**(`session`,路由已按 id 命中本 record),
1159
+ * 只有来源不是对象时才退回 record 当前实例。
1160
+ *
1161
+ * 为什么按来源实例判定:session replacement/cold-resume 后,新轮的 user/message 与
1162
+ * turn/end 都写在**新实例**的日志里;若统一读 record.session(可能还是旧实例),严格
1163
+ * 判定会去旧日志里找新 messageId 而永远失败。反过来,旧实例的迟到事件仍然按旧实例
1164
+ * 判定 → 找不到本轮 messageId → 只留 `shard_attribution_unproven` 诊断,不会覆盖新轮。
1165
+ */
1166
+ const attributionSession = isObject(session) ? session : record.session;
1167
+ if (record.continuation === true) {
1168
+ // admission 期间(messageId 尚未绑定):先放进有界暂存(含来源 session),绑定后严格回放。
1169
+ // 这样「新轮 turn/end 先到」不会被 knownIds 吞掉且无回放,也不会被当作已绑定的轮次结算。
1170
+ if (record.phase === "continue" && record.finished !== true) {
1171
+ stageAdmissionEvent(record, attributionSession, event);
1172
+ return true;
1173
+ }
1174
+ // 多轮复用:同一 child/session 承载多个 attempt,必须**严格证明**本轮归属才允许结算。
1175
+ // 任何一条证据缺失都保持 attempt 未结算,并留下结构化诊断(不沿用首轮的宽松 fallback)。
1176
+ const verdict = continuationAttribution(record, attributionSession, event);
1177
+ if (verdict.ok !== true) {
1178
+ reportAttributionViolation(record, verdict);
1179
+ return true;
1180
+ }
1181
+ } else if (!turnMatchesRecordMessage(record, attributionSession, event)) {
1182
+ // 首轮 launcher attempt:保持既有宽松 fallback(messageId/turn 不可读时按旧行为结算)。
1183
+ return true;
1184
+ }
1185
+ const turn = turnOf(event);
1186
+ if (completionIsSuccess(event)) finishRecord(record, true, extractFanoutResult(attributionSession, turn));
1187
+ else finishRecord(record, false, fanoutCompletionError(turnReason(event), event));
1188
+ return true;
1189
+ }
1190
+
1191
+ /**
1192
+ * 记录一次「continuation 归属无法证明」的结构化诊断。
1193
+ *
1194
+ * 每个 record(= 一个 attempt 的一轮投递)对**同一种原因**最多报告一次,避免异常 child
1195
+ * 反复发 turn/end 造成事件抖动;不同原因(缺消息号 / 消息号不一致 / turn 非法 …)各自
1196
+ * 独立留证,便于定位竞态的真实形态。事件由 manager 发布(进事件历史/持久化/RPC)。
1197
+ */
1198
+ function reportAttributionViolation(record, verdict) {
1199
+ const reason = typeof verdict?.reason === "string" && verdict.reason.length > 0 ? verdict.reason : "unproven";
1200
+ const reported = record.attributionDiagnosticReasons instanceof Set
1201
+ ? record.attributionDiagnosticReasons
1202
+ : (record.attributionDiagnosticReasons = new Set());
1203
+ if (reported.has(reason)) return;
1204
+ reported.add(reason);
1205
+ const payload = {
1206
+ parentTaskId: record.input?.parentTaskId,
1207
+ shardId: record.input?.shardId,
1208
+ attempt: record.input?.attempt,
1209
+ reason,
1210
+ ...(Number.isSafeInteger(verdict?.turn) ? { turn: verdict.turn } : {}),
1211
+ ...(typeof verdict?.messageId === "string" ? { messageId: verdict.messageId } : {}),
1212
+ };
1213
+ try {
1214
+ manager.recordAttributionViolation(payload);
1215
+ } catch (error) {
1216
+ log("fan-out attribution diagnostic failed", error);
1217
+ }
1218
+ log("fan-out continuation attribution unproven: " + JSON.stringify(payload));
1219
+ }
1220
+
1221
+ /**
1222
+ * 记录一次「admission 期间的旧实例 disposal」待决标记(有界:每个 record 至多一条)。
1223
+ *
1224
+ * 为什么需要它:DSH `sendMessage()` 在旧 activation 尚未关闭时会先关旧 Agent、再为**同一
1225
+ * child id** cold-resume。此时新的 continuation record 已经按 childId 注册到 childRecords,
1226
+ * 旧 Agent 的 `agent/disposed` 会命中这个**新** attempt;若立即 finish,合法的新一轮还没开始
1227
+ * 就被判 failed,随后 DSH 成功返回 messageId 也无法结算。
1228
+ *
1229
+ * 待决标记的作用只是「不立即终结」,不消耗任何授权:真正的收敛由 `runContinuation()` 的
1230
+ * 成功/失败结果、runtime close/cancel、或后续对**当前** session 的 disposal 决定。
1231
+ * 不保存 Agent 对象引用,只留可比较的实例与 id/代次(避免悬挂引用)。
1232
+ */
1233
+ function deferDisposal(record, agent, session, sessionId) {
1234
+ record.pendingDisposal = {
1235
+ session: isObject(session) ? session : undefined,
1236
+ sessionId: typeof sessionId === "string" && sessionId.length > 0 ? sessionId : undefined,
1237
+ agentId: typeof agent?.id === "string" && agent.id.length > 0 ? agent.id : undefined,
1238
+ generation: Number.isSafeInteger(record.sessionGeneration) ? record.sessionGeneration : 0,
1239
+ };
1240
+ return true;
1241
+ }
1242
+
1243
+ /**
1244
+ * fan-out child(或可信持久化身份的 cold child)被销毁。
1245
+ *
1246
+ * 路由收紧(终审 disposal 竞态):
1247
+ * 1. 未绑定 messageId 的 continuation admission(`phase === "continue"`):旧实例 disposal
1248
+ * 属于「旧 Agent 关闭 → 同一 child cold-resume」的替换窗口,**不立即终结**,只记待决标记;
1249
+ * 2. 已绑定 / 首轮 launcher:disposal 必须明确对应当前 attempt 的**当前 session 实例**
1250
+ * (或无法比较实例时按 id 兜底)才收敛;否则视为迟到/别的实例的 disposal,忽略之
1251
+ * (旧实例销毁不代表当前实例销毁,不能让迟到事件覆盖新 attempt);
1252
+ * 3. 首轮 launcher 的既有 disposal 失败语义保持不变(其当前 session 被销毁 → failed)。
1253
+ */
1254
+ function handleAgentDisposed(agent, persistedIdentity) {
1255
+ if (!isFanoutAgent(agent) && !isFanoutIdentity(persistedIdentity)) return false;
1256
+ const record = childRecords.get(agent?.id) ?? sessionRecords.get(sessionIdOf(agent));
1257
+ if (record === undefined || record.finished === true) return true;
1258
+ const disposedSession = isObject(agent?.session) ? agent.session : undefined;
1259
+ // 1) 替换窗口:admission 还没绑定 messageId → 记待决,等 runContinuation 的结果。
1260
+ if (record.continuation === true && record.phase === "continue") {
1261
+ deferDisposal(record, agent, disposedSession, sessionIdOf(agent));
1262
+ return true;
1263
+ }
1264
+ // 2) 迟到/别的实例的 disposal:不终结当前 attempt(例如旧实例在 cold-resume 之后才发出
1265
+ // disposed;当前实例仍活着)。
1266
+ if (disposedSession !== undefined && isObject(record.session) && disposedSession !== record.session) {
1267
+ record.staleDisposalIgnored = (Number.isSafeInteger(record.staleDisposalIgnored) ? record.staleDisposalIgnored : 0) + 1;
1268
+ return true;
1269
+ }
1270
+ // 3) 当前 attempt 的当前 session 被销毁(或无法比较实例):按既有语义收敛为 failed。
1271
+ finishRecord(record, false, fanoutCompletionError({ kind: "disposed", message: "fan-out child 已销毁" }));
1272
+ return true;
1273
+ }
1274
+
1275
+ /**
1276
+ * 从持久化快照恢复事件历史(外部输入)。
1277
+ *
1278
+ * 判定顺序(每一步都在产生大额临时副本之前):
1279
+ * 1. 原始数组长度上限——外部数组可能极大,必须先看 length 再决定是否处理,
1280
+ * 否则 filter/sort/map/clone 会先复制一份同样量级的临时数组;
1281
+ * 2. 逐个事件的单条字节预算 + JSON 可测量性(不可序列化整体拒绝);
1282
+ * 3. 归一化后的条数与字节总量预算。
1283
+ *
1284
+ * 任何一项超限都**整体拒绝**并且不改动现有事件(原子失败)——不能像运行时裁剪那样
1285
+ * 静默丢弃历史,也不能把超大事件直接写进 events。
1286
+ *
1287
+ * 既有契约保持不变:非数组返回 false;含非法项的数组仍按"过滤掉非法项"处理
1288
+ * (sequence 非正整数、非对象项、缺失 sequence 都被忽略)。
1289
+ *
1290
+ * @param {object[]} restoredEvents
1291
+ * @returns {boolean} 接受并替换返回 true;非法输入或超预算返回 false。
1292
+ */
1293
+ function restoreEvents(restoredEvents) {
1294
+ if (!Array.isArray(restoredEvents)) return false;
1295
+ // 1. 先看原始长度:超限立即拒绝,不进入任何逐元素处理。
1296
+ if (restoredEvents.length > FANOUT_EVENT_LOG_LIMIT) {
1297
+ lastEventViolation = describeRestoreEventsViolation(restoredEvents.length);
1298
+ return false;
1299
+ }
1300
+ // 2. 单条事件:字节预算 + 可测量性(不可序列化不能当"未知体积"放过)。
1301
+ for (const event of restoredEvents) {
1302
+ if (!isObject(event)) continue;
1303
+ if (measureJsonBytes(event) === null) {
1304
+ lastEventViolation = describeRestoreEventsUnmeasurableViolation();
1305
+ return false;
1306
+ }
1307
+ const single = checkEventBudget(event, { subject: "restoreEvents" });
1308
+ if (single.ok) continue;
1309
+ lastEventViolation = single.violation;
1310
+ return false;
1311
+ }
1312
+ const normalized = restoredEvents
1313
+ .filter((event) => isObject(event) && Number.isSafeInteger(event.sequence) && event.sequence > 0)
1314
+ .sort((left, right) => left.sequence - right.sequence)
1315
+ .map(normalizeEvent);
1316
+ // 3. 归一化后的字节总量:合法但超限的输入整体拒绝,不静默裁剪。
1317
+ const history = checkEventHistoryBudget(normalized, {
1318
+ maxCount: FANOUT_EVENT_LOG_LIMIT,
1319
+ maxBytes: FANOUT_BUDGET.maxEventHistoryBytes,
1320
+ });
1321
+ if (history.droppedCount > 0 || normalized.length > FANOUT_EVENT_LOG_LIMIT) {
1322
+ lastEventViolation = history.violation ?? describeRestoreEventsViolation(normalized.length);
1323
+ return false;
1324
+ }
1325
+ events.splice(0, events.length, ...normalized);
1326
+ // 归一化后是整体按 sequence 升序的日志,快路径前提重新成立。
1327
+ eventsAscending = true;
1328
+ lastEventViolation = null;
1329
+ return true;
1330
+ }
1331
+
1332
+ /** 恢复事件不可 JSON 序列化时的结构化违规(整体拒绝,不写原始负载)。 */
1333
+ function describeRestoreEventsUnmeasurableViolation() {
1334
+ return Object.freeze({
1335
+ code: "BUDGET_EXCEEDED",
1336
+ resource: "event_history",
1337
+ limit: FANOUT_EVENT_LOG_LIMIT,
1338
+ maxBytes: FANOUT_EVENT_LOG_LIMIT,
1339
+ receivedBytes: null,
1340
+ unmeasurable: true,
1341
+ complete: false,
1342
+ recoverable: false,
1343
+ message: "超出预算: 恢复事件无法测量(不可 JSON 序列化)",
1344
+ });
1345
+ }
1346
+
1347
+ /** 恢复事件条数超限时的结构化违规(与 history 字节违规共用同一 code/resource)。 */
1348
+ function describeRestoreEventsViolation(count) {
1349
+ return Object.freeze({
1350
+ code: "BUDGET_EXCEEDED",
1351
+ resource: "event_history",
1352
+ limit: FANOUT_EVENT_LOG_LIMIT,
1353
+ maxBytes: FANOUT_EVENT_LOG_LIMIT,
1354
+ receivedBytes: count,
1355
+ complete: false,
1356
+ recoverable: false,
1357
+ message: "超出预算: 恢复事件条数 " + String(count) + " > 上限 " + String(FANOUT_EVENT_LOG_LIMIT),
1358
+ });
1359
+ }
1360
+
1361
+ /**
1362
+ * 二分定位第一条 sequence > cursor 的事件下标(调用方保证日志升序)。
1363
+ *
1364
+ * sequence 缺失/非整数的事件按 0 处理:它们本来就被 `sequence > cursor` 排除
1365
+ * (cursor 恒为非负整数),因此不会影响正确性。
1366
+ */
1367
+ function firstIndexAfterCursor(cursor) {
1368
+ let low = 0;
1369
+ let high = events.length;
1370
+ while (low < high) {
1371
+ const mid = (low + high) >> 1;
1372
+ if ((events[mid].sequence ?? 0) > cursor) high = mid;
1373
+ else low = mid + 1;
1374
+ }
1375
+ return low;
1376
+ }
1377
+
1378
+ /**
1379
+ * 按游标读取事件差集。
1380
+ *
1381
+ * 与旧实现(对全部事件 filter + 逐条深拷贝)语义逐字段等价,但只扫描游标之后的事件、
1382
+ * 只对差集做深拷贝:升序日志下先二分定位起点,读取成本与"新增事件数"成正比,不再随
1383
+ * 历史长度线性增长(日志最多保留 FANOUT_EVENT_LOG_LIMIT 条)。
1384
+ *
1385
+ * 语义保持:
1386
+ * - 返回顺序仍是日志顺序(升序快路径下即 sequence 升序),不重排;
1387
+ * - parentTaskId 过滤、`sequence > cursor` 判定不变;
1388
+ * - nextSequence 仍取"传入游标"与"日志末条 sequence"的较大者:空日志返回游标本身,
1389
+ * 因此历史被截断后,游标 0 的新客户端依然会重读全部保留事件(不会因为首条 sequence
1390
+ * 大于游标而漏读),而已推进的游标不会倒退。
1391
+ * - 乱序日志(eventsAscending=false)退回从 0 开始的全量扫描,结果与旧实现一致。
1392
+ */
1393
+ function getEvents({ after = 0, parentTaskId } = {}) {
1394
+ const cursor = Number.isSafeInteger(after) && after >= 0 ? after : 0;
1395
+ const start = eventsAscending ? firstIndexAfterCursor(cursor) : 0;
1396
+ const batch = [];
1397
+ for (let index = start; index < events.length; index += 1) {
1398
+ const event = events[index];
1399
+ // 与旧实现的判定逐字一致(非整数/缺失 sequence 一律视为不满足 > cursor);
1400
+ // 升序快路径下这里恒为真,只有乱序兜底路径才会真正过滤。
1401
+ if (!(event.sequence > cursor)) continue;
1402
+ if (parentTaskId !== undefined && event.parentTaskId !== parentTaskId) continue;
1403
+ batch.push(normalizeEvent(event));
1404
+ }
1405
+ return {
1406
+ events: batch,
1407
+ nextSequence: events.length === 0 ? cursor : Math.max(cursor, events[events.length - 1].sequence ?? cursor),
1408
+ };
1409
+ }
1410
+
1411
+ /**
1412
+ * 幂等关闭低层 runtime;返回 true 表示本次调用真正执行了关闭,false 表示已关闭。
1413
+ *
1414
+ * 两步顺序不可交换:
1415
+ * 1. 先把在途 admission 以 runtime_closed 同步收敛(settleNow),再关闭 manager。
1416
+ * 反序会让 manager.close() 先把仍在 running 的 attempt 扫成 cancelled(manager_closed),
1417
+ * 丢掉本模块的关闭原因;先收敛则这些 attempt 在 manager 侧就是 failed(runtime_closed)。
1418
+ * 2. manager.close() 是 runtime 与 manager 共享的关闭门:关闭之后通过公开 manager API
1419
+ * 发起的 submit/retryShard/restore 一律结构化失败(MANAGER_CLOSED / manager_closed),
1420
+ * 不入队、不恢复、不新建 attempt。Host facade 的 manager-first 顺序下这次关闭是幂等
1421
+ * no-op(already_closed),已有 manager_closed 终态不受影响。
1422
+ */
1423
+ function close() {
1424
+ if (closed) return false;
1425
+ closed = true;
1426
+ // 先封锁 admission,再让所有未终态 attempt 收敛:abort admission,对已经拿到
1427
+ // childId 的记录发出 best-effort interrupt(同步异常与 rejected Promise 都由
1428
+ // requestChildInterrupt 吞并记录),最后用显式关闭错误一次性结算 completion。
1429
+ for (const record of records.values()) {
1430
+ record.cancelRequested = true;
1431
+ try { record.controller?.abort(); } catch (error) { log("fan-out admission abort failed", error); }
1432
+ requestChildInterrupt(record, { ...record.input, reason: "runtime_closed" }, record.childId);
1433
+ // 还停在 buildRequest 的 launcher admission(phase=build)与还没拿到 messageId 的
1434
+ // 续作 admission(phase=continue)都尚未产生任何 child 句柄:同步以关闭错误收敛,
1435
+ // 让 manager 在自身关闭前先看到 failed(runtime_closed)。已经进入 startContinuable /
1436
+ // continueMessage 且拿到句柄的 admission 保持原语义——迟到的 childId 仍会被 interrupt
1437
+ // 一次,且暂存(pendingAdmissionEvents)随 finishRecord 一并释放,不残留悬挂记录。
1438
+ if (record.phase === "build" || record.phase === "continue") {
1439
+ record.admission?.settleNow(fanoutRuntimeClosedError());
1440
+ }
1441
+ finishRecord(record, false, fanoutRuntimeClosedError());
1442
+ }
1443
+ // 仍挂在 buildRequest/startContinuable 的 launcher 靠 finished 标记与 interruptedKeys
1444
+ // 收敛;清空映射后,迟到的 agent/created、session event、agent disposed 找不到 record,
1445
+ // 既不复活记录也不会二次结算。
1446
+ records.clear();
1447
+ childRecords.clear();
1448
+ sessionRecords.clear();
1449
+ // 共享关闭门:关闭唯一调度状态机的全部写入入口。manager 已被外部(Host facade
1450
+ // manager-first 顺序)关闭时,这里只是幂等 no-op。
1451
+ try { manager.close(); } catch (error) { log("fan-out manager close failed", error); }
1452
+ try { unsubscribeManager?.(); } catch {}
1453
+ return true;
1454
+ }
1455
+
1456
+ return {
1457
+ manager,
1458
+ launcher,
1459
+ /** 多轮复用的续作 admission(manager 的 continuer)。 */
1460
+ continuer: continueShard,
1461
+ /**
1462
+ * continuation 的执行前置条件(只读):投递路由 + 每个分片既有 child 的 session 是否
1463
+ * 可解析。Host 必须在 manager 状态修改之前调用它,任何不满足都必须结构化拒绝。
1464
+ */
1465
+ continuationReadiness,
1466
+ canceller,
1467
+ isFanoutAgent,
1468
+ handleAgentCreated,
1469
+ handleSessionEvent,
1470
+ handleAgentDisposed,
1471
+ restoreEvents,
1472
+ getEvents,
1473
+ close,
1474
+ /**
1475
+ * 只读预算视图:当前用量、冻结上限与最近一次违规。恢复事件(外部输入)的违规
1476
+ * 不属于 manager,因此在这里与 manager 的违规合并后返回(取更新的一方)。
1477
+ */
1478
+ budget: () => {
1479
+ const managerBudget = manager.budget();
1480
+ return lastEventViolation === null
1481
+ ? managerBudget
1482
+ : { ...managerBudget, eventLog: events.length, lastViolation: lastEventViolation };
1483
+ },
1484
+ get activeAttemptCount() { return records.size; },
1485
+ /**
1486
+ * 只读诊断:某 attempt 当前的会话绑定状态(sessionId / 安装代次 / 是否有实例 / 阶段 /
1487
+ * 是否有 admission 期间的 disposal 待决标记 / 已忽略的迟到 disposal 数)。
1488
+ *
1489
+ * 用途是验证两轮终审修复的可观测不变式:latest-session 规则(replacement 后写回不得推进/
1490
+ * 回退代次)与 disposal 替换窗口(未绑定的 continuation admission 不得被旧实例 disposal
1491
+ * 提前终结)。返回普通标量,不暴露 session/Agent 对象引用。
1492
+ */
1493
+ sessionBindingOf(parentTaskId, shardId, attemptNumber) {
1494
+ const record = records.get(fanoutAttemptKey(parentTaskId, shardId, attemptNumber));
1495
+ if (record === undefined) return null;
1496
+ return {
1497
+ sessionId: typeof record.sessionId === "string" ? record.sessionId : null,
1498
+ generation: Number.isSafeInteger(record.sessionGeneration) ? record.sessionGeneration : 0,
1499
+ hasInstance: isObject(record.session),
1500
+ phase: typeof record.phase === "string" ? record.phase : null,
1501
+ pendingDisposal: record.pendingDisposal !== undefined,
1502
+ staleDisposalIgnored: Number.isSafeInteger(record.staleDisposalIgnored) ? record.staleDisposalIgnored : 0,
1503
+ replacedAfterDisposal: Number.isSafeInteger(record.replacedAfterDisposal) ? record.replacedAfterDisposal : 0,
1504
+ };
1505
+ },
1506
+ };
1507
+ }