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.js ADDED
@@ -0,0 +1,2981 @@
1
+ /**
2
+ * dsh-expert-group 的 fan-out 领域模块。
3
+ *
4
+ * 该模块把「一个父任务拆成多个 shard、按 role 限流并发执行、wait-all 聚合」这套
5
+ * 逻辑收敛成纯 Node ESM 工厂,不依赖任何 DSH runtime。Host 只需通过依赖注入提供:
6
+ *
7
+ * - launcher:启动一个 shard 对应的 child(可适配现有 startContinuable);
8
+ * - canceller:取消/中断一个正在运行的 child(可适配 subagents.interrupt);
9
+ * - clock:now/setTimeout/clearTimeout(可注入假时钟做确定性超时测试);
10
+ * - persistence:状态变化后的快照落盘回调;
11
+ * - conflictChecker:affectedPaths 冲突判定(模块不自带路径解析器)。
12
+ *
13
+ * 设计要点(状态机、队列、公平性、竞态与错误处理):
14
+ *
15
+ * 1. 状态机:shard 逻辑状态只有 queued / running / 四类终态
16
+ * (succeeded、failed、cancelled、timed_out),外加恢复态 interrupted(只在
17
+ * restore 出现,表示原运行时句柄已丢失)。终态写入必须一次性且幂等,取消与完成
18
+ * 竞态采用「先写入终态者胜出」,迟到结果只记录不覆盖。
19
+ * 2. 队列:每个 shard 是独立调度单元,按输入顺序保留;并发上限只约束 running 数量,
20
+ * 不限制登记总量,排队中的 shard 不计入 running。
21
+ * 并发约束是**两层独立判定的与关系**,不是覆盖关系:
22
+ * - 任务级显式 concurrencyLimit(fanout 工具入参):只统计该父任务**自身**的 running
23
+ * 分片数,代表"这个父任务最多同时跑几个分片";未显式给出(含快照恢复出的任务级值
24
+ * 缺失)时这一层不设限。
25
+ * - role 级 roleLimits(Host 接入插件 settings.roleConcurrencyLimit),缺失时落到
26
+ * 插件默认值:代表"整个 role 共享的并发池",统计该 role 下**所有**父任务的 running
27
+ * 分片数。
28
+ * 派发一个 shard 必须同时满足「任务自身 running < 任务级上限」与「role 共享 running <
29
+ * role 池上限」。若只按单层覆盖,低任务级上限的父任务会被其它父任务占用的 role 槽位
30
+ * 饿死,而高任务级上限的父任务又能单任务越过 role 共享池。
31
+ * 角色级/default 两层描述"该角色的共享并发池",可以注入解析函数并按当前设置动态
32
+ * 取值(设置页保存后立即约束后续派发);任务级显式限制会随任务快照固化,恢复后不变。
33
+ * 3. 公平性:多个父任务共享同一 role 槽位时,按父任务轮转(round-robin cursor)
34
+ * 派发,避免大型父任务用排队 shard 饿死后来的小任务。
35
+ * 4. 竞态:启动器可能同步返回、异步返回,也可能启动后永不结束(只能靠超时或取消
36
+ * 收敛);取消可能发生在 queued、running 或已经终态之后,全部路径都收敛到同一
37
+ * 个 settle() 入口。
38
+ * 5. 错误处理:启动失败(同步抛错或 Promise reject)记为 failed,不自动重试,兄弟
39
+ * shard 继续;手工重试只通过 retryShard 显式触发,并在原父任务内追加 attempt,
40
+ * 保留全部历史。
41
+ *
42
+ * 6. 数据资源预算:prompt / result / error / 事件 / 快照 / task/shard/attempt 数量
43
+ * 全部有界,超限一律结构化拒绝或显式失败(见 resource-budget.js)。输入超限在
44
+ * submit 阶段整体拒绝;result/error 超限把该 shard 收敛为 failed 并携带
45
+ * complete=false 的违规元数据;快照超限不落盘。
46
+ *
47
+ * @module dsh-expert-group/fanout
48
+ */
49
+ import {
50
+ BUDGET_EXCEEDED_CODE,
51
+ FANOUT_BUDGET,
52
+ checkAttemptPayloadBudget,
53
+ checkBudget,
54
+ checkEventBudget,
55
+ checkEventHistoryBudget,
56
+ checkRestoreBudget,
57
+ checkTaskInputBudget,
58
+ describeBudgetViolation,
59
+ measureJsonBytes,
60
+ measureUtf8Bytes,
61
+ } from "./resource-budget.js";
62
+ import { describeSubagentCapacityFailure } from "./shared.js";
63
+
64
+ /** per-role 并发上限的默认值。 */
65
+ export const DEFAULT_CONCURRENCY_LIMIT = 10;
66
+ /** per-role 并发上限的下界。 */
67
+ export const MIN_CONCURRENCY_LIMIT = 1;
68
+ /** per-role 并发上限的上界。 */
69
+ export const MAX_CONCURRENCY_LIMIT = 64;
70
+
71
+ /**
72
+ * shard 可选任务名称的 UTF-8 字节上限。
73
+ *
74
+ * 名称是"给人看的短标签",会随任务快照、事件与 Expert Group 投影一路传递并展示在
75
+ * 右栏第一行,因此必须有界:无界名称会让单条投影行被超长文本撑爆(视觉截断只解决
76
+ * 显示,不解决传输与落盘体积)。256 字节足够容纳一句话级的中文任务名(约 85 个汉字),
77
+ * 同时又远小于单个 shard prompt 的预算,超限一律结构化拒绝而不是静默截断。
78
+ *
79
+ * 常量留在本模块而不是 resource-budget.js:它是本模块的输入形状约束,不属于 fan-out
80
+ * 的字节资源预算族(最大快照/事件/输入体积),混进预算表反而会让人误以为它参与
81
+ * 全局满载测量。
82
+ */
83
+ export const MAX_SHARD_NAME_BYTES = 256;
84
+
85
+ /** shard 的四类终态(interrupted 只由 restore 产生,见下)。 */
86
+ export const SHARD_TERMINAL_STATUSES = Object.freeze([
87
+ "succeeded",
88
+ "failed",
89
+ "cancelled",
90
+ "timed_out",
91
+ "interrupted",
92
+ ]);
93
+
94
+ /** 父任务聚合状态。pending 表示仍有未终态 shard。 */
95
+ export const FANOUT_TASK_STATUSES = Object.freeze([
96
+ "pending",
97
+ "completed",
98
+ "completed_with_errors",
99
+ "cancelled",
100
+ ]);
101
+
102
+ /** 会出现在 counts 中的全部 shard 状态。 */
103
+ const SHARD_STATUS_KEYS = Object.freeze([
104
+ "queued",
105
+ "running",
106
+ "succeeded",
107
+ "failed",
108
+ "cancelled",
109
+ "timed_out",
110
+ "interrupted",
111
+ ]);
112
+ const SHARD_STATUS_SET = new Set(SHARD_STATUS_KEYS);
113
+
114
+ /** 快照格式版本;restore 只接受同版本快照。 */
115
+ const SNAPSHOT_VERSION = 1;
116
+
117
+ /** 判断 shard 是否已进入终态(含恢复态 interrupted)。 */
118
+ export function isTerminalShardStatus(status) {
119
+ return SHARD_TERMINAL_STATUSES.includes(status);
120
+ }
121
+
122
+ /** 结构化校验错误:调用方可用 code/field 做精确分支,不需要解析文案。 */
123
+ export class FanoutValidationError extends Error {
124
+ constructor(code, message, details = {}) {
125
+ super(message);
126
+ this.name = "FanoutValidationError";
127
+ this.code = code;
128
+ Object.assign(this, details);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * 预算违规的 shard 错误:结构化、可 JSON 序列化,且不携带超限原始负载。
134
+ * complete/recoverable/receivedBytes/maxBytes 与 describeBudgetViolation 完全一致。
135
+ */
136
+ function budgetShardError(violation) {
137
+ return {
138
+ name: "BudgetExceededError",
139
+ reason: "budget_exceeded",
140
+ code: BUDGET_EXCEEDED_CODE,
141
+ resource: violation.resource,
142
+ message: violation.message,
143
+ receivedBytes: violation.receivedBytes,
144
+ maxBytes: violation.maxBytes,
145
+ limit: violation.limit,
146
+ complete: false,
147
+ recoverable: violation.recoverable === true,
148
+ subject: violation.subject,
149
+ ...(violation.hint === undefined ? {} : { hint: violation.hint }),
150
+ };
151
+ }
152
+
153
+ /** 结构化 shard 错误:保留 reason 便于 Host 区分 timeout/cancel/启动失败。 */
154
+ function toShardError(error, fallbackMessage) {
155
+ if (error === null || error === undefined) return { name: "Error", message: fallbackMessage };
156
+ if (typeof error === "object" && !Array.isArray(error)) {
157
+ const record = { ...error };
158
+ if (typeof record.message !== "string" || record.message.length === 0) record.message = fallbackMessage;
159
+ if (typeof record.name !== "string") record.name = "Error";
160
+ return record;
161
+ }
162
+ return { name: "Error", message: String(error) };
163
+ }
164
+
165
+ /** 把任意启动结果规范化为结构化错误。 */
166
+ function normalizeLaunchError(error) {
167
+ // 容量耗尽:用户看到可读提示(等待子代理完成 / 继续使用现有子代理),
168
+ // 而不是宿主原始错误码。code 用自有稳定值,保持 structured error 契约。
169
+ const capacity = describeSubagentCapacityFailure(error);
170
+ if (capacity !== undefined) {
171
+ return { name: "SubagentCapacityError", code: capacity.code, message: capacity.message };
172
+ }
173
+ if (error instanceof Error) {
174
+ return toShardError({ name: error.name, message: error.message, stack: error.stack }, "shard 启动失败");
175
+ }
176
+ return toShardError(error, "shard 启动失败");
177
+ }
178
+
179
+ /**
180
+ * 深拷贝快照数据。模块只处理 JSON 安全数据(prompt/结果/错误/时间戳),
181
+ * 因此用 JSON 往返即可,既不引入依赖,也天然隔离外部修改。
182
+ */
183
+ function cloneJson(value) {
184
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
185
+ }
186
+
187
+ function normalizeText(value) {
188
+ return typeof value === "string" ? value : "";
189
+ }
190
+
191
+ /**
192
+ * 校验 per-role 并发上限。必须是对 1~64 范围内的整数。
193
+ * @param {unknown} value - 待校验值。
194
+ * @returns {{ok:boolean, value?:number, code?:string, message?:string}}
195
+ */
196
+ export function validateConcurrencyLimit(value) {
197
+ if (value === undefined || value === null) {
198
+ return { ok: false, code: "INVALID_CONCURRENCY_LIMIT", message: "concurrencyLimit 必须是 1~" + MAX_CONCURRENCY_LIMIT + " 的整数" };
199
+ }
200
+ if (typeof value !== "number" || !Number.isInteger(value)) {
201
+ return { ok: false, code: "INVALID_CONCURRENCY_LIMIT", message: "concurrencyLimit 必须是整数" };
202
+ }
203
+ if (value < MIN_CONCURRENCY_LIMIT || value > MAX_CONCURRENCY_LIMIT) {
204
+ return { ok: false, code: "INVALID_CONCURRENCY_LIMIT", message: "concurrencyLimit 必须在 " + MIN_CONCURRENCY_LIMIT + "~" + MAX_CONCURRENCY_LIMIT + " 之间" };
205
+ }
206
+ return { ok: true, value };
207
+ }
208
+
209
+ /**
210
+ * 把 roleLimits 配置规范化为干净的 role -> limit 映射。
211
+ * 非法条目直接抛错,避免把错误的并发上限静默降级成默认值。
212
+ * @param {Record<string, number>} [roleLimits]
213
+ * @returns {Record<string, number>}
214
+ */
215
+ export function normalizeRoleLimits(roleLimits) {
216
+ if (roleLimits === undefined || roleLimits === null) return {};
217
+ // 函数形式是"每次新建父任务时读取当前 settings"的解析回调(见 createFanoutManager)。
218
+ // 这里只校验形状,值本身由 normalizer 在提交/取用时归一化。
219
+ if (typeof roleLimits === "function") return roleLimits;
220
+ if (typeof roleLimits !== "object" || Array.isArray(roleLimits)) {
221
+ throw new FanoutValidationError("INVALID_ROLE_LIMITS", "roleLimits 必须是 role -> limit 的映射", { field: "roleLimits" });
222
+ }
223
+ const normalized = {};
224
+ for (const [role, limit] of Object.entries(roleLimits)) {
225
+ const check = validateConcurrencyLimit(limit);
226
+ if (!check.ok) {
227
+ throw new FanoutValidationError("INVALID_ROLE_LIMITS", "roleLimits." + role + " 非法: " + check.message, { field: "roleLimits." + role });
228
+ }
229
+ normalized[role] = limit;
230
+ }
231
+ return normalized;
232
+ }
233
+
234
+ function normalizeWriteRoles(writeRoles) {
235
+ if (writeRoles === undefined || writeRoles === null) return [];
236
+ const list = Array.isArray(writeRoles) ? writeRoles : [writeRoles];
237
+ return [...new Set(list.filter((role) => typeof role === "string" && role.length > 0))];
238
+ }
239
+
240
+ function normalizeShardId(value) {
241
+ if (typeof value !== "string") return "";
242
+ return value.trim();
243
+ }
244
+
245
+ /**
246
+ * 校验并归一化单个 shard 的可选任务名称,失败时给出结构化错误。
247
+ *
248
+ * 名称只在 trim 之后使用,不改内部大小写与标点(避免破坏用户命名)。
249
+ *
250
+ * 语义(与项目其它可选字段一致:undefined/null 视为"未提供"):
251
+ * - undefined / null:合法,表示主代理没有为该分片命名;
252
+ * - 非字符串(数字、布尔、对象、数组):显式拒绝,绝不 String() 静默转换;
253
+ * - 纯空白字符串:显式拒绝——分片代号回退只认"字段缺失",空白名称是写错的输入;
254
+ * - 非空字符串:trim 后按 UTF-8 字节判预算,超限同样以 INVALID_SHARD_NAME 整体拒绝。
255
+ *
256
+ * 全部失败分支统一使用 code=INVALID_SHARD_NAME:调用方按字段分支即可,不需要区分
257
+ * "不是字符串"和"太长"两种拒绝原因;预算违规仍把结构化元数据带在 violation 里。
258
+ *
259
+ * @param {unknown} value - shard.name 原始值。
260
+ * @param {string} field - 出错时回填给调用方的字段路径(如 shards[0].name)。
261
+ * @returns {{ok:true, name?:string} | {ok:false, code:string, message:string, field:string, violation?:object}}
262
+ */
263
+ function validateShardName(value, field) {
264
+ const fail = (message) => ({ ok: false, code: "INVALID_SHARD_NAME", message, field });
265
+ if (value === undefined || value === null) return { ok: true };
266
+ if (typeof value !== "string") return fail(field + " 必须是字符串");
267
+ const name = value.trim();
268
+ if (name.length === 0) return fail(field + " 必须是非空字符串");
269
+ const violation = budgetVerdict(
270
+ "shard_name",
271
+ MAX_SHARD_NAME_BYTES,
272
+ measureUtf8Bytes(name),
273
+ field,
274
+ "把任务名称缩短成一句话级别的短标签",
275
+ );
276
+ // 预算通道的 message 只描述资源与体积,这里把字段路径补进去:多 shard 提交时
277
+ // 光看"实测 N > 上限 M"无法定位是哪个分片的名称超限。
278
+ if (violation !== null) return { ...violation, message: field + " " + violation.message };
279
+ return { ok: true, name };
280
+ }
281
+
282
+ /**
283
+ * 默认的 affectedPaths 冲突检查:只对写入 role 判定重叠。
284
+ *
285
+ * 只读 role(例如 explorer)的同角色分片读取同一批文件是正常并行,默认不判为冲突;
286
+ * 只有出现在 writeRoles 中的 role 才禁止 affectedPaths 重叠。模块不做路径解析,
287
+ * 只按传入的路径字符串做相等比较——需要目录/规范化语义时由 Host 注入自己的
288
+ * conflictChecker。
289
+ *
290
+ * @param {object} [options]
291
+ * @param {string[]} [options.writeRoles] - 需要禁止重叠的写入 role。
292
+ * @returns {(input:{role:string, shard:object, otherShards:object[]}) => {conflict:boolean, shardIds?:string[], paths?:string[]}}
293
+ */
294
+ export function createWriteRoleConflictChecker({ writeRoles = [] } = {}) {
295
+ const writeRoleSet = new Set(normalizeWriteRoles(writeRoles));
296
+ return function writeRoleConflictChecker({ role, shard, otherShards = [] } = {}) {
297
+ const paths = Array.isArray(shard?.affectedPaths) ? shard.affectedPaths : [];
298
+ if (!writeRoleSet.has(role) || paths.length === 0) return { conflict: false };
299
+ for (const other of otherShards) {
300
+ const otherPaths = Array.isArray(other?.affectedPaths) ? other.affectedPaths : [];
301
+ if (otherPaths.length === 0) continue;
302
+ const overlap = paths.filter((path) => otherPaths.includes(path));
303
+ if (overlap.length === 0) continue;
304
+ return {
305
+ conflict: true,
306
+ shardIds: [normalizeShardId(other.shardId), normalizeShardId(shard.shardId)].filter((id) => id.length > 0),
307
+ paths: [...new Set(overlap)],
308
+ };
309
+ }
310
+ return { conflict: false };
311
+ };
312
+ }
313
+
314
+ /** 把注入 checker 的返回值(布尔或对象)统一成 {conflict, shardIds, paths}。 */
315
+ function normalizeConflict(verdict) {
316
+ if (verdict === true) return { conflict: true };
317
+ if (verdict === false || verdict === undefined || verdict === null) return { conflict: false };
318
+ if (typeof verdict === "object") {
319
+ return {
320
+ conflict: verdict.conflict === true,
321
+ shardIds: Array.isArray(verdict.shardIds) ? verdict.shardIds : undefined,
322
+ paths: Array.isArray(verdict.paths) ? verdict.paths : undefined,
323
+ };
324
+ }
325
+ return { conflict: false };
326
+ }
327
+
328
+ /**
329
+ * 纯函数校验 submit 输入。校验失败返回结构化错误,绝不抛异常;
330
+ * submit() 再把失败结果翻译成 FanoutValidationError,保证「非法输入整体拒绝」。
331
+ *
332
+ * shard 支持可选的 name(人类可读任务名称,如"实现添加任务名称"):它是展示用的短
333
+ * 标签,不参与受影响路径冲突判定,也不改变调度语义;缺失时保持旧行为(投影回退
334
+ * shardId)。非法 name 与非法 shardId/prompt 一样整体拒绝,避免把写错的输入静默降级。
335
+ *
336
+ * @param {object} input - submit() 的原始入参。
337
+ * @returns {{ok:true, role:string, basePrompt:string, shards:object[], requestId?:string, concurrencyLimit?:number, allowOverlap:boolean} | {ok:false, code:string, message:string, field?:string, conflict?:object}}
338
+ */
339
+ export function validateSubmitInput(input) {
340
+ const raw = input === null || typeof input !== "object" ? {} : input;
341
+ const role = typeof raw.role === "string" ? raw.role.trim() : "";
342
+ if (role.length === 0) {
343
+ return { ok: false, code: "INVALID_ROLE", message: "role 必须是非空字符串", field: "role" };
344
+ }
345
+ const basePrompt = normalizeText(raw.basePrompt);
346
+ if (basePrompt.trim().length === 0) {
347
+ return { ok: false, code: "INVALID_BASE_PROMPT", message: "basePrompt 必须是非空字符串", field: "basePrompt" };
348
+ }
349
+ if (!Array.isArray(raw.shards) || raw.shards.length === 0) {
350
+ return { ok: false, code: "INVALID_SHARDS", message: "shards 必须是非空数组", field: "shards" };
351
+ }
352
+ // 每个 task 的 shard 数必须有界:快照、事件与 attempt 历史都按 shard 线性增长。
353
+ const shardCountViolation = budgetVerdict("shards", FANOUT_BUDGET.maxShardsPerTask, raw.shards.length, "shards");
354
+ if (shardCountViolation !== null) return shardCountViolation;
355
+
356
+ const seen = new Set();
357
+ const shards = [];
358
+ for (let index = 0; index < raw.shards.length; index += 1) {
359
+ const rawShard = raw.shards[index];
360
+ const field = "shards[" + index + "]";
361
+ if (rawShard === null || typeof rawShard !== "object" || Array.isArray(rawShard)) {
362
+ return { ok: false, code: "INVALID_SHARD", message: field + " 必须是对象", field };
363
+ }
364
+ const shardId = normalizeShardId(rawShard.shardId);
365
+ if (shardId.length === 0) {
366
+ return { ok: false, code: "INVALID_SHARD_ID", message: field + ".shardId 必须是非空字符串", field: field + ".shardId" };
367
+ }
368
+ // 可选任务名称:分片代号之后立刻判定,保证非法名称与非法 shardId 一样走
369
+ // "整体拒绝、不登记任务、不创建 child"的原子校验路径。
370
+ const nameVerdict = validateShardName(rawShard.name, field + ".name");
371
+ if (!nameVerdict.ok) return nameVerdict;
372
+ if (seen.has(shardId)) {
373
+ return { ok: false, code: "DUPLICATE_SHARD_ID", message: "duplicate shardId: " + shardId, field: field + ".shardId" };
374
+ }
375
+ seen.add(shardId);
376
+ const prompt = normalizeText(rawShard.prompt);
377
+ if (prompt.trim().length === 0) {
378
+ return { ok: false, code: "INVALID_SHARD_PROMPT", message: field + ".prompt 必须是非空字符串", field: field + ".prompt" };
379
+ }
380
+ let affectedPaths;
381
+ if (rawShard.affectedPaths !== undefined) {
382
+ if (!Array.isArray(rawShard.affectedPaths) || rawShard.affectedPaths.some((path) => typeof path !== "string")) {
383
+ return { ok: false, code: "INVALID_AFFECTED_PATHS", message: field + ".affectedPaths 必须是字符串数组", field: field + ".affectedPaths" };
384
+ }
385
+ affectedPaths = [...rawShard.affectedPaths];
386
+ }
387
+ let timeoutMs;
388
+ if (rawShard.timeoutMs !== undefined && rawShard.timeoutMs !== null) {
389
+ if (typeof rawShard.timeoutMs !== "number" || !Number.isFinite(rawShard.timeoutMs) || rawShard.timeoutMs <= 0) {
390
+ return { ok: false, code: "INVALID_TIMEOUT", message: field + ".timeoutMs 必须是正有限数", field: field + ".timeoutMs" };
391
+ }
392
+ timeoutMs = rawShard.timeoutMs;
393
+ }
394
+ shards.push({
395
+ shardId,
396
+ prompt,
397
+ // 名称缺失时完全不出现该字段:旧 Host / 旧快照读取方看到的是原有形状。
398
+ ...(nameVerdict.name === undefined ? {} : { name: nameVerdict.name }),
399
+ ...(affectedPaths === undefined ? {} : { affectedPaths }),
400
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
401
+ });
402
+ }
403
+
404
+ // 输入预算:basePrompt 与每个 shard 的正文共用同一上限。超限必须显式拒绝——
405
+ // 截断后派发会让专家基于不完整目标工作,属于静默丢输入。
406
+ for (let index = 0; index < shards.length; index += 1) {
407
+ const inputCheck = checkTaskInputBudget({
408
+ basePrompt,
409
+ shardPrompt: shards[index].prompt,
410
+ subject: "shards[" + index + "]",
411
+ });
412
+ if (inputCheck.ok) continue;
413
+ return {
414
+ ok: false,
415
+ code: "INVALID_BUDGET",
416
+ message: inputCheck.violation.message,
417
+ field: "shards[" + index + "].prompt",
418
+ violation: inputCheck.violation,
419
+ };
420
+ }
421
+
422
+ let concurrencyLimit;
423
+ if (raw.concurrencyLimit !== undefined && raw.concurrencyLimit !== null) {
424
+ const check = validateConcurrencyLimit(raw.concurrencyLimit);
425
+ if (!check.ok) {
426
+ return { ok: false, code: check.code, message: check.message, field: "concurrencyLimit" };
427
+ }
428
+ concurrencyLimit = check.value;
429
+ }
430
+
431
+ let requestId;
432
+ if (raw.requestId !== undefined && raw.requestId !== null) {
433
+ if (typeof raw.requestId !== "string" || raw.requestId.trim().length === 0) {
434
+ return { ok: false, code: "INVALID_REQUEST_ID", message: "requestId 必须是非空字符串", field: "requestId" };
435
+ }
436
+ requestId = raw.requestId;
437
+ }
438
+
439
+ return {
440
+ ok: true,
441
+ role,
442
+ basePrompt,
443
+ shards,
444
+ ...(requestId === undefined ? {} : { requestId }),
445
+ ...(concurrencyLimit === undefined ? {} : { concurrencyLimit }),
446
+ allowOverlap: raw.allowOverlap === true,
447
+ };
448
+ }
449
+
450
+ /** 把校验失败结果翻译成 FanoutValidationError。 */
451
+ function validationError(verdict) {
452
+ return new FanoutValidationError(verdict.code, verdict.message, {
453
+ ...(verdict.field === undefined ? {} : { field: verdict.field }),
454
+ ...(verdict.conflict === undefined ? {} : { conflict: verdict.conflict }),
455
+ // 预算拒绝必须把结构化违规元数据一并带出(code/resource/receivedBytes/maxBytes)。
456
+ ...(verdict.violation === undefined ? {} : { violation: verdict.violation }),
457
+ });
458
+ }
459
+
460
+ /**
461
+ * 把预算违规翻译成 submit 校验失败结果。
462
+ * 输入超限不能截断后继续执行,因此这里走"整体拒绝"通道(不登记任务、不建 child)。
463
+ */
464
+ function budgetVerdict(resource, limit, receivedBytes, field, hint) {
465
+ const check = checkBudget({ resource, limit, receivedBytes, subject: field, recoverable: true, hint });
466
+ if (check.ok) return null;
467
+ return { ok: false, code: "INVALID_BUDGET", message: check.violation.message, field, violation: check.violation };
468
+ }
469
+
470
+ /**
471
+ * 创建一个 fan-out 管理器。
472
+ *
473
+ * @param {object} options
474
+ * @param {(input:{parentTaskId:string, role:string, basePrompt:string, shardId:string, name?:string, prompt:string, affectedPaths?:string[], timeoutMs?:number, attempt:number}) => (object|Promise<object>)} options.launcher
475
+ * shard 启动器。返回值可以是 child 句柄(childId/messageId 可选)或直接返回 Promise;
476
+ * 句柄可携带 completion Promise 表示 child 的终态。返回值/句柄都是启动结果,不影响
477
+ * 任务状态归属(状态只存在本模块内,不绑定单个 childId)。
478
+ * @param {(input:{parentTaskId:string, role:string, basePrompt:string, shardId:string, attempt:number, childId:string, prompt:string}) => (object|Promise<object>)} [options.continuer]
479
+ * 多轮 continuation 的续作启动器:与 launcher 同形,但必须把新一轮 prompt 投递给**同一
480
+ * child**(input.childId),返回值句柄的 completion 表示该轮 child 终态。缺失时
481
+ * continueTask 一律结构化拒绝(fail-closed,不新建 attempt)。
482
+ * @param {(input:{parentTaskId:string, shardId:string, attempt:number, childId?:string, reason:string, handle?:object}) => (void|Promise<unknown>)} [options.canceller]
483
+ * 取消器。调用后由 child 的 completion 收敛为 cancelled,因此取消是「立即接受 + 异步收敛」。
484
+ * @param {{now:()=>number, setTimeout:(fn:Function,ms:number)=>unknown, clearTimeout:(handle:unknown)=>void}} [options.clock]
485
+ * 可注入时钟;默认使用全局 Date/timers。
486
+ * @param {{save?:(snapshot:object)=>unknown}} [options.persistence]
487
+ * 持久化回调。每次状态变化后收到一份深拷贝、可 JSON 序列化的快照。
488
+ * @param {(input:{role:string, shard:object, otherShards:object[]}) => (boolean|object)} [options.conflictChecker]
489
+ * 冲突检查函数。未提供时使用 createWriteRoleConflictChecker(writeRoles)。
490
+ * @param {(input:{role:string, shard:object, otherShards:object[]}) => void} [options.conflictObserver]
491
+ * 可选的只读观测 seam;异常会被忽略,不参与调度决策,供固定基准记录候选规模。
492
+ * @param {string[]} [options.writeRoles] - 需要禁止 affectedPaths 重叠的写入 role。
493
+ * @param {boolean} [options.allowOverlap] - 全局放行 affectedPaths 重叠(提交级 allowOverlap 优先)。
494
+ * @param {number|Function} [options.concurrencyLimit] - 默认 per-role 并发上限;传函数时按
495
+ * 「每次新建父任务读取一次当前配置」的动态默认值解析(Host 用它接入插件 settings)。
496
+ * @param {Record<string, number>|Function} [options.roleLimits] - role 级并发上限覆盖;函数形式
497
+ * 返回 {role: limit},缺失 role 落到默认值。函数返回值里的非法条目一律回退默认值,
498
+ * 绝不让 scheduler 拿到 NaN/负数/越界值。
499
+ * @param {number} [options.maxSnapshotBytes] - 单份快照字节上限覆盖(默认取 FANOUT_BUDGET)。
500
+ * @returns {object} fan-out 管理器公共接口。
501
+ */
502
+ export function createFanoutManager(options = {}) {
503
+ if (options === null || typeof options !== "object") {
504
+ throw new FanoutValidationError("INVALID_OPTIONS", "createFanoutManager 需要配置对象");
505
+ }
506
+ const {
507
+ launcher,
508
+ continuer,
509
+ canceller,
510
+ clock,
511
+ persistence,
512
+ conflictChecker: injectedConflictChecker,
513
+ conflictObserver,
514
+ roleLimits: rawRoleLimits,
515
+ concurrencyLimit: rawConcurrencyLimit,
516
+ } = options;
517
+ if (typeof launcher !== "function") {
518
+ throw new FanoutValidationError("INVALID_LAUNCHER", "必须注入 launcher 函数");
519
+ }
520
+ if (continuer !== undefined && continuer !== null && typeof continuer !== "function") {
521
+ throw new FanoutValidationError("INVALID_CONTINUER", "continuer 必须是函数");
522
+ }
523
+ if (canceller !== undefined && canceller !== null && typeof canceller !== "function") {
524
+ throw new FanoutValidationError("INVALID_CANCELLER", "canceller 必须是函数");
525
+ }
526
+ if (persistence !== undefined && persistence !== null && typeof persistence.save !== "function") {
527
+ throw new FanoutValidationError("INVALID_PERSISTENCE", "persistence.save 必须是函数");
528
+ }
529
+ if (conflictObserver !== undefined && conflictObserver !== null && typeof conflictObserver !== "function") {
530
+ throw new FanoutValidationError("INVALID_CONFLICT_OBSERVER", "conflictObserver 必须是函数");
531
+ }
532
+ const now = typeof clock?.now === "function" ? clock.now : () => Date.now();
533
+ const setTimeoutFn = typeof clock?.setTimeout === "function" ? clock.setTimeout : (fn, ms) => setTimeout(fn, ms);
534
+ const clearTimeoutFn = typeof clock?.clearTimeout === "function" ? clock.clearTimeout : (handle) => clearTimeout(handle);
535
+
536
+ // 默认并发上限既可以是静态整数,也可以是"每次新建父任务读取一次"的解析函数。
537
+ // 函数形式接入插件 settings 的 roleConcurrencyLimit:静态非法值必须在构造期显式
538
+ // 失败(配置写错不能静默降级),函数返回值则按归一化规则回退默认值——settings 里
539
+ // 的旧配置/非法值不能污染调度器。
540
+ // 未被 settings 解析函数提供时先取静态默认值;之后 restore 若带来快照里的
541
+ // defaultConcurrencyLimit,则改由快照值驱动(见 roleLimitFor 的 resolverSource)。
542
+ let defaultConcurrencyLimit = typeof rawConcurrencyLimit === "function"
543
+ ? DEFAULT_CONCURRENCY_LIMIT
544
+ : (rawConcurrencyLimit === undefined
545
+ ? DEFAULT_CONCURRENCY_LIMIT
546
+ : (() => {
547
+ const check = validateConcurrencyLimit(rawConcurrencyLimit);
548
+ if (!check.ok) throw new FanoutValidationError(check.code, check.message, { field: "concurrencyLimit" });
549
+ return check.value;
550
+ })());
551
+ /**
552
+ * 默认并发上限的来源:live(读取注入的解析函数,接入 settings)或 snapshot
553
+ * (本快照自带 defaultConcurrencyLimit)。互斥,避免两套默认值互相覆盖。
554
+ *
555
+ * 只有"快照确实带了 defaultConcurrencyLimit"时 restore 才切到 snapshot——旧快照与
556
+ * 不落盘全局配置的场景必须继续跟随注入的当前设置,否则恢复出来的 queued shard 会
557
+ * 使用请求时的临时 roleLimits 残值。任务级(快照固化)上限始终优先于本条。
558
+ */
559
+ let resolverSource = typeof rawConcurrencyLimit === "function" ? "live" : "snapshot";
560
+ let roleLimits = normalizeRoleLimits(rawRoleLimits);
561
+ /** 解析函数返回非法值时的最近一次诊断(只读,不参与调度决策)。 */
562
+ let invalidLimitDiagnostic = null;
563
+ let writeRoles = normalizeWriteRoles(options.writeRoles);
564
+ let defaultAllowOverlap = options.allowOverlap === true;
565
+ /**
566
+ * 预算策略:默认全部取自 resource-budget.js 的冻结值;maxSnapshotBytes 可在
567
+ * 组合根/测试里收紧,用于验证"超限不落盘"的显式拒绝路径。
568
+ */
569
+ const budgetPolicy = Object.freeze({
570
+ ...FANOUT_BUDGET,
571
+ ...(Number.isSafeInteger(options.maxSnapshotBytes) && options.maxSnapshotBytes > 0
572
+ ? { maxSnapshotBytes: options.maxSnapshotBytes }
573
+ : {}),
574
+ });
575
+ /** 最近一次预算违规(只读诊断;不参与调度决策)。 */
576
+ let lastBudgetViolation = null;
577
+ const usesDefaultConflictChecker = typeof injectedConflictChecker !== "function";
578
+ let conflictChecker = usesDefaultConflictChecker
579
+ ? createWriteRoleConflictChecker({ writeRoles })
580
+ : injectedConflictChecker;
581
+
582
+ /** 父任务表:parentTaskId -> 内部任务状态。 */
583
+ const tasks = new Map();
584
+ /** requestId 去重索引:requestId -> parentTaskId。 */
585
+ const requestIndex = new Map();
586
+ /**
587
+ * continuation 幂等派生索引:requestId -> parentTaskId。
588
+ *
589
+ * 真相源是**每个任务自己的** `task.continuationRequests`(随快照持久化、restore 时恢复),
590
+ * 这里只是从 tasks 重建出来的查询索引,用来回答「这个 requestId 是否已被**别的**父任务
591
+ * 用过」。因此:
592
+ * - 不做任何淘汰:单任务可记录的 requestId 数受 attempt 预算约束(每成功续作一轮至少消耗
593
+ * 一个 attempt,`maxAttemptsPerShard` 封顶),总量受 maxTasks × 该上限约束;
594
+ * - restore 后由 `rebuildIndexes()` 重建,重启不丢幂等键;
595
+ * - 任务被显式清理(close/释放)时随之消失。
596
+ */
597
+ const continuationRequestOwners = new Map();
598
+ /**
599
+ * 调度热路径索引:保持 tasks Map 的注册顺序,同时避免每轮派发重新 filter 全部父任务。
600
+ * 该索引是派生状态,restore 时从 tasks 重新构建,不写入快照。
601
+ */
602
+ const parentsByRole = new Map();
603
+ /**
604
+ * role 共享池的 running 计数:role -> 该 role 下**所有**父任务的 running 分片数。
605
+ * 受 role 级设置(roleLimits / 默认值)约束,是"共享池"语义的唯一计数来源。
606
+ */
607
+ const runningCountsByRole = new Map();
608
+ /**
609
+ * 任务级 running 计数:key 是任务对象身份,value 是该父任务自身的 running 分片数。
610
+ *
611
+ * 与 runningCountsByRole 并存的第二个计数维度:任务级 concurrencyLimit 只允许约束
612
+ * 「自己这个父任务」的并发,不能拿 role 全局计数去比较,否则一个低上限任务会因为别的
613
+ * 父任务占着 role 槽位而永远派发不出去。
614
+ * key 用对象身份而不是 parentTaskId 字符串:与 activeShardEntries 同理,避免任意字符
615
+ * 拼接碰撞,也免去在任务销毁时按 id 反查的开销(计数随任务对象一起被 GC 回收)。
616
+ * 该索引是派生状态,restore 时由 rebuildIndexes() 从 tasks 重新构建,不写入快照。
617
+ */
618
+ const runningCountsByTask = new Map();
619
+ /**
620
+ * 活动 shard 索引:key 使用 shard 对象身份,避免 parentTaskId/shardId 中的任意字符
621
+ * 发生拼接碰撞;path index 只服务内置 affectedPaths checker。
622
+ */
623
+ const activeShardEntries = new Map();
624
+ const activeShardsByPath = new Map();
625
+ let nextActiveOrder = 0;
626
+ /** 事件订阅者。 */
627
+ const subscribers = new Set();
628
+ /** 每个 role 的派发轮转游标,用于父任务间公平轮转。 */
629
+ const rotationCursors = new Map();
630
+
631
+ let sequence = 0;
632
+ let nextTaskNumber = 0;
633
+ // 事件与任务快照一起持久化,重启后仍可按 sequence 续读;仅保留 bounded 历史。
634
+ // 条数上限(1000,既有契约)与字节上限都来自 budgetPolicy,见 trimEventHistory()。
635
+ const eventHistory = [];
636
+ // 调度器状态:
637
+ // - workRequested:有派发请求还没被 drain 处理完;
638
+ // - drainScheduled:已经排好一次 drain 微任务;
639
+ // - resolveDrain:drainPromise 的 resolve,只在最后一次 drain 结束时调用。
640
+ let workRequested = false;
641
+ let drainScheduled = false;
642
+ // restore 默认只重建状态;直到新的 submit/retry 或 resume 调度显式到来前,
643
+ // 任何已经排队的旧 drain 都不得偷偷启动恢复队列。
644
+ let restoreDispatchSuppressed = false;
645
+ // restore 只重建逻辑状态;禁止 aggregateTask 在恢复时重复发布 task_settled。
646
+ let restoringSnapshot = false;
647
+ let closed = false;
648
+ let resolveDrain;
649
+ let drainPromise = Promise.resolve();
650
+
651
+ // -------------------------------------------------------------------------
652
+ // 事件与持久化
653
+ // -------------------------------------------------------------------------
654
+
655
+ /**
656
+ * 发布一条事件。监听器抛错只影响自己,不能破坏调度。
657
+ *
658
+ * 单事件字节预算:原始负载过大时只发布"不含负载"的诊断事件,既不让事件历史
659
+ * 无界增长,也不静默丢失事实(诊断保留 type/subject/resource 与实测字节)。
660
+ * 事件进历史前先记录违规,调用方可通过 budget() 观察。
661
+ */
662
+ function emit(event) {
663
+ const candidate = { ...event, sequence: ++sequence, at: now() };
664
+ const verdict = checkEventBudget(candidate, { subject: candidate.type });
665
+ if (!verdict.ok) {
666
+ noteBudgetViolation(verdict.violation);
667
+ const diagnostic = {
668
+ type: "event_budget_exceeded",
669
+ subject: typeof candidate.type === "string" ? candidate.type : "event",
670
+ parentTaskId: candidate.parentTaskId,
671
+ violation: verdict.violation,
672
+ sequence,
673
+ at: now(),
674
+ };
675
+ eventHistory.push(cloneJson(diagnostic));
676
+ trimEventHistory();
677
+ for (const listener of [...subscribers]) {
678
+ try {
679
+ listener(cloneJson(diagnostic));
680
+ } catch {
681
+ // 订阅者异常必须被隔离。
682
+ }
683
+ }
684
+ return diagnostic;
685
+ }
686
+ eventHistory.push(cloneJson(candidate));
687
+ trimEventHistory();
688
+ for (const listener of [...subscribers]) {
689
+ try {
690
+ listener(cloneJson(candidate));
691
+ } catch {
692
+ // 订阅者异常必须被隔离:fan-out 的调度语义不依赖任何监听器。
693
+ }
694
+ }
695
+ return candidate;
696
+ }
697
+
698
+ /** 记录预算违规(只读诊断,不改变调度状态)。 */
699
+ function noteBudgetViolation(violation) {
700
+ if (violation === null || violation === undefined) return;
701
+ lastBudgetViolation = cloneJson(violation);
702
+ }
703
+
704
+ /**
705
+ * 事件历史的条数/字节双预算:条数保留既有 1000 条契约,字节超出时继续丢弃最旧
706
+ * 记录。两条预算同时生效,结果取更小的保留集合。
707
+ */
708
+ function trimEventHistory() {
709
+ const verdict = checkEventHistoryBudget(eventHistory, {
710
+ maxCount: budgetPolicy.maxEventHistory,
711
+ maxBytes: budgetPolicy.maxEventHistoryBytes,
712
+ });
713
+ if (verdict.droppedCount === 0) return;
714
+ eventHistory.splice(0, verdict.droppedCount);
715
+ noteBudgetViolation(verdict.violation);
716
+ }
717
+
718
+ function emitShardStatus(task, shard, status, attempt, extra = {}) {
719
+ return emit({
720
+ type: "shard_status_changed",
721
+ parentTaskId: task.parentTaskId,
722
+ shardId: shard.shardId,
723
+ attempt,
724
+ status,
725
+ role: task.role,
726
+ counts: computeCounts(task),
727
+ ...extra,
728
+ });
729
+ }
730
+
731
+ /**
732
+ * 记录被状态机拒绝的结算。迟到 completion 不能改变已写终态,但必须留下可检索的
733
+ * 诊断事件;事件本身也进入持久化快照,因此 Host 能按 sequence 观察完整竞态顺序。
734
+ */
735
+ function rejectSettlement(task, shard, attempt, requestedStatus, reason) {
736
+ const event = emit({
737
+ type: "shard_settlement_rejected",
738
+ parentTaskId: task.parentTaskId,
739
+ shardId: shard.shardId,
740
+ attempt: attempt.attempt,
741
+ role: task.role,
742
+ reason,
743
+ currentStatus: attempt.status,
744
+ requestedStatus,
745
+ });
746
+ notifyPersistence();
747
+ return { accepted: false, reason, event };
748
+ }
749
+
750
+ /**
751
+ * 快照体积闸门(接受**已测量**的字节数)。
752
+ *
753
+ * 判定规则与 resource-budget.checkSnapshotBudget 完全一致:上限取
754
+ * budgetPolicy.maxSnapshotBytes(非法时回退 FANOUT_BUDGET.maxSnapshotBytes)、
755
+ * recoverable=false、提示文案同源;区别只是这里复用序列化时量出的字节数。
756
+ *
757
+ * 之所以不能直接调用 checkSnapshotBudget:它内部会再 measureJsonBytes 一次,
758
+ * 即为了体积判定对同一份快照做第二次全量 stringify(审核确认的热点之一)。
759
+ * 上限数值仍然只有 resource-budget.js 一处来源,本函数不新增任何预算常量,
760
+ * 也不改变判定口径(receivedBytes === limit 仍视为合规)。
761
+ */
762
+ function snapshotSizeVerdict(bytes) {
763
+ return checkBudget({
764
+ resource: "snapshot",
765
+ limit: Number.isSafeInteger(budgetPolicy.maxSnapshotBytes) && budgetPolicy.maxSnapshotBytes > 0
766
+ ? budgetPolicy.maxSnapshotBytes
767
+ : FANOUT_BUDGET.maxSnapshotBytes,
768
+ receivedBytes: bytes,
769
+ recoverable: false,
770
+ hint: "快照过大时先让未终态 work 收敛,或清理已结算的历史任务",
771
+ });
772
+ }
773
+
774
+ /**
775
+ * 落盘一份快照并返回结构化结果。
776
+ *
777
+ * 落盘闸门(按顺序判定,任何一条不通过都不调用 persistence.save):
778
+ * 1. degraded 快照:序列化因不可 JSON 序列化而退化出的空 tasks 快照,
779
+ * 绝不能拿去覆盖已有的完整 durable 快照——否则就是用"丢失全部任务"冒充成功落盘;
780
+ * 2. 不可测量快照(JSON.stringify 抛错):同样拒绝;
781
+ * 3. 字节预算超限:拒绝并记录违规;
782
+ * 4. 没有 persistence:返回 no_persistence。
783
+ *
784
+ * 所有拒绝路径只更新内存健康状态并返回 {saved:false, ...},绝不抛异常、绝不改动内存
785
+ * 状态机,因此 notifyPersistence 不会被降级路径变成持久化反馈循环。
786
+ *
787
+ * @param {object} [snapshot] - 待落盘快照;缺省时序列化当前状态。
788
+ * @returns {{saved:boolean, bytes:number|null, reason?:string, violation?:object}}
789
+ */
790
+ function persistSnapshot(snapshot) {
791
+ // 两条路径的差别只在"内容与体积怎么来":
792
+ // - 内部路径(缺省):serializeSnapshotEnvelope 只做一次 stringify,payload 与 bytes
793
+ // 同源,闸门不再为测量重复序列化;
794
+ // - 显式 payload 路径(调用方传入快照对象):对象必须原样交给 persistence.save
795
+ // (调用方可能持有引用并期望写入的就是它),因此体积仍用 measureJsonBytes 测量。
796
+ // 这条路径不在状态变化热路径上(热路径见 notifyPersistence 的批合并),保持原语义。
797
+ const envelope = snapshot === undefined ? serializeSnapshotEnvelope() : null;
798
+ const payload = envelope === null ? snapshot : envelope.payload;
799
+ const measured = envelope === null ? measureJsonBytes(payload) : envelope.bytes;
800
+ // 1. 降级快照:degraded 标记由序列化路径在不可序列化时写入。
801
+ if (payload !== null && typeof payload === "object" && payload.degraded === true) {
802
+ const violation = payload.violation ?? describeBudgetViolation({
803
+ resource: "snapshot",
804
+ limit: budgetPolicy.maxSnapshotBytes,
805
+ receivedBytes: null,
806
+ measured: false,
807
+ recoverable: false,
808
+ subject: "persist",
809
+ hint: "快照不完整(degraded),拒绝落盘以避免覆盖已有的完整快照",
810
+ });
811
+ noteBudgetViolation(violation);
812
+ return { saved: false, bytes: null, reason: "degraded_snapshot", violation };
813
+ }
814
+ // 2. 不可测量:明确拒绝,不把"未知体积"当合法。
815
+ if (measured === null) {
816
+ const violation = describeBudgetViolation({
817
+ resource: "snapshot",
818
+ limit: budgetPolicy.maxSnapshotBytes,
819
+ receivedBytes: null,
820
+ measured: false,
821
+ recoverable: false,
822
+ subject: "persist",
823
+ hint: "快照不可 JSON 序列化,拒绝落盘",
824
+ });
825
+ noteBudgetViolation(violation);
826
+ return { saved: false, bytes: null, reason: "unmeasurable_snapshot", violation };
827
+ }
828
+ // 3. 字节预算(复用上面已量出的字节数,不再重新序列化)。
829
+ const verdict = snapshotSizeVerdict(measured);
830
+ if (!verdict.ok) {
831
+ noteBudgetViolation(verdict.violation);
832
+ return { saved: false, bytes: verdict.violation.receivedBytes, violation: verdict.violation };
833
+ }
834
+ // 4. 没有持久化目标。
835
+ if (persistence === undefined || persistence === null) {
836
+ return { saved: false, bytes: measured, reason: "no_persistence" };
837
+ }
838
+ try {
839
+ const result = persistence.save(payload);
840
+ if (result !== null && typeof result === "object" && typeof result.catch === "function") {
841
+ result.catch(() => {});
842
+ }
843
+ return { saved: true, bytes: null };
844
+ } catch (error) {
845
+ // 持久化是旁路:落盘失败不改变内存状态机,由 Host 自行告警。
846
+ return { saved: false, bytes: null, reason: "save_failed", error };
847
+ }
848
+ }
849
+
850
+ // -------------------------------------------------------------------------
851
+ // 持久化通知合并(同一同步批内只序列化一次)
852
+ // -------------------------------------------------------------------------
853
+
854
+ /**
855
+ * 一个"序列化 tick" = 一次公开写调用 / 一次 drain / 一次异步收敛回调。
856
+ *
857
+ * 为什么需要合并:一次 tick 里可能有多次状态变化(批量派发 N 个 shard、close 收敛
858
+ * 多个任务、cancelParent 结算多个排队 shard),旧实现对每次变化都全量序列化一遍
859
+ * (含全部任务与最多 1000 条事件),高频分片结算的长会话因此出现调度热点。
860
+ * 现在批内只置脏标记,批结束时同步落盘**一次**,批末内容即该批的权威最终状态——
861
+ * 每个被跳过的中间快照都会被更晚的快照取代,因此不丢失任何可观察语义。
862
+ *
863
+ * 语义边界:
864
+ * - 只在同一同步批内合并,**不引入微任务/定时器延迟**:批结束时同步写,所以
865
+ * "状态变化返回后立即读 persistence 记录即可见"的既有可观察行为不变;
866
+ * - 没有状态变化时 flush 是 no-op(终态后的重复取消不会产生多余 save);
867
+ * - 落盘回调若同步重入写入口,重入期间的变化留在同一个 flush 循环里继续收敛,
868
+ * 绝不丢写,也不会无限递归。
869
+ */
870
+ let persistenceBatchDepth = 0;
871
+ let persistenceDirty = false;
872
+ let persistenceFlushing = false;
873
+
874
+ /** 落盘"合并后"的当前状态;没有待落盘变化时是 no-op。 */
875
+ function flushPersistence() {
876
+ if (!persistenceDirty || persistenceFlushing) return;
877
+ persistenceFlushing = true;
878
+ try {
879
+ // while 而非 if:落盘回调可能同步重入(例如 save 里再 submit/close),
880
+ // 重入只会重新置脏,由同一轮 flush 收敛。
881
+ while (persistenceDirty) {
882
+ persistenceDirty = false;
883
+ persistSnapshot();
884
+ }
885
+ } finally {
886
+ persistenceFlushing = false;
887
+ }
888
+ }
889
+
890
+ /**
891
+ * 把一段同步状态变化过程包成一个持久化批。
892
+ *
893
+ * 嵌套调用共享最外层批(例如订阅者在 running 事件里重入 close/submit),因此不会在
894
+ * 中间态产生额外写;work 抛错时同样在 finally 落盘,避免已发生的状态变化被吞掉。
895
+ */
896
+ function withPersistenceBatch(work) {
897
+ persistenceBatchDepth += 1;
898
+ try {
899
+ return work();
900
+ } finally {
901
+ persistenceBatchDepth -= 1;
902
+ if (persistenceBatchDepth === 0) flushPersistence();
903
+ }
904
+ }
905
+
906
+ /** 每次状态变化后落盘一份深拷贝快照;持久化失败不能中断调度。 */
907
+ function notifyPersistence() {
908
+ if (persistence === undefined || persistence === null) return;
909
+ persistenceDirty = true;
910
+ // 批内合并,由批结束时统一落盘;批外(防御性路径)立即落盘,保持旧行为。
911
+ if (persistenceBatchDepth > 0) return;
912
+ flushPersistence();
913
+ }
914
+
915
+ // -------------------------------------------------------------------------
916
+ // 状态派生
917
+ // -------------------------------------------------------------------------
918
+
919
+ function computeCounts(task) {
920
+ const counts = {};
921
+ for (const key of SHARD_STATUS_KEYS) counts[key] = 0;
922
+ for (const shard of task.shards) {
923
+ const status = shard.status ?? "queued";
924
+ counts[status] = (counts[status] ?? 0) + 1;
925
+ }
926
+ // pending 是「尚未终态」的派生计数,方便调用方直接读聚合进度。
927
+ counts.pending = counts.queued + counts.running;
928
+ return counts;
929
+ }
930
+
931
+ function activeShard(shard) {
932
+ return shard.attempts.find((attempt) => attempt.status === "running");
933
+ }
934
+
935
+ /** 记录一次动态上限的非法返回(只读诊断:调度仍然按默认值安全降级)。 */
936
+ function noteInvalidDynamicLimit(kind, value) {
937
+ invalidLimitDiagnostic = {
938
+ kind,
939
+ valueType: value === null ? "null" : typeof value,
940
+ message: kind === "concurrencyLimit"
941
+ ? "settings 解析出的并发上限不是 1~" + MAX_CONCURRENCY_LIMIT + " 的整数,已回退默认值 " + DEFAULT_CONCURRENCY_LIMIT
942
+ : "settings 解析出的 roleLimits 不是 role -> limit 映射,已忽略全部角色级覆盖",
943
+ };
944
+ }
945
+
946
+ function resolveDefaultConcurrencyLimit() {
947
+ if (resolverSource !== "live" || typeof rawConcurrencyLimit !== "function") return defaultConcurrencyLimit;
948
+ let raw;
949
+ try {
950
+ raw = rawConcurrencyLimit();
951
+ } catch {
952
+ // 解析函数读的是外部设置状态:异常只允许降级,不能让调度抛错。
953
+ noteInvalidDynamicLimit("concurrencyLimit", undefined);
954
+ return DEFAULT_CONCURRENCY_LIMIT;
955
+ }
956
+ const check = validateConcurrencyLimit(raw);
957
+ if (check.ok) return check.value;
958
+ noteInvalidDynamicLimit("concurrencyLimit", raw);
959
+ return DEFAULT_CONCURRENCY_LIMIT;
960
+ }
961
+
962
+ /**
963
+ * 解析 role 级覆盖。
964
+ *
965
+ * 静态映射在构造/restore 时已完成校验(非法配置直接抛错)。函数形式读取的是
966
+ * "当前设置",因此:
967
+ * - 每次调用都读一次最新值——设置可能在两次提交之间变化,缓存会把新设置吞掉;
968
+ * - 调度热路径对每个父任务每轮只解析一次,任务级显式限制则完全不走这里;
969
+ * - 返回结构非法时整体忽略角色级覆盖,单个角色的非法值只回退该 role。
970
+ */
971
+ function resolveRoleLimits() {
972
+ if (typeof roleLimits !== "function") return roleLimits;
973
+ let raw;
974
+ try {
975
+ raw = roleLimits();
976
+ } catch {
977
+ noteInvalidDynamicLimit("roleLimits", undefined);
978
+ return Object.freeze({});
979
+ }
980
+ if (raw === undefined || raw === null) return Object.freeze({});
981
+ if (typeof raw !== "object" || Array.isArray(raw)) {
982
+ noteInvalidDynamicLimit("roleLimits", raw);
983
+ return Object.freeze({});
984
+ }
985
+ const normalized = {};
986
+ for (const [role, limit] of Object.entries(raw)) {
987
+ const check = validateConcurrencyLimit(limit);
988
+ if (check.ok) normalized[role] = limit;
989
+ }
990
+ return Object.freeze(normalized);
991
+ }
992
+
993
+ /**
994
+ * 快照里对外暴露的单个任务"有效并发上限",仅供展示与契约稳定使用。
995
+ *
996
+ * 三级优先级:任务级显式 concurrencyLimit(submit 入参,或快照恢复出来的任务级值)
997
+ * > 角色级设置(roleLimits,即该角色的共享并发池,按当前设置解析,可增可减)
998
+ * > 动态/静态默认值。恢复任务在 restore 时会带上快照里的任务级限制,因此重启不会改变
999
+ * 显式限制的语义。
1000
+ *
1001
+ * 注意:调度判定**不能**直接使用本函数。它给出的单值只适用于"某 role 只有一个父任务"
1002
+ * 的场景;多父任务并存时任务级上限与 role 共享池必须分别判定(见 rolePoolLimitFor /
1003
+ * taskLimitFor 与 dispatchAvailable),否则低上限任务会被别人的 role 占用饿死。
1004
+ */
1005
+ function roleLimitFor(task) {
1006
+ if (typeof task.concurrencyLimit === "number") return task.concurrencyLimit;
1007
+ const configured = resolveRoleLimits()[task.role];
1008
+ if (typeof configured === "number") return configured;
1009
+ return resolveDefaultConcurrencyLimit();
1010
+ }
1011
+
1012
+ /**
1013
+ * role 共享池上限:该 role 下**所有**父任务合计允许的 running 分片数。
1014
+ *
1015
+ * 只看 role 级设置与默认值,**不读 task.concurrencyLimit**——任务级显式上限是"这个
1016
+ * 父任务最多跑几个"的独立约束,不能把它当成整个 role 的池子大小,否则一个任务级 64
1017
+ * 的任务会把同一 role 里别的父任务挤出去。
1018
+ */
1019
+ function rolePoolLimitFor(task) {
1020
+ const configured = resolveRoleLimits()[task.role];
1021
+ if (typeof configured === "number") return configured;
1022
+ return resolveDefaultConcurrencyLimit();
1023
+ }
1024
+
1025
+ /**
1026
+ * 任务级上限:显式 concurrencyLimit 才有值;未显式给出时返回 undefined。
1027
+ *
1028
+ * 返回 undefined 而不是回退默认值,是因为"未设置任务级上限"的语义是**这一层不设限**,
1029
+ * 由 role 共享池单独兜底;若这里回退成默认值,一个未设限的任务在同一 role 里就再也
1030
+ * 拿不到超过 10 个槽位(即便 role 池允许更多),与工具描述不符。
1031
+ */
1032
+ function taskLimitFor(task) {
1033
+ return typeof task.concurrencyLimit === "number" ? task.concurrencyLimit : undefined;
1034
+ }
1035
+
1036
+ /** 只读并发视图:当前有效默认值与各 role 覆盖(诊断用,不暴露任务负载)。 */
1037
+ function concurrencyLimits() {
1038
+ return Object.freeze({
1039
+ default: resolveDefaultConcurrencyLimit(),
1040
+ roles: Object.freeze({ ...resolveRoleLimits() }),
1041
+ source: resolverSource,
1042
+ ...(invalidLimitDiagnostic === null ? {} : { invalid: Object.freeze({ ...invalidLimitDiagnostic }) }),
1043
+ });
1044
+ }
1045
+
1046
+ /**
1047
+ * 同步一个 shard 的 running 归属:role 共享池计数与父任务自身计数一起增减。
1048
+ *
1049
+ * 两个计数必须同源同点更新(唯一调用点是 setShardStatus):只要有一处漏改,派发判定
1050
+ * 就会读到漂移值,出现超发或永久饿死。计数归零时删除条目,避免空条目长期驻留。
1051
+ */
1052
+ function addRunningCount(task, delta) {
1053
+ const role = task.role;
1054
+ const nextRoleCount = (runningCountsByRole.get(role) ?? 0) + delta;
1055
+ if (nextRoleCount <= 0) runningCountsByRole.delete(role);
1056
+ else runningCountsByRole.set(role, nextRoleCount);
1057
+ const nextTaskCount = (runningCountsByTask.get(task) ?? 0) + delta;
1058
+ if (nextTaskCount <= 0) runningCountsByTask.delete(task);
1059
+ else runningCountsByTask.set(task, nextTaskCount);
1060
+ }
1061
+
1062
+ function addTaskToRoleIndex(task, roleIndex) {
1063
+ let parents = parentsByRole.get(task.role);
1064
+ if (parents === undefined) {
1065
+ parents = [];
1066
+ parentsByRole.set(task.role, parents);
1067
+ }
1068
+ if (Number.isInteger(roleIndex) && roleIndex >= 0 && roleIndex <= parents.length) parents.splice(roleIndex, 0, task);
1069
+ else parents.push(task);
1070
+ }
1071
+
1072
+ function removeActiveShardIndex(shard) {
1073
+ const previous = activeShardEntries.get(shard);
1074
+ if (previous === undefined) return;
1075
+ for (const path of previous.affectedPaths) {
1076
+ const shards = activeShardsByPath.get(path);
1077
+ if (shards === undefined) continue;
1078
+ shards.delete(shard);
1079
+ if (shards.size === 0) activeShardsByPath.delete(path);
1080
+ }
1081
+ activeShardEntries.delete(shard);
1082
+ }
1083
+
1084
+ /**
1085
+ * 同步单个 shard 的活动视图、path index 与 role running 计数。
1086
+ * 所有状态转移都经由 setShardStatus 进入,避免取消/超时/迟到完成漏掉索引更新。
1087
+ */
1088
+ function syncActiveShardIndex(task, shard) {
1089
+ const previous = activeShardEntries.get(shard);
1090
+ if (previous !== undefined) {
1091
+ for (const path of previous.affectedPaths) {
1092
+ const shards = activeShardsByPath.get(path);
1093
+ if (shards === undefined) continue;
1094
+ shards.delete(shard);
1095
+ if (shards.size === 0) activeShardsByPath.delete(path);
1096
+ }
1097
+ }
1098
+ if (isTerminalShardStatus(shard.status)) {
1099
+ activeShardEntries.delete(shard);
1100
+ return;
1101
+ }
1102
+ const attempt = shard.attempts[shard.attempts.length - 1];
1103
+ const affectedPaths = Array.isArray(attempt?.affectedPaths) ? [...attempt.affectedPaths] : [];
1104
+ const entry = {
1105
+ parentTaskId: task.parentTaskId,
1106
+ role: task.role,
1107
+ shardId: shard.shardId,
1108
+ attempt: attempt?.attempt ?? 1,
1109
+ status: shard.status ?? "queued",
1110
+ affectedPaths,
1111
+ order: previous?.order ?? nextActiveOrder++,
1112
+ };
1113
+ activeShardEntries.set(shard, entry);
1114
+ for (const path of affectedPaths) {
1115
+ let shards = activeShardsByPath.get(path);
1116
+ if (shards === undefined) {
1117
+ shards = new Set();
1118
+ activeShardsByPath.set(path, shards);
1119
+ }
1120
+ shards.add(shard);
1121
+ }
1122
+ }
1123
+
1124
+ function setShardStatus(task, shard, status) {
1125
+ const previous = shard.status;
1126
+ if (previous !== status) {
1127
+ if (previous === "running") addRunningCount(task, -1);
1128
+ if (status === "running") addRunningCount(task, 1);
1129
+ }
1130
+ shard.status = status;
1131
+ syncActiveShardIndex(task, shard);
1132
+ }
1133
+
1134
+ function indexTask(task, roleIndex) {
1135
+ addTaskToRoleIndex(task, roleIndex);
1136
+ for (const shard of task.shards) {
1137
+ if (shard.status === "running") addRunningCount(task, 1);
1138
+ syncActiveShardIndex(task, shard);
1139
+ }
1140
+ }
1141
+
1142
+ function removeTaskIndex(task) {
1143
+ const parents = parentsByRole.get(task.role);
1144
+ const roleIndex = parents?.indexOf(task) ?? -1;
1145
+ if (roleIndex >= 0) {
1146
+ parents.splice(roleIndex, 1);
1147
+ if (parents.length === 0) parentsByRole.delete(task.role);
1148
+ }
1149
+ for (const shard of task.shards) {
1150
+ if (shard.status === "running") addRunningCount(task, -1);
1151
+ removeActiveShardIndex(shard);
1152
+ }
1153
+ // 任务级计数以任务对象身份为 key,删除索引后必须回收该条目,否则被销毁的父子任务
1154
+ // 会以"零 running 的空条目"形式滞留在 Map 里直到 close。
1155
+ runningCountsByTask.delete(task);
1156
+ return roleIndex >= 0 ? roleIndex : undefined;
1157
+ }
1158
+
1159
+ function rebuildIndexes() {
1160
+ parentsByRole.clear();
1161
+ runningCountsByRole.clear();
1162
+ runningCountsByTask.clear();
1163
+ activeShardEntries.clear();
1164
+ activeShardsByPath.clear();
1165
+ nextActiveOrder = 0;
1166
+ // continuation requestId 的跨任务索引同样从任务本体(持久化真相源)重建:
1167
+ // restore 后旧 requestId 仍然可查(不会因为进程重启或索引淘汰而丢失幂等性)。
1168
+ continuationRequestOwners.clear();
1169
+ for (const task of tasks.values()) {
1170
+ indexTask(task);
1171
+ for (const requestId of Object.keys(task.continuationRequests ?? {})) {
1172
+ continuationRequestOwners.set(requestId, task.parentTaskId);
1173
+ }
1174
+ }
1175
+ }
1176
+
1177
+ /** 单任务已记录的 continuation requestId 数量(只读诊断/预算判定)。 */
1178
+ function continuationRequestCount(task) {
1179
+ return task?.continuationRequests === undefined || task.continuationRequests === null
1180
+ ? 0
1181
+ : Object.keys(task.continuationRequests).length;
1182
+ }
1183
+
1184
+ /**
1185
+ * 单任务 continuation requestId 记录预算。
1186
+ *
1187
+ * 与 attempt 预算同源:每成功续作一轮至少消耗一个 attempt(`maxAttemptsPerShard` 封顶),
1188
+ * 因此「记录数 ≥ 该上限」在正常路径上不可能先于 attempt 预算触发;这里保留显式判定是
1189
+ * 为了给恢复外部快照/未来改动一个**结构化拒绝**出口——绝不允许用淘汰旧键的方式腾空间。
1190
+ */
1191
+ function continuationRequestBudgetExceeded(task) {
1192
+ return continuationRequestCount(task) >= budgetPolicy.maxAttemptsPerShard;
1193
+ }
1194
+
1195
+ function activeShardView(entry) {
1196
+ return {
1197
+ parentTaskId: entry.parentTaskId,
1198
+ role: entry.role,
1199
+ shardId: entry.shardId,
1200
+ attempt: entry.attempt,
1201
+ status: entry.status,
1202
+ ...(entry.affectedPaths.length === 0 ? {} : { affectedPaths: [...entry.affectedPaths] }),
1203
+ };
1204
+ }
1205
+
1206
+ /** 取当前所有活动(queued/running)shard 的公共视图,用于冲突判定。 */
1207
+ function collectActiveShards(excludeShard) {
1208
+ const active = [];
1209
+ for (const [shard, entry] of activeShardEntries) {
1210
+ if (shard === excludeShard) continue;
1211
+ active.push(activeShardView(entry));
1212
+ }
1213
+ return active;
1214
+ }
1215
+
1216
+ /**
1217
+ * 内置 checker 只按精确 affectedPaths 发生候选收敛;注入 checker 不能走这个过滤,
1218
+ * 因为 Host 可能使用目录/规范化/角色等额外冲突语义。
1219
+ */
1220
+ function collectConflictCandidates(role, shard, excludeShard) {
1221
+ if (!usesDefaultConflictChecker) return collectActiveShards(excludeShard);
1222
+ if (!writeRoles.includes(role)) return [];
1223
+ const paths = Array.isArray(shard?.affectedPaths) ? shard.affectedPaths : [];
1224
+ if (paths.length === 0) return [];
1225
+ const candidates = new Set();
1226
+ for (const path of paths) {
1227
+ for (const candidate of activeShardsByPath.get(path) ?? []) {
1228
+ if (candidate !== excludeShard) candidates.add(candidate);
1229
+ }
1230
+ }
1231
+ return [...candidates]
1232
+ .map((candidate) => activeShardEntries.get(candidate))
1233
+ .filter((entry) => entry !== undefined)
1234
+ .sort((left, right) => left.order - right.order)
1235
+ .map(activeShardView);
1236
+ }
1237
+
1238
+ function filterDefaultConflictCandidates(shard, otherShards) {
1239
+ const paths = new Set(Array.isArray(shard?.affectedPaths) ? shard.affectedPaths : []);
1240
+ if (paths.size === 0) return [];
1241
+ return otherShards.filter((other) => (other.affectedPaths ?? []).some((path) => paths.has(path)));
1242
+ }
1243
+
1244
+ /** 对候选 shard 调用注入的冲突检查;返回 null 表示无冲突。 */
1245
+ function findConflict(role, shard, otherShards) {
1246
+ const candidates = usesDefaultConflictChecker
1247
+ ? filterDefaultConflictCandidates(shard, otherShards)
1248
+ : otherShards;
1249
+ if (typeof conflictObserver === "function") {
1250
+ try {
1251
+ conflictObserver({ role, shard, otherShards: candidates });
1252
+ } catch {
1253
+ // 观测器是旁路,不能把诊断代码异常变成调度失败。
1254
+ }
1255
+ }
1256
+ if (candidates.length === 0) return null;
1257
+ const verdict = normalizeConflict(conflictChecker({ role, shard, otherShards: candidates }));
1258
+ if (!verdict.conflict) return null;
1259
+ return {
1260
+ ...(verdict.shardIds === undefined ? {} : { shardIds: verdict.shardIds }),
1261
+ ...(verdict.paths === undefined ? {} : { paths: verdict.paths }),
1262
+ ...(shard.shardId === undefined ? {} : { shardId: shard.shardId }),
1263
+ role,
1264
+ };
1265
+ }
1266
+
1267
+ // -------------------------------------------------------------------------
1268
+ // 快照序列化(内部与公共共用)
1269
+ // -------------------------------------------------------------------------
1270
+
1271
+ function shardSnapshot(task, shard) {
1272
+ return {
1273
+ shardId: shard.shardId,
1274
+ // 可选任务名称:Expert Group 用它作为分片第一行的任务名,缺失时由投影侧回退 shardId。
1275
+ ...(shard.name === undefined ? {} : { name: shard.name }),
1276
+ prompt: shard.prompt,
1277
+ status: shard.status,
1278
+ attemptCount: shard.attempts.length,
1279
+ createdAt: shard.createdAt,
1280
+ updatedAt: shard.updatedAt,
1281
+ affectedPaths: [...shard.affectedPaths],
1282
+ ...(shard.cancelRequested === true ? { cancelRequested: true } : {}),
1283
+ ...(shard.timeoutMs === undefined ? {} : { timeoutMs: shard.timeoutMs }),
1284
+ attempts: shard.attempts.map((attempt) => ({
1285
+ attempt: attempt.attempt,
1286
+ status: attempt.status,
1287
+ startedAt: attempt.startedAt ?? null,
1288
+ finishedAt: attempt.finishedAt ?? null,
1289
+ affectedPaths: [...attempt.affectedPaths],
1290
+ ...(attempt.cancelRequested === true ? { cancelRequested: true } : {}),
1291
+ ...(attempt.timeoutMs === undefined ? {} : { timeoutMs: attempt.timeoutMs }),
1292
+ ...(attempt.childId === undefined ? {} : { childId: attempt.childId }),
1293
+ ...(attempt.messageId === undefined ? {} : { messageId: attempt.messageId }),
1294
+ ...(attempt.result === undefined ? {} : { result: attempt.result }),
1295
+ ...(attempt.error === undefined ? {} : { error: attempt.error }),
1296
+ })),
1297
+ };
1298
+ }
1299
+
1300
+ function taskSnapshot(task) {
1301
+ const counts = computeCounts(task);
1302
+ const shards = task.shards.map((shard) => shardSnapshot(task, shard));
1303
+ return {
1304
+ parentTaskId: task.parentTaskId,
1305
+ ...(task.requestId === undefined ? {} : { requestId: task.requestId }),
1306
+ role: task.role,
1307
+ basePrompt: task.basePrompt,
1308
+ status: task.status,
1309
+ // 逻辑轮次与「当前是否可复用续作」:工具回执与侧边栏投影都读这两个字段,
1310
+ // 不需要自己遍历 attempt 推断。
1311
+ round: Number.isSafeInteger(task.round) && task.round >= 1 ? task.round : 1,
1312
+ continuable: !closed && task.parentCancelRequested !== true && allShardsTerminal(task),
1313
+ // continuation 幂等记录(requestId -> 本轮结果):随快照持久化,冷恢复后仍可查,
1314
+ // 消费方据此确认「同一个 requestId 曾经续作过哪一轮」。
1315
+ ...(continuationRequestCount(task) === 0 ? {} : { continuationRequests: cloneJson(task.continuationRequests) }),
1316
+ allowOverlap: task.allowOverlap,
1317
+ concurrencyLimit: roleLimitFor(task),
1318
+ createdAt: task.createdAt,
1319
+ updatedAt: task.updatedAt,
1320
+ settledAt: task.settledAt ?? null,
1321
+ parentCancelRequested: task.parentCancelRequested === true,
1322
+ counts,
1323
+ shards,
1324
+ // 结果按输入顺序排列,与 shards 同序;这是 wait-all 的对外主视图。
1325
+ results: shards.map((shard, index) => ({
1326
+ shardId: shard.shardId,
1327
+ status: shard.status,
1328
+ attempts: shard.attempts.length,
1329
+ ...(shard.attempts[shard.attempts.length - 1]?.result === undefined
1330
+ ? {}
1331
+ : { result: shard.attempts[shard.attempts.length - 1].result }),
1332
+ ...(shard.attempts[shard.attempts.length - 1]?.error === undefined
1333
+ ? {}
1334
+ : { error: shard.attempts[shard.attempts.length - 1].error }),
1335
+ index,
1336
+ })),
1337
+ };
1338
+ }
1339
+
1340
+ /**
1341
+ * 构建可序列化快照的**内部投影**:只含逻辑状态,不含 timer/promise/child 句柄。
1342
+ *
1343
+ * 返回值仍然引用内部状态(result/error/配置对象都是原引用),因此绝不能直接交给
1344
+ * 外部:所有对外出口(snapshot() / 持久化)都经过 serializeSnapshotEnvelope() 取
1345
+ * 深拷贝。本函数只是把"快照应该长什么样"收敛到唯一一处,避免内容投影出现第二实现。
1346
+ */
1347
+ function buildSnapshotPayload() {
1348
+ return {
1349
+ version: SNAPSHOT_VERSION,
1350
+ sequence,
1351
+ nextTaskNumber,
1352
+ defaultConcurrencyLimit,
1353
+ roleLimits,
1354
+ writeRoles,
1355
+ defaultAllowOverlap,
1356
+ parentRotation: Object.fromEntries(rotationCursors),
1357
+ events: eventHistory.map((event) => cloneJson(event)),
1358
+ tasks: [...tasks.values()].map((task) => ({
1359
+ parentTaskId: task.parentTaskId,
1360
+ ...(task.requestId === undefined ? {} : { requestId: task.requestId }),
1361
+ role: task.role,
1362
+ basePrompt: task.basePrompt,
1363
+ status: task.status,
1364
+ // 逻辑轮次持久化:冷恢复后仍能区分「第几轮复用」并继续递增。
1365
+ round: Number.isSafeInteger(task.round) && task.round >= 1 ? task.round : 1,
1366
+ // continuation 幂等记录持久化:重启后同一 requestId 仍然 duplicate=true。
1367
+ ...(continuationRequestCount(task) === 0 ? {} : { continuationRequests: cloneJson(task.continuationRequests) }),
1368
+ allowOverlap: task.allowOverlap,
1369
+ ...(task.concurrencyLimit === undefined ? {} : { concurrencyLimit: task.concurrencyLimit }),
1370
+ createdAt: task.createdAt,
1371
+ updatedAt: task.updatedAt,
1372
+ settledAt: task.settledAt ?? null,
1373
+ parentCancelRequested: task.parentCancelRequested === true,
1374
+ shards: task.shards.map((shard) => ({
1375
+ shardId: shard.shardId,
1376
+ ...(shard.name === undefined ? {} : { name: shard.name }),
1377
+ prompt: shard.prompt,
1378
+ status: shard.status,
1379
+ createdAt: shard.createdAt,
1380
+ updatedAt: shard.updatedAt,
1381
+ ...(shard.cancelRequested === true ? { cancelRequested: true } : {}),
1382
+ attempts: shard.attempts.map((attempt) => ({
1383
+ attempt: attempt.attempt,
1384
+ status: attempt.status,
1385
+ ...(attempt.startedAt === undefined ? {} : { startedAt: attempt.startedAt }),
1386
+ ...(attempt.finishedAt === undefined ? {} : { finishedAt: attempt.finishedAt }),
1387
+ affectedPaths: [...attempt.affectedPaths],
1388
+ ...(attempt.cancelRequested === true ? { cancelRequested: true } : {}),
1389
+ ...(attempt.timeoutMs === undefined ? {} : { timeoutMs: attempt.timeoutMs }),
1390
+ ...(attempt.childId === undefined ? {} : { childId: attempt.childId }),
1391
+ ...(attempt.messageId === undefined ? {} : { messageId: attempt.messageId }),
1392
+ ...(attempt.result === undefined ? {} : { result: attempt.result }),
1393
+ ...(attempt.error === undefined ? {} : { error: attempt.error }),
1394
+ })),
1395
+ })),
1396
+ })),
1397
+ };
1398
+ }
1399
+
1400
+ /**
1401
+ * 单次序列化快照:**同一份 JSON 文本**同时产出内容与体积。
1402
+ *
1403
+ * - payload:由该文本 parse 出的深拷贝,与内部状态完全隔离(与旧 cloneJson 语义
1404
+ * 一致:丢弃 undefined 字段、断开一切引用),调用方可自由修改;
1405
+ * - bytes:同一份文本的 UTF-8 字节数,供体积闸门直接使用。
1406
+ *
1407
+ * 因此"内容"与"体积测量"同源,不再出现旧实现里「cloneJson 全量克隆 + measureJsonBytes
1408
+ * 再 stringify + 闸门内又一次 stringify」的三次全量序列化(审核确认的调度热点),
1409
+ * 也不会出现内容与体积来自两次不同序列化的第二事实源。
1410
+ *
1411
+ * 永不抛异常:正常数据走完整投影;一旦遇到不可 JSON 序列化的负载(循环引用 /
1412
+ * BigInt / 抛错 getter,可能来自外部完成句柄),改为记录 budget 违规并返回
1413
+ * 只含存活终态的最小安全快照(bytes=null 表示不可测量)——绝不把未处理异常抛给
1414
+ * persistence / RPC 路径。
1415
+ *
1416
+ * @returns {{payload:object, bytes:number|null, degraded:boolean}}
1417
+ */
1418
+ function serializeSnapshotEnvelope() {
1419
+ try {
1420
+ const text = JSON.stringify(buildSnapshotPayload());
1421
+ return { payload: JSON.parse(text), bytes: Buffer.byteLength(text, "utf8"), degraded: false };
1422
+ } catch (error) {
1423
+ const violation = describeBudgetViolation({
1424
+ resource: "snapshot",
1425
+ limit: budgetPolicy.maxSnapshotBytes,
1426
+ receivedBytes: null,
1427
+ measured: false,
1428
+ recoverable: false,
1429
+ subject: "serialize",
1430
+ hint: "快照含不可 JSON 序列化的负载,已退化为最小安全快照",
1431
+ });
1432
+ noteBudgetViolation(violation);
1433
+ warn("dsh-expert-group: fan-out snapshot is not JSON-serializable, degraded to minimal snapshot");
1434
+ return {
1435
+ payload: {
1436
+ version: SNAPSHOT_VERSION,
1437
+ sequence,
1438
+ nextTaskNumber,
1439
+ degraded: true,
1440
+ violation,
1441
+ tasks: [],
1442
+ events: [],
1443
+ },
1444
+ bytes: null,
1445
+ degraded: true,
1446
+ };
1447
+ }
1448
+ }
1449
+
1450
+ /**
1451
+ * 可序列化快照(公开出口):返回与内部状态隔离的深拷贝,永不抛异常。
1452
+ *
1453
+ * 与落盘路径共用同一个 envelope:内容与体积来自同一次 stringify,degraded 语义也不
1454
+ * 会有第二套实现。读路径虽然用不到 bytes,代价只是一次 O(文本长度) 的字节统计,
1455
+ * 远小于 stringify/parse 本身。
1456
+ */
1457
+ function serializeSnapshot() {
1458
+ return serializeSnapshotEnvelope().payload;
1459
+ }
1460
+
1461
+ // -------------------------------------------------------------------------
1462
+ // 调度器:per-role 限流 + 父任务轮转
1463
+ // -------------------------------------------------------------------------
1464
+
1465
+ function activeCountsByRole() {
1466
+ // running 计数在状态转移处同步维护;返回副本避免 dispatch 修改派生索引。
1467
+ return new Map(runningCountsByRole);
1468
+ }
1469
+
1470
+ function activeCountsByTask() {
1471
+ // 同上:任务级计数同样是派生索引,dispatch 只在副本上做本轮预算。
1472
+ return new Map(runningCountsByTask);
1473
+ }
1474
+
1475
+ /**
1476
+ * 派发一轮:每个 role 在「父任务轮转顺序」上取下一个可派发 shard。
1477
+ * 轮转游标按 role 维护,保证后来的父任务不会因为前面有大量排队 shard 而饿死。
1478
+ *
1479
+ * 并发判定是两层独立约束(见模块头设计要点 2):role 共享池上限约束整个 role,
1480
+ * 任务级上限只约束当前候选父任务自身,两者必须同时满足才允许派发。
1481
+ */
1482
+ function dispatchAvailable() {
1483
+ if (closed || restoreDispatchSuppressed) return false;
1484
+ const roleActiveCounts = activeCountsByRole();
1485
+ const taskActiveCounts = activeCountsByTask();
1486
+ // parentsByRole 保持首次注册 role 的顺序,避免每轮遍历整个 tasks 表。
1487
+ const roles = [...parentsByRole.keys()];
1488
+
1489
+ let dispatched = false;
1490
+ for (const role of roles) {
1491
+ // 本轮使用索引快照,避免 running 事件重入 submit 改变本轮公平序列。
1492
+ const parents = [...(parentsByRole.get(role) ?? [])];
1493
+ if (parents.length === 0) continue;
1494
+ let cursor = rotationCursors.get(role) ?? 0;
1495
+ if (!Number.isInteger(cursor) || cursor < 0 || cursor >= parents.length) cursor = 0;
1496
+ // 本 role 共享池当前已占用的槽位;每派发一个 shard 就 +1,直到达到池上限。
1497
+ let used = roleActiveCounts.get(role) ?? 0;
1498
+ // role 共享池上限只看 role 级设置/默认值,不读任何任务的 concurrencyLimit,因此
1499
+ // 同一 role 下所有父任务解析出的池上限必然相同:在循环外算一次,避免热路径重复求值。
1500
+ // 池子已满时不能再进入内层循环——否则第一个候选会把 blockedInARow 记成"无法产出",
1501
+ // 看起来像所有父任务都被自身上限卡住,从而提前跳出本 role(语义等价但仍然多做一次
1502
+ // 无效遍历)。直接 break 保持与旧实现完全一致的可观测行为。
1503
+ if (used >= rolePoolLimitFor(parents[0])) continue;
1504
+ // 轮转预算:一圈内每个父任务最多贡献一个 shard,保证公平性;
1505
+ // 若一圈后仍有空槽位(例如某个父任务已无排队 shard),允许再走一圈补齐,
1506
+ // 这样并发上限才真正被填满,而不是每轮只启动一个。
1507
+ let budget = parents.length;
1508
+ // 连续遇到「本轮无法产出」的父任务计数。只有轮转一整圈都没有任何产出才停止:
1509
+ // 某个父任务被**自身任务级上限**卡住时仍有其它父任务可以派发,因此它只能算
1510
+ // "本轮无产出",不能直接终止整个 role 的派发(否则高上限的任务会被低上限的
1511
+ // 邻居饿死)。无排队 shard / 冲突判定失败同样属于"本轮无产出"。
1512
+ let blockedInARow = 0;
1513
+ while (budget > 0) {
1514
+ const task = parents[cursor % parents.length];
1515
+ cursor = (cursor + 1) % parents.length;
1516
+ budget -= 1;
1517
+ // 任务级上限只与**该父任务自身**的 running 数比较:其它父任务占用 role 槽位
1518
+ // 不再影响它(旧实现拿 role 全局计数比较,导致低上限任务被别的任务饿死)。
1519
+ const taskLimit = taskLimitFor(task);
1520
+ if (taskLimit !== undefined && (taskActiveCounts.get(task) ?? 0) >= taskLimit) {
1521
+ blockedInARow += 1;
1522
+ if (blockedInARow >= parents.length) break;
1523
+ continue;
1524
+ }
1525
+ const shard = task.shards.find((entry) => entry.status === "queued" && (
1526
+ entry.cancelRequested !== true
1527
+ || entry.attempts[entry.attempts.length - 1]?.attempt > 1
1528
+ ));
1529
+ if (shard === undefined) {
1530
+ // 该父任务已无排队 shard:本轮没有产出,但它的存在不应顺手终结整个 role。
1531
+ blockedInARow += 1;
1532
+ if (blockedInARow >= parents.length) break;
1533
+ continue;
1534
+ }
1535
+ // retryShard 会重新进入队列;重新 admission 前复用同一 conflictChecker,
1536
+ // 只阻止当前候选与其它活动 shard 的明显冲突,不引入路径解析语义。
1537
+ const otherShards = collectConflictCandidates(task.role, shard, shard);
1538
+ const conflict = findConflict(task.role, shard, otherShards);
1539
+ if (conflict !== null && !task.allowOverlap) {
1540
+ blockedInARow += 1;
1541
+ if (blockedInARow >= parents.length) break;
1542
+ continue;
1543
+ }
1544
+ if (startShard(task, shard)) {
1545
+ dispatched = true;
1546
+ used += 1;
1547
+ // 任务级计数必须在本轮内同步自增:入口快照只是基线(避免 running 事件
1548
+ // 重入改变公平序列),但本轮刚派发的 shard 若不立即计入本地计数,同一
1549
+ // 次 drain 的内层循环(budget 重置允许连续派发同一任务)会读到过期值,
1550
+ // 使任务越过自身任务级上限——快照要等下一次 dispatchAvailable 才刷新。
1551
+ taskActiveCounts.set(task, (taskActiveCounts.get(task) ?? 0) + 1);
1552
+ blockedInARow = 0;
1553
+ if (closed) break;
1554
+ // role 共享池在派发过程中可能刚好被填满(例如另一路径同步派发),逐次复核;
1555
+ // 一旦填满就停止本 role 的派发,保证 role 全局 running 不越过池上限。
1556
+ if (used >= rolePoolLimitFor(task)) break;
1557
+ // 有新 shard 进入 running 后,重新评估其它父任务的排队情况。
1558
+ budget = parents.length;
1559
+ }
1560
+ }
1561
+ rotationCursors.set(role, cursor % parents.length);
1562
+ }
1563
+ return dispatched;
1564
+ }
1565
+
1566
+ /**
1567
+ * drain 的持久化批包装。
1568
+ *
1569
+ * 一次 drain 可能在同一个同步批里连续启动多个 shard(每个都发 running 事件并登记
1570
+ * 句柄),也可能同步结算 shard;这些状态变化只允许序列化/落盘一次。drain 自身在
1571
+ * finally 里可能再排下一轮微任务,那时属于新的批(新的 tick 会带来新的变化)。
1572
+ */
1573
+ function drainBatch() {
1574
+ withPersistenceBatch(drain);
1575
+ }
1576
+
1577
+ function drain() {
1578
+ try {
1579
+ // 派发可能同步完成一个 shard,从而释放槽位并产生新的可派发 shard,
1580
+ // 因此循环直到没有新的派发发生为止。
1581
+ while (dispatchAvailable()) {
1582
+ // 循环体为空:dispatchAvailable 内部完成状态推进。
1583
+ }
1584
+ } finally {
1585
+ // 关键:只要 drain 期间又产生了新的调度请求,就再排一次微任务继续派发,并且不
1586
+ // resolve drainPromise。whenIdle() 因此一定等到稳定态,不会让调用方看到中间态。
1587
+ if (workRequested) {
1588
+ workRequested = false;
1589
+ queueMicrotask(drainBatch);
1590
+ } else {
1591
+ drainScheduled = false;
1592
+ const resolve = resolveDrain;
1593
+ resolveDrain = undefined;
1594
+ if (resolve !== undefined) resolve();
1595
+ }
1596
+ }
1597
+ }
1598
+
1599
+ /**
1600
+ * 请求一次调度。
1601
+ * - pending 表示「还有派发工作要做」;
1602
+ * - drainScheduled 表示「已经排好一次 drain 微任务」;
1603
+ * - drainPromise 只在最后一次 drain 结束时 resolve,因此 whenIdle() 一定等到稳定态。
1604
+ */
1605
+ function schedule() {
1606
+ if (closed) return Promise.resolve();
1607
+ restoreDispatchSuppressed = false;
1608
+ workRequested = true;
1609
+ if (!drainScheduled) {
1610
+ drainScheduled = true;
1611
+ // 进入 drain 前先清掉请求标记;drain 期间再次 schedule() 会重新置位,
1612
+ // 由 finally 决定是否再排一轮。
1613
+ workRequested = false;
1614
+ drainPromise = new Promise((resolve) => { resolveDrain = resolve; });
1615
+ queueMicrotask(drainBatch);
1616
+ }
1617
+ return drainPromise;
1618
+ }
1619
+
1620
+ // -------------------------------------------------------------------------
1621
+ // shard 生命周期
1622
+ // -------------------------------------------------------------------------
1623
+
1624
+ /**
1625
+ * 新建一个 attempt。
1626
+ *
1627
+ * @param {object} shard - 所属 shard(继承 affectedPaths/timeoutMs)。
1628
+ * @param {number} attemptNumber - attempt 序号(同一 shard 内单调递增,历史不可覆盖)。
1629
+ * @param {object} [extra] - 追加字段。多轮 continuation 用 `{ continuation: true, childId }`
1630
+ * 标记「复用既有 child」——该标记只存在于内存 attempt 上,**不进快照**:冷恢复后原
1631
+ * child 可能已不存在,恢复派发一律走 launcher 新建 child(与既有 restore 语义一致)。
1632
+ */
1633
+ function createAttempt(shard, attemptNumber, extra = {}) {
1634
+ return {
1635
+ attempt: attemptNumber,
1636
+ status: "queued",
1637
+ startedAt: undefined,
1638
+ finishedAt: undefined,
1639
+ affectedPaths: [...shard.affectedPaths],
1640
+ ...(shard.timeoutMs === undefined ? {} : { timeoutMs: shard.timeoutMs }),
1641
+ ...extra,
1642
+ };
1643
+ }
1644
+
1645
+ function updateShardStatus(task, shard, status) {
1646
+ setShardStatus(task, shard, status);
1647
+ shard.updatedAt = now();
1648
+ task.updatedAt = shard.updatedAt;
1649
+ }
1650
+
1651
+ /**
1652
+ * 某 shard 之前各轮已绑定的 messageId 集合(不含最新 attempt)。
1653
+ *
1654
+ * 只服务 continuation 的严格归属校验:本轮 messageId 必须既非空、也不属于这个集合,
1655
+ * 否则「同一 child 的旧轮 turn/end」可以冒充新一轮并提前结算。上限由 attempt 预算约束
1656
+ * (≤ maxAttemptsPerShard 条),因此不需要额外裁剪。
1657
+ */
1658
+ function previousMessageIdsFor(shard) {
1659
+ const ids = [];
1660
+ for (let index = 0; index < shard.attempts.length - 1; index += 1) {
1661
+ const messageId = shard.attempts[index]?.messageId;
1662
+ if (typeof messageId === "string" && messageId.length > 0) ids.push(messageId);
1663
+ }
1664
+ return ids;
1665
+ }
1666
+
1667
+ /**
1668
+ * shard 终态写入入口。所有路径(启动失败、child 完成、取消、超时、恢复)都收敛到
1669
+ * 这里,保证终态一次性且幂等:已进入终态的 shard 只拒绝并记录诊断,不被改写。
1670
+ */
1671
+ function settleShard(task, shard, attempt, status, { result, error, violation: presetViolation } = {}) {
1672
+ if (!SHARD_STATUS_SET.has(status) || !isTerminalShardStatus(status)) {
1673
+ return rejectSettlement(task, shard, attempt, status, "invalid_status");
1674
+ }
1675
+ // 结果/错误的字节预算:超限必须显式失败并携带完整元数据,绝不写入被截断的内容,
1676
+ // 也不能把超限负载写进 attempt(否则事件、快照都会跟着无界增长)。
1677
+ // 判定发生在状态迁移之前,因此违规时该 shard 走同一条终态路径收敛为 failed。
1678
+ let payloadViolation = presetViolation ?? null;
1679
+ const payloadVerdict = presetViolation === undefined
1680
+ ? checkAttemptPayloadBudget({
1681
+ result,
1682
+ error,
1683
+ subject: task.parentTaskId + "/" + shard.shardId + "#" + attempt.attempt,
1684
+ })
1685
+ : { ok: true };
1686
+ if (!payloadVerdict.ok) {
1687
+ payloadViolation = payloadVerdict.violation;
1688
+ noteBudgetViolation(payloadViolation);
1689
+ status = "failed";
1690
+ result = undefined;
1691
+ error = budgetShardError(payloadViolation);
1692
+ }
1693
+ if (isTerminalShardStatus(attempt.status)) {
1694
+ return payloadViolation === null
1695
+ ? rejectSettlement(task, shard, attempt, status, "already_terminal")
1696
+ : rejectSettlement(task, shard, attempt, status, "attempt_budget_exceeded");
1697
+ }
1698
+ if (attempt.status !== "running" && !(attempt.status === "queued" && status === "cancelled")) {
1699
+ return payloadViolation === null
1700
+ ? rejectSettlement(task, shard, attempt, status, "invalid_transition")
1701
+ : rejectSettlement(task, shard, attempt, status, "attempt_budget_exceeded");
1702
+ }
1703
+ attempt.status = status;
1704
+ attempt.finishedAt = now();
1705
+ if (result !== undefined) attempt.result = result;
1706
+ if (error !== undefined) attempt.error = error;
1707
+ if (attempt.timer !== undefined) {
1708
+ clearTimeoutFn(attempt.timer);
1709
+ attempt.timer = undefined;
1710
+ }
1711
+ // 多轮复用安全边界:同一 shard 可以有多个 attempt(手工 retry / continuation),
1712
+ // shard 的**当前状态**只能由最新 attempt 决定。非最新 attempt 的迟到结算仍然写入它
1713
+ // 自己的 attempt 历史(审计可见)并发布 attempt 级事件,但绝不改写 shard.status,
1714
+ // 否则旧轮迟到结算会覆盖新轮状态,并可能重复发布旧轮 task_settled。
1715
+ const isLatestAttempt = shard.attempts[shard.attempts.length - 1] === attempt;
1716
+ if (isLatestAttempt) setShardStatus(task, shard, status);
1717
+ shard.updatedAt = attempt.finishedAt;
1718
+ task.updatedAt = attempt.finishedAt;
1719
+ emitShardStatus(task, shard, status, attempt.attempt, {
1720
+ ...(isLatestAttempt ? {} : { staleAttempt: true, shardStatus: shard.status }),
1721
+ ...(task.round === undefined ? {} : { round: task.round }),
1722
+ ...(result === undefined ? {} : { result: cloneJson(result) }),
1723
+ ...(error === undefined ? {} : { error: cloneJson(error) }),
1724
+ // 预算违规必须留下可检索的诊断(终态可观察,且不携带超限原始负载)。
1725
+ ...(payloadViolation === null ? {} : { violation: payloadViolation }),
1726
+ });
1727
+ aggregateTask(task);
1728
+ notifyPersistence();
1729
+ schedule();
1730
+ return true;
1731
+ }
1732
+
1733
+ function launchFailure(task, shard, attempt, error) {
1734
+ settleLaunchFailure(task, shard, attempt, error);
1735
+ }
1736
+
1737
+ /**
1738
+ * 启动/admission/child 失败的统一入口。
1739
+ *
1740
+ * 先用未归一化的原始错误判定字节预算:normalizeLaunchError 会丢弃自定义字段
1741
+ * (如 error.detail),若只判定归一化后的结果,超限负载会被静默丢弃而不是显式报告。
1742
+ * 超限时改写成带完整元数据的 BudgetExceededError(complete=false + 实测/上限字节)。
1743
+ */
1744
+ function settleLaunchFailure(task, shard, attempt, error) {
1745
+ const rawVerdict = checkBudget({
1746
+ resource: "attempt_error",
1747
+ limit: budgetPolicy.maxAttemptErrorBytes,
1748
+ receivedBytes: measureJsonBytes(errorInstinctPayload(error)),
1749
+ subject: task.parentTaskId + "/" + shard.shardId + "#" + attempt.attempt,
1750
+ });
1751
+ if (!rawVerdict.ok) {
1752
+ noteBudgetViolation(rawVerdict.violation);
1753
+ settleShard(task, shard, attempt, "failed", { error: budgetShardError(rawVerdict.violation) });
1754
+ return;
1755
+ }
1756
+ settleShard(task, shard, attempt, "failed", { error: normalizeLaunchError(error) });
1757
+ }
1758
+
1759
+ /**
1760
+ * 原始错误的可序列化投影:Error 实例只取 name/message/stack(enumerable 字段也带上),
1761
+ * 用于在归一化之前测量真实体积。
1762
+ */
1763
+ function errorInstinctPayload(error) {
1764
+ if (error instanceof Error) {
1765
+ return { name: error.name, message: error.message, stack: error.stack, ...error };
1766
+ }
1767
+ return error;
1768
+ }
1769
+
1770
+ function requestShardCancellation(task, shard, attempt, reason) {
1771
+ if (canceller === undefined || canceller === null) return;
1772
+ try {
1773
+ const outcome = canceller({
1774
+ parentTaskId: task.parentTaskId,
1775
+ shardId: shard.shardId,
1776
+ attempt: attempt.attempt,
1777
+ ...(attempt.childId === undefined ? {} : { childId: attempt.childId }),
1778
+ reason,
1779
+ handle: attempt.handle,
1780
+ });
1781
+ if (outcome !== null && typeof outcome === "object" && typeof outcome.catch === "function") {
1782
+ outcome.catch(() => {});
1783
+ }
1784
+ } catch {
1785
+ // 取消器异常不能阻断取消状态机:状态仍由 completion/超时收敛。
1786
+ }
1787
+ }
1788
+
1789
+ /**
1790
+ * 启动一个 queued shard。返回 true 表示已经占用一个 running 槽位。
1791
+ * 启动器可以是同步也可以是异步;同步抛错与 Promise reject 都记为 failed,
1792
+ * 且不自动重试。
1793
+ */
1794
+ function startShard(task, shard) {
1795
+ const attempt = shard.attempts[shard.attempts.length - 1];
1796
+ if (attempt === undefined || attempt.status !== "queued") return false;
1797
+ // 多轮 continuation:本轮 attempt 复用上一轮的 child,必须走 continuer(把新 prompt
1798
+ // 投递给同一 child),不能重新走 launcher 新建 child。
1799
+ const isContinuation = attempt.continuation === true;
1800
+ const launch = isContinuation ? continuer : launcher;
1801
+ attempt.status = "running";
1802
+ attempt.startedAt = now();
1803
+ setShardStatus(task, shard, "running");
1804
+ shard.updatedAt = attempt.startedAt;
1805
+ task.updatedAt = attempt.startedAt;
1806
+ emitShardStatus(task, shard, "running", attempt.attempt, {
1807
+ ...(isContinuation ? { continuation: true } : {}),
1808
+ ...(task.round === undefined ? {} : { round: task.round }),
1809
+ });
1810
+ notifyPersistence();
1811
+ // 订阅者可能在 running 事件中重入 close;此时不能再把本次 admission 交给 launcher。
1812
+ if (closed || isTerminalShardStatus(attempt.status)) return true;
1813
+
1814
+ const launchInput = {
1815
+ parentTaskId: task.parentTaskId,
1816
+ role: task.role,
1817
+ basePrompt: task.basePrompt,
1818
+ shardId: shard.shardId,
1819
+ // 名称随派发输入传给 Host:子代理任务标签与提示词都能显示"分片代号 + 任务名称",
1820
+ // 缺失时不出现该字段,保持旧 Host 的输入形状。
1821
+ ...(shard.name === undefined ? {} : { name: shard.name }),
1822
+ prompt: shard.prompt,
1823
+ ...(attempt.affectedPaths.length === 0 ? {} : { affectedPaths: [...attempt.affectedPaths] }),
1824
+ ...(attempt.timeoutMs === undefined ? {} : { timeoutMs: attempt.timeoutMs }),
1825
+ attempt: attempt.attempt,
1826
+ // 逻辑轮次随派发输入传给 Host:续作正文用它标注「这是同一分片的第几轮」。
1827
+ ...(Number.isSafeInteger(task.round) && task.round >= 1 ? { round: task.round } : {}),
1828
+ // 续作必须带上原 childId:manager 只声明「复用哪个 child」,真正的投递由 continuer 完成。
1829
+ ...(isContinuation && typeof attempt.childId === "string" && attempt.childId.length > 0
1830
+ ? { childId: attempt.childId }
1831
+ : {}),
1832
+ /**
1833
+ * 续作还要带上该 shard 之前各轮的 messageId:Host 用它们判定本轮 messageId 是否
1834
+ * 「新」——缺失、与本 child 旧轮重复都必须在启动阶段 fail-closed,否则一个重复的
1835
+ * messageId 会让旧轮 turn/end 直接结算新一轮(提前 task_settled / goal release)。
1836
+ */
1837
+ ...(isContinuation ? { previousMessageIds: previousMessageIdsFor(shard) } : {}),
1838
+ };
1839
+
1840
+ if (attempt.timeoutMs !== undefined) {
1841
+ // 超时通过注入时钟触发取消并标记 timed_out;若在此之前已经终态,
1842
+ // settleShard 会因幂等直接忽略这次超时。
1843
+ // 定时器回调是独立的异步 tick,自成一个持久化批(取消请求 + 终态写入只序列化一次)。
1844
+ attempt.timer = setTimeoutFn(() => withPersistenceBatch(() => {
1845
+ attempt.timer = undefined;
1846
+ requestShardCancellation(task, shard, attempt, "timeout");
1847
+ settleShard(task, shard, attempt, "timed_out", {
1848
+ error: toShardError({ name: "ShardTimeoutError", reason: "timeout", timeoutMs: attempt.timeoutMs }, "shard 超时"),
1849
+ });
1850
+ }), attempt.timeoutMs);
1851
+ }
1852
+
1853
+ let launched;
1854
+ try {
1855
+ // continuation 尝试但 runtime 未接线 continuer 时 fail-closed 为启动失败:绝不静默
1856
+ // 换 child 顶替复用。attempt 此时已是 running,因此走既有启动失败通道收敛为 failed
1857
+ // (历史一致、shard 不会停留在 queued 造成重复派发)。
1858
+ if (typeof launch !== "function") {
1859
+ throw new Error("fan-out continuation 未接线 continuer,拒绝复用既有 child: " + shard.shardId);
1860
+ }
1861
+ launched = launch(launchInput);
1862
+ } catch (error) {
1863
+ launchFailure(task, shard, attempt, error);
1864
+ return true;
1865
+ }
1866
+ if (launched !== null && typeof launched === "object" && typeof launched.then === "function") {
1867
+ // 异步 admission 的每个回调都是独立的异步 tick,各自成批:admission 落地带来的
1868
+ // 状态变化(句柄登记 / 启动失败终态)在该批内只序列化一次。
1869
+ launched.then(
1870
+ (value) => { withPersistenceBatch(() => consumeLaunchResult(task, shard, attempt, value)); },
1871
+ (error) => { withPersistenceBatch(() => launchFailure(task, shard, attempt, error)); },
1872
+ );
1873
+ } else {
1874
+ consumeLaunchResult(task, shard, attempt, launched);
1875
+ }
1876
+ return true;
1877
+ }
1878
+
1879
+ /** 消费启动器返回值:登记句柄元数据,并订阅 completion 决定 shard 终态。 */
1880
+ function consumeLaunchResult(task, shard, attempt, value) {
1881
+ const handle = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
1882
+ if (typeof handle.childId === "string") attempt.childId = handle.childId;
1883
+ if (typeof handle.messageId === "string") attempt.messageId = handle.messageId;
1884
+ attempt.handle = handle;
1885
+ notifyPersistence();
1886
+ // close 可能在 launcher admission 等待期间完成;晚到的 child 仍要收到一次取消请求,
1887
+ // 但不能把已经写入的 cancelled 终态重新变成 running。
1888
+ if (closed || isTerminalShardStatus(attempt.status)) {
1889
+ requestShardCancellation(task, shard, attempt, closed ? "manager_closed" : "already_terminal");
1890
+ }
1891
+
1892
+ const completion = handle.completion !== undefined
1893
+ ? handle.completion
1894
+ : typeof handle.then === "function"
1895
+ ? handle
1896
+ : undefined;
1897
+ if (completion === undefined) {
1898
+ // 启动器只回传句柄:shard 保持 running,等待 cancel/timeout 收敛。
1899
+ return;
1900
+ }
1901
+ // 完成回调是独立的异步 tick:结算(可能连带聚合父任务)在该批内只序列化一次。
1902
+ Promise.resolve(completion).then(
1903
+ (result) => { withPersistenceBatch(() => completeShard(task, shard, attempt, result)); },
1904
+ (error) => { withPersistenceBatch(() => failShard(task, shard, attempt, error)); },
1905
+ );
1906
+ }
1907
+
1908
+ function completeShard(task, shard, attempt, result) {
1909
+ // 取消与完成竞态:取消请求先写入终态则完成被忽略,反之完成胜出。
1910
+ if (attempt.cancelRequested === true) {
1911
+ settleShard(task, shard, attempt, "cancelled", {
1912
+ error: toShardError({ name: "ShardCancelledError", reason: "cancelled", message: "shard 已取消" }, "shard 已取消"),
1913
+ });
1914
+ return;
1915
+ }
1916
+ const rawResult = result === undefined ? null : result;
1917
+ // 不可 JSON 序列化的结果不能被 cloneJson 深拷贝(会抛 TypeError 并逃出 completion
1918
+ // 回调),因此在这里提前转成显式的预算失败;超限但仍可序列化的结果交给
1919
+ // settleShard 判定,以便它照常发出带 violation 的 shard 终态诊断事件。
1920
+ const measured = measureJsonBytes(rawResult);
1921
+ if (measured === null) {
1922
+ const violation = describeBudgetViolation({
1923
+ resource: "attempt_result",
1924
+ limit: budgetPolicy.maxAttemptResultBytes,
1925
+ receivedBytes: null,
1926
+ measured: false,
1927
+ recoverable: true,
1928
+ subject: task.parentTaskId + "/" + shard.shardId + "#" + attempt.attempt,
1929
+ hint: "结果没有 JSON 表示;请让专家只回传可序列化的文本/结构化结果",
1930
+ });
1931
+ noteBudgetViolation(violation);
1932
+ settleShard(task, shard, attempt, "failed", { error: budgetShardError(violation), violation });
1933
+ return;
1934
+ }
1935
+ settleShard(task, shard, attempt, "succeeded", { result: rawResult === null ? null : cloneJson(rawResult) });
1936
+ }
1937
+
1938
+ function failShard(task, shard, attempt, error) {
1939
+ if (attempt.cancelRequested === true && !isTerminalShardStatus(attempt.status)) {
1940
+ settleShard(task, shard, attempt, "cancelled", {
1941
+ error: toShardError({ name: "ShardCancelledError", reason: "cancelled", message: "shard 已取消" }, "shard 已取消"),
1942
+ });
1943
+ return;
1944
+ }
1945
+ settleLaunchFailure(task, shard, attempt, error);
1946
+ }
1947
+
1948
+ // -------------------------------------------------------------------------
1949
+ // wait-all 聚合
1950
+ // -------------------------------------------------------------------------
1951
+
1952
+ function aggregateTask(task) {
1953
+ const counts = computeCounts(task);
1954
+ const allTerminal = counts.pending === 0;
1955
+ const previousStatus = task.status;
1956
+ const wasSettled = task.settledAt !== undefined;
1957
+ let status = task.status;
1958
+ if (!allTerminal) {
1959
+ status = "pending";
1960
+ } else if (counts.succeeded === task.shards.length) {
1961
+ status = "completed";
1962
+ } else if (task.parentCancelRequested === true && counts.succeeded === 0 && counts.failed === 0 && counts.timed_out === 0) {
1963
+ // 父级取消且没有任何 shard 成功或异常:未完成的 shard 都是因为父级取消而终止。
1964
+ status = "cancelled";
1965
+ } else {
1966
+ status = "completed_with_errors";
1967
+ }
1968
+ task.status = status;
1969
+ task.updatedAt = now();
1970
+ if (!allTerminal) {
1971
+ task.settledAt = undefined;
1972
+ return;
1973
+ }
1974
+ task.settledAt = task.updatedAt;
1975
+ // 重复 cancel/aggregate 只刷新快照,不重复发布 task_settled;只有从 pending 进入
1976
+ // 终态,或终态因显式 retry / continuation 后再次结算,才产生新的聚合结算事件。
1977
+ if (restoringSnapshot || (wasSettled && previousStatus === status)) return;
1978
+ emit({
1979
+ type: "task_settled",
1980
+ parentTaskId: task.parentTaskId,
1981
+ role: task.role,
1982
+ status,
1983
+ counts,
1984
+ // 轮次随聚合结算一起发布:多轮复用下消费方据此区分「哪一轮的 wait-all 完成」,
1985
+ // 旧轮迟到结算(非最新 attempt)不会走到这里(settleShard 已拒绝改写 shard 状态)。
1986
+ round: Number.isSafeInteger(task.round) && task.round >= 1 ? task.round : 1,
1987
+ shards: task.shards.map((shard) => ({ shardId: shard.shardId, status: shard.status, attempts: shard.attempts.length })),
1988
+ });
1989
+ }
1990
+
1991
+ /** 是否每个 shard 的最新 attempt 都已终态(= 本轮 wait-all 已完成,可以复用续作)。 */
1992
+ function allShardsTerminal(task) {
1993
+ if (task === undefined || task === null) return false;
1994
+ return task.shards.every((shard) => {
1995
+ const latest = shard.attempts[shard.attempts.length - 1];
1996
+ return latest !== undefined && isTerminalShardStatus(latest.status);
1997
+ });
1998
+ }
1999
+
2000
+ // -------------------------------------------------------------------------
2001
+ // 公共 API
2002
+ // -------------------------------------------------------------------------
2003
+
2004
+ /**
2005
+ * 登记一个父任务并把 shards 按输入顺序放入队列。
2006
+ * 非法输入整体拒绝(不登记任务、不创建任何 child);requestId 重复时返回原句柄。
2007
+ */
2008
+ function submit(input) {
2009
+ if (closed) {
2010
+ return {
2011
+ accepted: false,
2012
+ changed: false,
2013
+ code: "MANAGER_CLOSED",
2014
+ reason: "manager_closed",
2015
+ shardIds: [],
2016
+ };
2017
+ }
2018
+ const verdict = validateSubmitInput(input);
2019
+ if (!verdict.ok) throw validationError(verdict);
2020
+
2021
+ // task 数量预算:快照里的 tasks 数组与内存表都按 task 线性增长,必须有上界。
2022
+ // 判定的是"本候选登记后的数量",因此 +1;requestId 去重命中时提前返回,不占新槽位。
2023
+ const taskCountViolation = budgetVerdict(
2024
+ "tasks",
2025
+ budgetPolicy.maxTasks,
2026
+ tasks.size + 1,
2027
+ "tasks",
2028
+ "先等待或取消已结算的父任务再提交新任务",
2029
+ );
2030
+ if (taskCountViolation !== null) throw validationError(taskCountViolation);
2031
+
2032
+ if (verdict.requestId !== undefined) {
2033
+ const existingId = requestIndex.get(verdict.requestId);
2034
+ if (existingId !== undefined && tasks.has(existingId)) {
2035
+ const existing = tasks.get(existingId);
2036
+ return {
2037
+ parentTaskId: existing.parentTaskId,
2038
+ duplicate: true,
2039
+ role: existing.role,
2040
+ status: existing.status,
2041
+ shardIds: existing.shards.map((shard) => shard.shardId),
2042
+ requestId: verdict.requestId,
2043
+ };
2044
+ }
2045
+ }
2046
+
2047
+ const allowOverlap = verdict.allowOverlap || defaultAllowOverlap;
2048
+ {
2049
+ // 冲突判定是「整体拒绝」的一部分:任何一个 shard 冲突都不登记任务。
2050
+ // allowOverlap 只放行拒绝动作,注入的 checker 仍会被调用,便于 Host 观测重叠。
2051
+ const otherShards = collectActiveShards();
2052
+ const withinSubmit = [];
2053
+ for (const shard of verdict.shards) {
2054
+ const conflict = findConflict(verdict.role, shard, [...otherShards, ...withinSubmit]);
2055
+ if (conflict !== null && !allowOverlap) {
2056
+ throw new FanoutValidationError(
2057
+ "AFFECTED_PATHS_CONFLICT",
2058
+ "shard " + shard.shardId + " 的 affectedPaths 与活动任务冲突",
2059
+ { field: "shards[" + verdict.shards.indexOf(shard) + "].affectedPaths", conflict },
2060
+ );
2061
+ }
2062
+ withinSubmit.push({
2063
+ parentTaskId: "(submitting)",
2064
+ role: verdict.role,
2065
+ shardId: shard.shardId,
2066
+ affectedPaths: shard.affectedPaths,
2067
+ });
2068
+ }
2069
+ }
2070
+
2071
+ const timestamp = now();
2072
+ nextTaskNumber += 1;
2073
+ const explicitParentTaskId = typeof input.parentTaskId === "string" && input.parentTaskId.trim().length > 0
2074
+ ? input.parentTaskId.trim()
2075
+ : undefined;
2076
+ const parentTaskId = explicitParentTaskId ?? "fanout-" + nextTaskNumber;
2077
+ if (tasks.has(parentTaskId)) {
2078
+ throw new FanoutValidationError("DUPLICATE_PARENT_TASK_ID", "parentTaskId 已存在: " + parentTaskId, { field: "parentTaskId" });
2079
+ }
2080
+ // 任务级限制只固化**显式**给出的值(submit 入参 / 快照里已有的 concurrencyLimit)。
2081
+ // 角色级设置与默认值不在创建时复制到任务上:它们描述的是"该角色的共享并发池",必须
2082
+ // 继续按当前 settings 解析——否则快照生成时那次请求的临时配置(例如 roleLimits=1)
2083
+ // 会被写进任务并永久压低该任务,配置缺失时尤其错误。
2084
+ const task = {
2085
+ parentTaskId,
2086
+ ...(verdict.requestId === undefined ? {} : { requestId: verdict.requestId }),
2087
+ role: verdict.role,
2088
+ basePrompt: verdict.basePrompt,
2089
+ status: "pending",
2090
+ // 逻辑轮次:首次提交 = 第 1 轮,每次 continuation 递增(attempt 序号独立递增)。
2091
+ round: 1,
2092
+ allowOverlap,
2093
+ ...(verdict.concurrencyLimit === undefined ? {} : { concurrencyLimit: verdict.concurrencyLimit }),
2094
+ createdAt: timestamp,
2095
+ updatedAt: timestamp,
2096
+ settledAt: undefined,
2097
+ parentCancelRequested: false,
2098
+ shards: verdict.shards.map((shard) => ({
2099
+ shardId: shard.shardId,
2100
+ // 任务名称进入运行时状态:快照投影与恢复都以它为唯一来源,不再从别处推导。
2101
+ ...(shard.name === undefined ? {} : { name: shard.name }),
2102
+ prompt: shard.prompt,
2103
+ status: "queued",
2104
+ affectedPaths: shard.affectedPaths ?? [],
2105
+ ...(shard.timeoutMs === undefined ? {} : { timeoutMs: shard.timeoutMs }),
2106
+ createdAt: timestamp,
2107
+ updatedAt: timestamp,
2108
+ attempts: [{
2109
+ attempt: 1,
2110
+ status: "queued",
2111
+ startedAt: undefined,
2112
+ finishedAt: undefined,
2113
+ affectedPaths: shard.affectedPaths ?? [],
2114
+ ...(shard.timeoutMs === undefined ? {} : { timeoutMs: shard.timeoutMs }),
2115
+ }],
2116
+ })),
2117
+ };
2118
+ tasks.set(parentTaskId, task);
2119
+ indexTask(task);
2120
+ if (verdict.requestId !== undefined) requestIndex.set(verdict.requestId, parentTaskId);
2121
+
2122
+ emit({
2123
+ type: "task_registered",
2124
+ parentTaskId,
2125
+ role: task.role,
2126
+ shardIds: task.shards.map((shard) => shard.shardId),
2127
+ counts: computeCounts(task),
2128
+ ...(verdict.requestId === undefined ? {} : { requestId: verdict.requestId }),
2129
+ });
2130
+ notifyPersistence();
2131
+ schedule();
2132
+ return {
2133
+ parentTaskId,
2134
+ duplicate: false,
2135
+ role: task.role,
2136
+ status: task.status,
2137
+ shardIds: task.shards.map((shard) => shard.shardId),
2138
+ ...(verdict.requestId === undefined ? {} : { requestId: verdict.requestId }),
2139
+ };
2140
+ }
2141
+
2142
+ /**
2143
+ * 多轮复用:对**同一逻辑父任务**续作一轮(continuation)。
2144
+ *
2145
+ * 语义(已确认规格):
2146
+ * 1. 复用判定由主代理发起,插件负责校验与执行;同一逻辑任务跨 ready/completed 复用,
2147
+ * parentTaskId 不变,不新建父任务、不新建 child;
2148
+ * 2. 每个既有 shard 追加一个新 attempt(attempt 序号单调递增),**保留原 childId**,
2149
+ * 旧 attempt 历史(状态/时间/结果/错误)原样保留、绝不覆盖;
2150
+ * 3. 复用只能发生在**本轮 wait-all 已完成**之后(每个 shard 的最新 attempt 已终态);
2151
+ * 任一 shard 仍在运行时结构化拒绝 `task_not_ready`,不做半写;
2152
+ * 4. 结构必须一致:role 相同、shard 数量相同、shardId 集合完全相同(同一个分片结构),
2153
+ * 否则结构化拒绝;新 prompt / 新 basePrompt 是本轮的指令;
2154
+ * 5. 提交后父任务立即回到 `pending`(投影层 = running),本轮全部新 attempt 终态后
2155
+ * 再次聚合,发布带 `round` 的 `task_settled`,父任务可继续下一轮;
2156
+ * 6. `requestId` 是幂等键:同一 requestId 的重复续作返回上次结果(duplicate=true),
2157
+ * 不追加 attempt;同一 requestId 指向别的父任务则拒绝。
2158
+ *
2159
+ * 校验全部先于任何状态修改:拒绝路径不改变任务、attempt 历史与事件。
2160
+ *
2161
+ * @param {{parentTaskId:string, role:string, basePrompt:string, shards:object[], requestId?:string}} input
2162
+ * @returns {object} 结构化结果:accepted/changed/reason/round/nextAttemptByShard 等。
2163
+ */
2164
+ function continueTask(input) {
2165
+ if (closed) return { accepted: false, changed: false, code: "MANAGER_CLOSED", reason: "manager_closed" };
2166
+ const verdict = validateSubmitInput(input);
2167
+ if (!verdict.ok) throw validationError(verdict);
2168
+ const parentTaskId = normalizeText(input?.parentTaskId).trim();
2169
+ if (parentTaskId.length === 0) {
2170
+ return { accepted: false, changed: false, reason: "missing_parent_task_id" };
2171
+ }
2172
+ const task = tasks.get(parentTaskId);
2173
+ if (task === undefined) {
2174
+ return { accepted: false, changed: false, reason: "unknown_parent_task", parentTaskId };
2175
+ }
2176
+ /**
2177
+ * requestId 幂等(**持久化**,不是全局 LRU):
2178
+ * - 命中同一父任务的记录 → 原样返回上次的 continuation result + duplicate=true;
2179
+ * - 记录在别的父任务上 → 拒绝(幂等键不能跨任务复用);
2180
+ * - 未命中 → 正常走到下面的校验/写入。
2181
+ *
2182
+ * 真相源是 task.continuationRequests(随快照持久化 + restore 恢复),因此进程重启、
2183
+ * 快照恢复以及大量请求之后旧 requestId 仍然可查,绝不会被淘汰后追加新 attempt。
2184
+ */
2185
+ if (verdict.requestId !== undefined) {
2186
+ const recorded = task.continuationRequests?.[verdict.requestId];
2187
+ if (recorded !== undefined && recorded !== null && typeof recorded.result === "object") {
2188
+ return { ...cloneJson(recorded.result), duplicate: true };
2189
+ }
2190
+ const elsewhere = continuationRequestOwners.get(verdict.requestId);
2191
+ if (typeof elsewhere === "string" && elsewhere !== parentTaskId) {
2192
+ return { accepted: false, changed: false, reason: "duplicate_request_id", parentTaskId, requestId: verdict.requestId };
2193
+ }
2194
+ if (continuationRequestBudgetExceeded(task)) {
2195
+ return { accepted: false, changed: false, reason: "request_budget_exceeded", parentTaskId, requestId: verdict.requestId };
2196
+ }
2197
+ }
2198
+ // 角色一致性:同一逻辑任务只允许同一专家角色续作。
2199
+ if (verdict.role !== task.role) {
2200
+ return { accepted: false, changed: false, reason: "role_mismatch", parentTaskId, role: verdict.role, expectedRole: task.role };
2201
+ }
2202
+ // 分片结构一致性:数量 + shardId 集合(顺序可不同,语义按 shardId 对齐)。
2203
+ if (verdict.shards.length !== task.shards.length) {
2204
+ return {
2205
+ accepted: false, changed: false, reason: "shard_count_mismatch", parentTaskId,
2206
+ expected: task.shards.length, received: verdict.shards.length,
2207
+ };
2208
+ }
2209
+ const expectedShardIds = task.shards.map((shard) => shard.shardId);
2210
+ const provided = new Map(verdict.shards.map((shard) => [shard.shardId, shard]));
2211
+ for (const shardId of expectedShardIds) {
2212
+ if (!provided.has(shardId)) {
2213
+ return { accepted: false, changed: false, reason: "shard_ids_mismatch", parentTaskId, missing: shardId };
2214
+ }
2215
+ }
2216
+ if (task.parentCancelRequested === true) {
2217
+ return { accepted: false, changed: false, reason: "parent_cancelled", parentTaskId };
2218
+ }
2219
+ // 忙碌判定:本轮 wait-all 尚未完成(任一分片最新 attempt 非终态)时拒绝,
2220
+ // 避免与运行中的 shard 竞争同一 child 的轮次。
2221
+ for (const shard of task.shards) {
2222
+ const latest = shard.attempts[shard.attempts.length - 1];
2223
+ if (latest === undefined || !isTerminalShardStatus(latest.status)) {
2224
+ return {
2225
+ accepted: false, changed: false, reason: "task_not_ready", parentTaskId,
2226
+ shardId: shard.shardId, shardStatus: shard.status, status: task.status,
2227
+ };
2228
+ }
2229
+ }
2230
+ // attempt 预算:**整体**判定(任一分片超限即整体拒绝),保证不会出现半写。
2231
+ for (const shard of task.shards) {
2232
+ const violation = budgetVerdict(
2233
+ "attempts",
2234
+ budgetPolicy.maxAttemptsPerShard,
2235
+ shard.attempts.length + 1,
2236
+ "shard " + shard.shardId,
2237
+ "该 shard 已耗尽多轮复用预算,请提交新的父任务或拆分工作",
2238
+ );
2239
+ if (violation !== null) {
2240
+ noteBudgetViolation(violation.violation);
2241
+ return {
2242
+ accepted: false, changed: false, reason: "attempt_budget_exceeded", parentTaskId,
2243
+ shardId: shard.shardId, violation: violation.violation,
2244
+ };
2245
+ }
2246
+ }
2247
+
2248
+ // 全部校验通过后才修改状态:先构造本轮的全部 attempt,再统一提交(无半写)。
2249
+ const round = Number.isSafeInteger(task.round) && task.round >= 1 ? task.round + 1 : 2;
2250
+ const timestamp = now();
2251
+ const prepared = task.shards.map((shard) => {
2252
+ const previous = shard.attempts[shard.attempts.length - 1];
2253
+ const next = provided.get(shard.shardId);
2254
+ return {
2255
+ shard,
2256
+ prompt: next.prompt,
2257
+ attempt: createAttempt(shard, previous.attempt + 1, {
2258
+ // 复用标记只存在于内存 attempt:冷恢复后原 child 可能已消失,恢复派发一律
2259
+ // 走 launcher 新建 child(既有 restore 语义)。
2260
+ continuation: true,
2261
+ ...(typeof previous.childId === "string" && previous.childId.length > 0 ? { childId: previous.childId } : {}),
2262
+ }),
2263
+ };
2264
+ });
2265
+ for (const item of prepared) {
2266
+ item.shard.prompt = item.prompt;
2267
+ item.shard.attempts.push(item.attempt);
2268
+ // 复用是对取消的显式复活(与 retryShard 同语义):清除该 shard 的取消标记,
2269
+ // 允许新一轮重新 admission。
2270
+ delete item.attempt.cancelRequested;
2271
+ item.shard.cancelRequested = false;
2272
+ setShardStatus(task, item.shard, "queued");
2273
+ item.shard.updatedAt = timestamp;
2274
+ }
2275
+ task.basePrompt = verdict.basePrompt;
2276
+ task.round = round;
2277
+ task.status = "pending";
2278
+ task.settledAt = undefined;
2279
+ task.updatedAt = timestamp;
2280
+ for (const item of prepared) {
2281
+ emitShardStatus(task, item.shard, "queued", item.attempt.attempt, {
2282
+ continuation: true,
2283
+ round,
2284
+ attemptCount: item.shard.attempts.length,
2285
+ ...(item.attempt.childId === undefined ? {} : { childId: item.attempt.childId }),
2286
+ });
2287
+ }
2288
+ const nextAttemptByShard = {};
2289
+ for (const item of prepared) nextAttemptByShard[item.shard.shardId] = item.attempt.attempt;
2290
+ emit({
2291
+ type: "task_continued",
2292
+ parentTaskId,
2293
+ role: task.role,
2294
+ round,
2295
+ shardIds: expectedShardIds,
2296
+ nextAttemptByShard,
2297
+ counts: computeCounts(task),
2298
+ ...(verdict.requestId === undefined ? {} : { requestId: verdict.requestId }),
2299
+ });
2300
+ const result = {
2301
+ accepted: true,
2302
+ changed: true,
2303
+ parentTaskId,
2304
+ role: task.role,
2305
+ round,
2306
+ status: task.status,
2307
+ shardIds: expectedShardIds,
2308
+ nextAttemptByShard,
2309
+ ...(verdict.requestId === undefined ? {} : { requestId: verdict.requestId }),
2310
+ };
2311
+ if (verdict.requestId !== undefined) {
2312
+ // 幂等记录写进**任务本体**(随快照持久化、restore 恢复):不淘汰、不丢键。
2313
+ // 记录的是本次返回的完整 result 快照(深拷贝),因此重复请求能原样复现同一结果。
2314
+ task.continuationRequests = {
2315
+ ...(task.continuationRequests ?? {}),
2316
+ [verdict.requestId]: { result: cloneJson(result), recordedAt: timestamp },
2317
+ };
2318
+ continuationRequestOwners.set(verdict.requestId, parentTaskId);
2319
+ }
2320
+ notifyPersistence();
2321
+ schedule();
2322
+ return result;
2323
+ }
2324
+
2325
+ /**
2326
+ * 读取某个 attempt 当前绑定的 messageId(只读,不克隆任务快照)。
2327
+ *
2328
+ * Host 用它做 continuation 的**严格 turn 归属**校验:turn/end 只能结算 messageId 与该
2329
+ * attempt 绑定值完全一致的那一次投递;拿不到(任务/分片/attempt 不存在,或该 attempt
2330
+ * 还没有 messageId)时返回 null,由 Host fail-closed(不结算,只记结构化诊断)。
2331
+ */
2332
+ function attemptMessageIdOf(parentTaskId, shardId, attemptNumber) {
2333
+ const task = tasks.get(parentTaskId);
2334
+ if (task === undefined) return null;
2335
+ const shard = task.shards.find((entry) => entry.shardId === shardId);
2336
+ if (shard === undefined) return null;
2337
+ const attempt = shard.attempts.find((entry) => entry.attempt === attemptNumber);
2338
+ if (attempt === undefined) return null;
2339
+ return typeof attempt.messageId === "string" && attempt.messageId.length > 0 ? attempt.messageId : null;
2340
+ }
2341
+
2342
+ /**
2343
+ * 记录一条「turn 归属无法证明」的结构化诊断(Host 观察到的竞态,不改变任何任务状态)。
2344
+ *
2345
+ * 语义边界:这不是结算失败(没有 attempt 状态写入),因此不能走 shard_settlement_rejected;
2346
+ * 只发布一条带 parentTaskId/shardId/attempt/reason/turn 的事件,让运维与测试都能按
2347
+ * sequence 观察「哪个 attempt 的哪一次 turn/end 因为无法证明归属而被忽略」。
2348
+ */
2349
+ function recordAttributionViolation(input) {
2350
+ const parentTaskId = normalizeText(input?.parentTaskId).trim();
2351
+ if (parentTaskId.length === 0) return { accepted: false, changed: false, reason: "missing_parent_task_id" };
2352
+ const shardId = normalizeText(input?.shardId).trim();
2353
+ const attemptNumber = Number.isSafeInteger(input?.attempt) && input.attempt > 0 ? input.attempt : undefined;
2354
+ const reason = normalizeText(input?.reason).trim();
2355
+ const turn = Number.isSafeInteger(input?.turn) ? input.turn : undefined;
2356
+ const messageId = typeof input?.messageId === "string" && input.messageId.length > 0 ? input.messageId : undefined;
2357
+ emit({
2358
+ type: "shard_attribution_unproven",
2359
+ parentTaskId,
2360
+ ...(shardId.length === 0 ? {} : { shardId }),
2361
+ ...(attemptNumber === undefined ? {} : { attempt: attemptNumber }),
2362
+ reason: reason.length === 0 ? "unproven" : reason,
2363
+ ...(turn === undefined ? {} : { turn }),
2364
+ ...(messageId === undefined ? {} : { messageId }),
2365
+ });
2366
+ notifyPersistence();
2367
+ return { accepted: true, changed: true, parentTaskId, reason: reason.length === 0 ? "unproven" : reason };
2368
+ }
2369
+
2370
+ /**
2371
+ * 取消单个 shard。立即返回接受结果:
2372
+ * - queued:直接进入 cancelled,永不 admission;
2373
+ * - running:调用注入取消器并标记取消请求,由 child completion 异步收敛;
2374
+ * - 已终态:不改写,changed=false(重复取消幂等)。
2375
+ */
2376
+ function cancelShard(parentTaskId, shardId) {
2377
+ if (closed) return { accepted: false, changed: false, code: "MANAGER_CLOSED", reason: "manager_closed" };
2378
+ const task = tasks.get(parentTaskId);
2379
+ if (task === undefined) return { accepted: false, changed: false, reason: "unknown_parent_task" };
2380
+ const shard = task.shards.find((entry) => entry.shardId === shardId);
2381
+ if (shard === undefined) return { accepted: false, changed: false, reason: "unknown_shard" };
2382
+
2383
+ const attempt = activeShard(shard) ?? shard.attempts[shard.attempts.length - 1];
2384
+ if (isTerminalShardStatus(attempt.status)) {
2385
+ return { accepted: true, changed: false, shardStatus: shard.status, attempt: attempt.attempt };
2386
+ }
2387
+ if (attempt.cancelRequested === true) {
2388
+ // 已接受过取消请求:幂等返回,不重复调用取消器。
2389
+ return { accepted: true, changed: false, shardStatus: shard.status, attempt: attempt.attempt, pending: true };
2390
+ }
2391
+ attempt.cancelRequested = true;
2392
+ if (attempt.status === "queued") {
2393
+ settleShard(task, shard, attempt, "cancelled", {
2394
+ error: toShardError({ name: "ShardCancelledError", reason: "cancelled", message: "shard 在排队阶段被取消" }, "shard 已取消"),
2395
+ });
2396
+ return { accepted: true, changed: true, shardStatus: shard.status, attempt: attempt.attempt };
2397
+ }
2398
+ // running:立即接受取消,但不直接写终态;真正的收敛由 completion 完成。
2399
+ requestShardCancellation(task, shard, attempt, "shard_cancelled");
2400
+ return { accepted: true, changed: true, shardStatus: shard.status, attempt: attempt.attempt, pending: true };
2401
+ }
2402
+
2403
+ /**
2404
+ * 取消整个父任务。已终态 shard 不受影响,所有未终态 shard 立即停止 admission;
2405
+ * 父任务状态在全部 shard 收敛后聚合为 cancelled 或 completed_with_errors。
2406
+ */
2407
+ function cancelParent(parentTaskId) {
2408
+ if (closed) return { accepted: false, changed: false, code: "MANAGER_CLOSED", reason: "manager_closed" };
2409
+ const task = tasks.get(parentTaskId);
2410
+ if (task === undefined) return { accepted: false, changed: false, reason: "unknown_parent_task" };
2411
+ const allTerminal = task.shards.every((shard) => {
2412
+ const attempt = shard.attempts[shard.attempts.length - 1];
2413
+ return attempt !== undefined && isTerminalShardStatus(attempt.status);
2414
+ });
2415
+ if (allTerminal) {
2416
+ // 已结算父任务的取消请求是幂等 no-op,不能再写入 parentCancelRequested 或重复发布聚合事件。
2417
+ return {
2418
+ accepted: true,
2419
+ changed: false,
2420
+ taskStatus: task.status,
2421
+ cancelledShardIds: [],
2422
+ cancellingShardIds: [],
2423
+ alreadyTerminalShardIds: task.shards.map((shard) => shard.shardId),
2424
+ };
2425
+ }
2426
+ task.parentCancelRequested = true;
2427
+ const cancelledShardIds = [];
2428
+ const cancellingShardIds = [];
2429
+ const alreadyTerminalShardIds = [];
2430
+ for (const shard of task.shards) {
2431
+ const attempt = activeShard(shard) ?? shard.attempts[shard.attempts.length - 1];
2432
+ if (isTerminalShardStatus(attempt.status)) {
2433
+ alreadyTerminalShardIds.push(shard.shardId);
2434
+ continue;
2435
+ }
2436
+ if (attempt.cancelRequested === true) {
2437
+ // 已经接受过取消请求的 shard 不再重复请求取消器。
2438
+ alreadyTerminalShardIds.push(shard.shardId);
2439
+ continue;
2440
+ }
2441
+ attempt.cancelRequested = true;
2442
+ // 在 shard 上留父级取消标记:retryShard 只有显式清除它才允许重新 admission。
2443
+ shard.cancelRequested = true;
2444
+ if (attempt.status === "queued") {
2445
+ // queued 直接进入 cancelled,永不 admission。
2446
+ cancelledShardIds.push(shard.shardId);
2447
+ settleShard(task, shard, attempt, "cancelled", {
2448
+ error: toShardError({ name: "ShardCancelledError", reason: "cancelled", message: "父任务取消,排队 shard 未 admission" }, "shard 已取消"),
2449
+ });
2450
+ } else {
2451
+ // running 只能异步收敛:先接受取消请求,等 child completion 决定终态。
2452
+ cancellingShardIds.push(shard.shardId);
2453
+ requestShardCancellation(task, shard, attempt, "parent_cancelled");
2454
+ }
2455
+ }
2456
+ aggregateTask(task);
2457
+ notifyPersistence();
2458
+ schedule();
2459
+ return {
2460
+ accepted: true,
2461
+ changed: cancelledShardIds.length > 0 || cancellingShardIds.length > 0,
2462
+ taskStatus: task.status,
2463
+ cancelledShardIds,
2464
+ cancellingShardIds,
2465
+ alreadyTerminalShardIds,
2466
+ };
2467
+ }
2468
+
2469
+ /**
2470
+ * 手工重试一个 shard。只允许 failed / timed_out / cancelled / interrupted,
2471
+ * 在原父任务内追加 attempt 并保留全部历史;最新 attempt 决定当前 shard 状态。
2472
+ */
2473
+ function retryShard(parentTaskId, shardId) {
2474
+ if (closed) return { accepted: false, changed: false, code: "MANAGER_CLOSED", reason: "manager_closed" };
2475
+ const task = tasks.get(parentTaskId);
2476
+ if (task === undefined) return { accepted: false, reason: "unknown_parent_task" };
2477
+ const shard = task.shards.find((entry) => entry.shardId === shardId);
2478
+ if (shard === undefined) return { accepted: false, reason: "unknown_shard" };
2479
+ const previous = shard.attempts[shard.attempts.length - 1];
2480
+ const retryable = previous.status === "failed"
2481
+ || previous.status === "timed_out"
2482
+ || previous.status === "cancelled"
2483
+ || previous.status === "interrupted";
2484
+ if (!retryable) return { accepted: false, reason: "not_retryable", shardStatus: shard.status, attempt: previous.attempt };
2485
+
2486
+ // attempt 数量预算:attempt 历史随重试无界增长(每个 attempt 都会进快照与事件),
2487
+ // 因此超限必须结构化失败且不新建 attempt。判定的是"新建后的 attempt 数"(+1),
2488
+ // 这样恰好允许 maxAttemptsPerShard 个 attempt 存在。
2489
+ const attemptViolation = budgetVerdict(
2490
+ "attempts",
2491
+ budgetPolicy.maxAttemptsPerShard,
2492
+ shard.attempts.length + 1,
2493
+ "attempt",
2494
+ "该 shard 已耗尽重试预算,请提交新的父任务或拆分工作",
2495
+ );
2496
+ if (attemptViolation !== null) {
2497
+ noteBudgetViolation(attemptViolation.violation);
2498
+ return {
2499
+ accepted: false,
2500
+ changed: false,
2501
+ reason: "attempt_budget_exceeded",
2502
+ shardStatus: shard.status,
2503
+ attempt: previous.attempt,
2504
+ violation: attemptViolation.violation,
2505
+ };
2506
+ }
2507
+
2508
+ const nextAttempt = previous.attempt + 1;
2509
+ const attempt = createAttempt(shard, nextAttempt);
2510
+ shard.attempts.push(attempt);
2511
+ // 手工重试是对取消的显式复活:清除该 shard 的取消请求标记,允许重新 admission。
2512
+ // 父任务的 parentCancelRequested 保持不变,剩余 queued shard 仍然不会自动派发。
2513
+ delete attempt.cancelRequested;
2514
+ shard.cancelRequested = false;
2515
+ updateShardStatus(task, shard, "queued");
2516
+ // 复活了一个非终态 shard,父任务立即回到 pending(不发布 task_settled 之外的新终态事件)。
2517
+ task.status = "pending";
2518
+ task.settledAt = undefined;
2519
+ emitShardStatus(task, shard, "queued", nextAttempt, { retryOf: previous.attempt });
2520
+ notifyPersistence();
2521
+ schedule();
2522
+ return { accepted: true, attempt: nextAttempt, shardStatus: shard.status, previousStatus: previous.status };
2523
+ }
2524
+
2525
+ function getTask(parentTaskId) {
2526
+ const task = tasks.get(parentTaskId);
2527
+ return task === undefined ? null : cloneJson(taskSnapshot(task));
2528
+ }
2529
+
2530
+ function listTasks() {
2531
+ return cloneJson([...tasks.values()].map((task) => taskSnapshot(task)));
2532
+ }
2533
+
2534
+ function getCounts(parentTaskId) {
2535
+ const task = tasks.get(parentTaskId);
2536
+ return task === undefined ? null : cloneJson(computeCounts(task));
2537
+ }
2538
+
2539
+ /** 订阅状态变化事件;返回退订函数。事件按真实发生顺序同步发布。 */
2540
+ function subscribe(listener) {
2541
+ if (typeof listener !== "function") {
2542
+ throw new FanoutValidationError("INVALID_LISTENER", "listener 必须是函数");
2543
+ }
2544
+ if (closed) return () => {};
2545
+ subscribers.add(listener);
2546
+ return () => { subscribers.delete(listener); };
2547
+ }
2548
+
2549
+ /** 等待当前调度工作全部派发完成(测试与 Host 收尾用)。 */
2550
+ function whenIdle() {
2551
+ // close 不会遗留新的调度工作;若 close 前已经排了 drain,仍等待该 drain 有界结束。
2552
+ return drainScheduled ? drainPromise : Promise.resolve();
2553
+ }
2554
+
2555
+ /** 当前调度器是否没有排队中的 drain;running child 仍可继续执行。 */
2556
+ function isIdle() {
2557
+ return closed || (!drainScheduled && !workRequested);
2558
+ }
2559
+
2560
+ /**
2561
+ * 从持久化快照恢复已登记任务。
2562
+ *
2563
+ * 恢复只重建逻辑状态,不复用任何 childId/句柄:快照中处于 running 的 shard 说明
2564
+ * 原运行时已经消失,统一标记为 interrupted;若 resume=true,这类 shard 会像
2565
+ * retryShard 一样追加一个新 attempt 继续执行(历史 attempt 与结果原样保留)。
2566
+ * 快照中仍处于 queued 的 shard 保持原 attempt 重新排队,已终态(succeeded/failed/
2567
+ * cancelled/timed_out/interrupted 且无新 attempt)的 shard 一律不重新派发。
2568
+ * 所有重派发都经过同一个 dispatchAvailable(per-role 限流、父任务轮转、
2569
+ * affectedPaths 冲突判定),restore 不复制任何调度规则。
2570
+ *
2571
+ * @param {object} snapshot - serializeSnapshot() 产出的快照。
2572
+ * @param {{resume?:boolean, merge?:boolean}} [restoreOptions] - resume=true 时显式触发一次重新派发;
2573
+ * merge=true 时保留当前任务并追加本次快照中的新任务,用于 deferred root 到达后的分批恢复;
2574
+ * 默认(false)只重建状态并让队列在下次调度时机自然派发。
2575
+ * @returns {{restored:boolean, tasks:number}}
2576
+ */
2577
+ function restore(snapshot, restoreOptions = {}) {
2578
+ if (closed) {
2579
+ return { accepted: false, restored: 0, tasks: 0, code: "MANAGER_CLOSED", reason: "manager_closed" };
2580
+ }
2581
+ if (snapshot === null || typeof snapshot !== "object" || Array.isArray(snapshot)) {
2582
+ throw new FanoutValidationError("INVALID_SNAPSHOT", "restore 需要快照对象");
2583
+ }
2584
+ if (snapshot.version !== SNAPSHOT_VERSION) {
2585
+ throw new FanoutValidationError("UNSUPPORTED_SNAPSHOT_VERSION", "不支持的快照版本: " + String(snapshot.version), { field: "version" });
2586
+ }
2587
+ if (!Array.isArray(snapshot.tasks)) {
2588
+ throw new FanoutValidationError("INVALID_SNAPSHOT", "快照缺少 tasks 数组", { field: "tasks" });
2589
+ }
2590
+ if (snapshot.events !== undefined && (!Array.isArray(snapshot.events) || snapshot.events.some((event) => (
2591
+ event === null || typeof event !== "object" || Array.isArray(event)
2592
+ || !Number.isSafeInteger(event.sequence) || event.sequence < 1
2593
+ )))) {
2594
+ throw new FanoutValidationError("INVALID_SNAPSHOT", "快照 events 必须是带正序列号的对象数组", { field: "events" });
2595
+ }
2596
+ // 规模预算先于逐项结构校验:超限的恢复输入必须给出 BUDGET_EXCEEDED(可定位到资源),
2597
+ // 而不是先被结构/历史转移校验拦成另一个错误。这里仍在任何状态修改与 clone 之前。
2598
+ // 外部持久化输入的规模预算:必须在任何状态修改与大规模 clone 之前判定。
2599
+ // 超限即原子失败(tasks/eventHistory/配置全部保持不变),并给出可定位的违规。
2600
+ const restoreBudget = checkRestoreBudget(snapshot, {
2601
+ existingTasks: restoreOptions.merge === true ? tasks.size : 0,
2602
+ maxBytes: budgetPolicy.maxSnapshotBytes,
2603
+ });
2604
+ if (!restoreBudget.ok) {
2605
+ noteBudgetViolation(restoreBudget.violation);
2606
+ throw validationError({
2607
+ ok: false,
2608
+ code: "INVALID_BUDGET",
2609
+ message: restoreBudget.violation.message,
2610
+ field: "snapshot",
2611
+ violation: restoreBudget.violation,
2612
+ });
2613
+ }
2614
+
2615
+ for (const task of snapshot.tasks) {
2616
+ if (task === null || typeof task !== "object" || typeof task.parentTaskId !== "string" || task.parentTaskId.length === 0) {
2617
+ throw new FanoutValidationError("INVALID_SNAPSHOT", "快照任务缺少 parentTaskId", { field: "tasks" });
2618
+ }
2619
+ if (typeof task.role !== "string" || !Array.isArray(task.shards)) {
2620
+ throw new FanoutValidationError("INVALID_SNAPSHOT", "快照任务缺少 role/shards: " + task.parentTaskId, { field: "tasks" });
2621
+ }
2622
+ if (task.status !== undefined && !FANOUT_TASK_STATUSES.includes(task.status)) {
2623
+ throw new FanoutValidationError(
2624
+ "INVALID_SNAPSHOT_STATUS",
2625
+ "快照任务状态非法: " + String(task.status),
2626
+ { field: "tasks.status", status: task.status },
2627
+ );
2628
+ }
2629
+ if (task.concurrencyLimit !== undefined) {
2630
+ const check = validateConcurrencyLimit(task.concurrencyLimit);
2631
+ if (!check.ok) throw new FanoutValidationError(check.code, check.message, { field: "tasks.concurrencyLimit" });
2632
+ }
2633
+ // continuation 幂等记录(外部持久化输入):结构必须严格合法,否则整体拒绝——
2634
+ // 静默丢弃会让「曾经续作过的 requestId」在恢复后被当成新请求而重复追加 attempt。
2635
+ if (task.continuationRequests !== undefined) {
2636
+ const requests = task.continuationRequests;
2637
+ if (requests === null || typeof requests !== "object" || Array.isArray(requests)) {
2638
+ throw new FanoutValidationError("INVALID_SNAPSHOT", "快照 continuationRequests 必须是对象: " + task.parentTaskId, { field: "tasks.continuationRequests" });
2639
+ }
2640
+ const keys = Object.keys(requests);
2641
+ if (keys.length > budgetPolicy.maxAttemptsPerShard) {
2642
+ throw new FanoutValidationError(
2643
+ "INVALID_BUDGET",
2644
+ "快照 continuationRequests 条数超过预算: " + task.parentTaskId,
2645
+ { field: "tasks.continuationRequests" },
2646
+ );
2647
+ }
2648
+ for (const requestId of keys) {
2649
+ const entry = requests[requestId];
2650
+ if (requestId.length === 0 || entry === null || typeof entry !== "object" || Array.isArray(entry)
2651
+ || entry.result === null || typeof entry.result !== "object" || Array.isArray(entry.result)) {
2652
+ throw new FanoutValidationError(
2653
+ "INVALID_SNAPSHOT",
2654
+ "快照 continuationRequests 记录非法: " + task.parentTaskId,
2655
+ { field: "tasks.continuationRequests" },
2656
+ );
2657
+ }
2658
+ }
2659
+ }
2660
+ for (let shardIndex = 0; shardIndex < task.shards.length; shardIndex += 1) {
2661
+ const shard = task.shards[shardIndex];
2662
+ const shardField = "tasks.shards[" + shardIndex + "]";
2663
+ if (shard === null || typeof shard !== "object" || typeof shard.shardId !== "string" || !Array.isArray(shard.attempts)) {
2664
+ throw new FanoutValidationError("INVALID_SNAPSHOT", "快照 shard 结构非法: " + task.parentTaskId, { field: shardField });
2665
+ }
2666
+ const nameVerdict = validateShardName(shard.name, shardField + ".name");
2667
+ if (!nameVerdict.ok) throw validationError(nameVerdict);
2668
+ if (shard.status !== undefined && !SHARD_STATUS_SET.has(shard.status)) {
2669
+ throw new FanoutValidationError(
2670
+ "INVALID_SNAPSHOT_STATUS",
2671
+ "快照 shard 状态非法: " + String(shard.status),
2672
+ { field: shardField + ".status", status: shard.status },
2673
+ );
2674
+ }
2675
+ for (let attemptIndex = 0; attemptIndex < shard.attempts.length; attemptIndex += 1) {
2676
+ const rawAttempt = shard.attempts[attemptIndex];
2677
+ const attemptField = shardField + ".attempts[" + attemptIndex + "]";
2678
+ if (rawAttempt === null || typeof rawAttempt !== "object" || Array.isArray(rawAttempt)) {
2679
+ throw new FanoutValidationError("INVALID_SNAPSHOT", "快照 attempt 结构非法: " + task.parentTaskId, { field: attemptField });
2680
+ }
2681
+ if (rawAttempt.status !== undefined && !SHARD_STATUS_SET.has(rawAttempt.status)) {
2682
+ throw new FanoutValidationError(
2683
+ "INVALID_SNAPSHOT_STATUS",
2684
+ "快照 attempt 状态非法: " + String(rawAttempt.status),
2685
+ { field: attemptField + ".status", status: rawAttempt.status },
2686
+ );
2687
+ }
2688
+ // 追加 attempt 前,旧 attempt 必须已经终态;否则恢复会把非法历史静默变成可派发队列。
2689
+ if (attemptIndex < shard.attempts.length - 1
2690
+ && rawAttempt.status !== undefined
2691
+ && !isTerminalShardStatus(rawAttempt.status)) {
2692
+ throw new FanoutValidationError(
2693
+ "INVALID_SNAPSHOT_TRANSITION",
2694
+ "快照 attempt 历史包含未终态转移: " + task.parentTaskId,
2695
+ { field: attemptField + ".status", status: rawAttempt.status },
2696
+ );
2697
+ }
2698
+ }
2699
+ }
2700
+ }
2701
+
2702
+ // 配置必须沿用创建 manager 时的校验边界;所有字段先完成校验,再一次性替换,
2703
+ // 避免半个快照生效后把当前 manager 留在不可用状态。
2704
+ const restoredDefaultConcurrencyLimit = snapshot.defaultConcurrencyLimit === undefined
2705
+ ? defaultConcurrencyLimit
2706
+ : (() => {
2707
+ const check = validateConcurrencyLimit(snapshot.defaultConcurrencyLimit);
2708
+ if (!check.ok) throw new FanoutValidationError(check.code, check.message, { field: "defaultConcurrencyLimit" });
2709
+ return check.value;
2710
+ })();
2711
+ const restoredRoleLimits = snapshot.roleLimits === undefined
2712
+ ? roleLimits
2713
+ : normalizeRoleLimits(snapshot.roleLimits);
2714
+ if (snapshot.writeRoles !== undefined
2715
+ && snapshot.writeRoles !== null
2716
+ && !Array.isArray(snapshot.writeRoles)
2717
+ && typeof snapshot.writeRoles !== "string") {
2718
+ throw new FanoutValidationError("INVALID_WRITE_ROLES", "快照 writeRoles 必须是字符串数组", { field: "writeRoles" });
2719
+ }
2720
+ if (Array.isArray(snapshot.writeRoles) && snapshot.writeRoles.some((role) => typeof role !== "string")) {
2721
+ throw new FanoutValidationError("INVALID_WRITE_ROLES", "快照 writeRoles 必须是字符串数组", { field: "writeRoles" });
2722
+ }
2723
+ const restoredWriteRoles = snapshot.writeRoles === undefined
2724
+ ? writeRoles
2725
+ : normalizeWriteRoles(snapshot.writeRoles);
2726
+ const restoredDefaultAllowOverlap = snapshot.defaultAllowOverlap === undefined
2727
+ ? defaultAllowOverlap
2728
+ : (() => {
2729
+ if (typeof snapshot.defaultAllowOverlap !== "boolean") {
2730
+ throw new FanoutValidationError("INVALID_ALLOW_OVERLAP", "快照 defaultAllowOverlap 必须是布尔值", { field: "defaultAllowOverlap" });
2731
+ }
2732
+ return snapshot.defaultAllowOverlap;
2733
+ })();
2734
+
2735
+ // 校验全部通过后才修改内存状态:非法快照不得破坏现有任务或配置。
2736
+ //
2737
+ // 全局配置的恢复边界(与任务级上限区分):
2738
+ // - 快照**自带** defaultConcurrencyLimit 时切到 snapshot 来源,恢复出来的任务按快照
2739
+ // 值驱动(重启不改变既有任务语义);
2740
+ // - 快照**没有**这两个字段(旧快照 / 不落盘全局配置)时必须保留构造时注入的解析
2741
+ // 函数与 role 覆盖:否则恢复出来的 queued shard 会卡在快照生成时那次请求的临时
2742
+ // 配置上(例如 roleLimits=1),而当前 settings 明明允许更多并发。
2743
+ // 任务级 concurrencyLimit 不受这里影响:它始终优先,且由 task.concurrencyLimit 固化。
2744
+ if (snapshot.defaultConcurrencyLimit !== undefined) {
2745
+ defaultConcurrencyLimit = restoredDefaultConcurrencyLimit;
2746
+ resolverSource = "snapshot";
2747
+ }
2748
+ if (snapshot.roleLimits !== undefined) roleLimits = restoredRoleLimits;
2749
+ writeRoles = restoredWriteRoles;
2750
+ defaultAllowOverlap = restoredDefaultAllowOverlap;
2751
+ if (typeof injectedConflictChecker !== "function") {
2752
+ conflictChecker = createWriteRoleConflictChecker({ writeRoles });
2753
+ }
2754
+ const resume = restoreOptions.resume === true;
2755
+ const merge = restoreOptions.merge === true;
2756
+ if (!merge) {
2757
+ tasks.clear();
2758
+ requestIndex.clear();
2759
+ rotationCursors.clear();
2760
+ }
2761
+ // restore 在所有任务重建完成后一次性刷新派生索引,确保 merge 替换任务时不留旧引用。
2762
+ const restoredEvents = Array.isArray(snapshot.events)
2763
+ ? snapshot.events.slice(-budgetPolicy.maxEventHistory).map((event) => cloneJson(event))
2764
+ : [];
2765
+ const highestEventSequence = restoredEvents.reduce((highest, event) => Math.max(highest, event.sequence), 0);
2766
+ sequence = merge
2767
+ ? Math.max(sequence, Number.isInteger(snapshot.sequence) ? snapshot.sequence : 0, highestEventSequence)
2768
+ : Math.max(Number.isInteger(snapshot.sequence) ? snapshot.sequence : 0, highestEventSequence);
2769
+ if (merge) {
2770
+ const knownSequences = new Set(eventHistory.map((event) => event.sequence));
2771
+ for (const event of restoredEvents) {
2772
+ if (!knownSequences.has(event.sequence)) eventHistory.push(event);
2773
+ }
2774
+ eventHistory.sort((left, right) => left.sequence - right.sequence);
2775
+ trimEventHistory();
2776
+ } else {
2777
+ eventHistory.splice(0, eventHistory.length, ...restoredEvents);
2778
+ }
2779
+ nextTaskNumber = merge
2780
+ ? Math.max(nextTaskNumber, Number.isInteger(snapshot.nextTaskNumber) ? snapshot.nextTaskNumber : 0)
2781
+ : Number.isInteger(snapshot.nextTaskNumber) ? snapshot.nextTaskNumber : 0;
2782
+ if (snapshot.parentRotation !== null && typeof snapshot.parentRotation === "object") {
2783
+ for (const [role, cursor] of Object.entries(snapshot.parentRotation)) {
2784
+ if (Number.isInteger(cursor) && cursor >= 0) rotationCursors.set(role, cursor);
2785
+ }
2786
+ }
2787
+ restoreDispatchSuppressed = !resume;
2788
+ workRequested = false;
2789
+ const rolesToSchedule = new Set();
2790
+ restoringSnapshot = true;
2791
+ for (const rawTask of snapshot.tasks) {
2792
+ const parentCancelRequested = rawTask.parentCancelRequested === true;
2793
+ // 角色在构造 task 之前取出:shards.map 的回调在 task 对象字面量求值期间执行,
2794
+ // 此时 `task` 仍处于 TDZ,任何对 task.role 的引用都会抛 ReferenceError
2795
+ // (整个 restore 因此静默失败)。用已校验的 rawTask.role 作为本任务的 role 源。
2796
+ const taskRole = rawTask.role;
2797
+ const task = {
2798
+ parentTaskId: rawTask.parentTaskId,
2799
+ ...(typeof rawTask.requestId === "string" ? { requestId: rawTask.requestId } : {}),
2800
+ role: taskRole,
2801
+ basePrompt: typeof rawTask.basePrompt === "string" ? rawTask.basePrompt : "",
2802
+ status: "pending",
2803
+ // 轮次随快照持久化:旧快照没有该字段时按第 1 轮(首次提交)归一。
2804
+ round: Number.isSafeInteger(rawTask.round) && rawTask.round >= 1 ? rawTask.round : 1,
2805
+ // continuation 幂等记录恢复:restore 后旧 requestId 仍然 duplicate=true,
2806
+ // 且绝不因为索引淘汰而把旧键当成新的一轮(幂等键的生命周期 = 任务生命周期)。
2807
+ ...(rawTask.continuationRequests === undefined || rawTask.continuationRequests === null
2808
+ ? {}
2809
+ : { continuationRequests: cloneJson(rawTask.continuationRequests) }),
2810
+ allowOverlap: rawTask.allowOverlap === true,
2811
+ ...(typeof rawTask.concurrencyLimit === "number" ? { concurrencyLimit: rawTask.concurrencyLimit } : {}),
2812
+ createdAt: typeof rawTask.createdAt === "number" ? rawTask.createdAt : now(),
2813
+ updatedAt: typeof rawTask.updatedAt === "number" ? rawTask.updatedAt : now(),
2814
+ settledAt: typeof rawTask.settledAt === "number" ? rawTask.settledAt : undefined,
2815
+ parentCancelRequested,
2816
+ shards: rawTask.shards.map((rawShard) => {
2817
+ const attempts = (rawShard.attempts.length === 0 ? [{ attempt: 1, status: "queued" }] : rawShard.attempts).map((rawAttempt, index) => ({
2818
+ attempt: Number.isInteger(rawAttempt.attempt) ? rawAttempt.attempt : index + 1,
2819
+ status: typeof rawAttempt.status === "string" ? rawAttempt.status : "queued",
2820
+ startedAt: typeof rawAttempt.startedAt === "number" ? rawAttempt.startedAt : undefined,
2821
+ finishedAt: typeof rawAttempt.finishedAt === "number" ? rawAttempt.finishedAt : undefined,
2822
+ affectedPaths: Array.isArray(rawAttempt.affectedPaths) ? [...rawAttempt.affectedPaths] : [],
2823
+ ...(rawAttempt.cancelRequested === true ? { cancelRequested: true } : {}),
2824
+ ...(typeof rawAttempt.timeoutMs === "number" ? { timeoutMs: rawAttempt.timeoutMs } : {}),
2825
+ ...(typeof rawAttempt.childId === "string" ? { childId: rawAttempt.childId } : {}),
2826
+ ...(typeof rawAttempt.messageId === "string" ? { messageId: rawAttempt.messageId } : {}),
2827
+ ...(rawAttempt.result === undefined ? {} : { result: cloneJson(rawAttempt.result) }),
2828
+ ...(rawAttempt.error === undefined ? {} : { error: cloneJson(rawAttempt.error) }),
2829
+ }));
2830
+ // 恢复语义:running 的运行时句柄已经不存在,统一记为 interrupted。
2831
+ for (const attempt of attempts) {
2832
+ if (attempt.status === "running") {
2833
+ attempt.status = "interrupted";
2834
+ attempt.finishedAt = attempt.finishedAt ?? now();
2835
+ attempt.error = attempt.error ?? toShardError(
2836
+ { name: "ShardInterruptedError", reason: "restore", message: "运行时恢复后丢失原 child,需手工重试" },
2837
+ "shard 因恢复而中断",
2838
+ );
2839
+ }
2840
+ }
2841
+ const last = attempts[attempts.length - 1];
2842
+ const latestTerminal = isTerminalShardStatus(last.status);
2843
+ const shard = {
2844
+ shardId: rawShard.shardId,
2845
+ // 使用恢复阶段已校验并归一化的名称;旧快照没有该字段时保持"无名称",
2846
+ // 由投影侧回退 shardId。
2847
+ ...(typeof rawShard.name === "string" ? { name: rawShard.name.trim() } : {}),
2848
+ prompt: typeof rawShard.prompt === "string" ? rawShard.prompt : "",
2849
+ status: latestTerminal ? last.status : "queued",
2850
+ affectedPaths: last.affectedPaths.length > 0 ? [...last.affectedPaths] : (Array.isArray(rawShard.affectedPaths) ? [...rawShard.affectedPaths] : []),
2851
+ ...(rawShard.cancelRequested === true || (parentCancelRequested && rawShard.status === "queued") ? { cancelRequested: true } : {}),
2852
+ ...(last.timeoutMs === undefined ? {} : { timeoutMs: last.timeoutMs }),
2853
+ createdAt: typeof rawShard.createdAt === "number" ? rawShard.createdAt : now(),
2854
+ updatedAt: typeof rawShard.updatedAt === "number" ? rawShard.updatedAt : now(),
2855
+ attempts,
2856
+ };
2857
+ // resume 的派发候选只来自"非终态且仍在 queued"的 shard:latestTerminal 的
2858
+ // shard 已经结算,重新派发等于重复执行,必须排除。
2859
+ if (resume && shard.status === "queued" && !latestTerminal) rolesToSchedule.add(taskRole);
2860
+ return shard;
2861
+ }),
2862
+ };
2863
+ // resume 语义:被中断的 running shard 只有换一个新 attempt 才可能继续执行——
2864
+ // 原来的 attempt 已经写成 interrupted 终态,调度器只派发 queued attempt。
2865
+ // 这里复用 retryShard 的语义(追加 attempt、保留历史、清取消标记),但只在
2866
+ // resume 时执行,且不触发 per-shard 事件重放(恢复期不发布实时事件)。
2867
+ if (resume) {
2868
+ for (const shard of task.shards) {
2869
+ const last = shard.attempts[shard.attempts.length - 1];
2870
+ if (last === undefined || last.status !== "interrupted") continue;
2871
+ if (shard.cancelRequested === true) continue;
2872
+ const attempt = createAttempt(shard, last.attempt + 1);
2873
+ shard.attempts.push(attempt);
2874
+ delete attempt.cancelRequested;
2875
+ shard.cancelRequested = false;
2876
+ setShardStatus(task, shard, "queued");
2877
+ shard.updatedAt = now();
2878
+ task.updatedAt = shard.updatedAt;
2879
+ rolesToSchedule.add(taskRole);
2880
+ }
2881
+ }
2882
+ tasks.set(task.parentTaskId, task);
2883
+ if (task.requestId !== undefined) requestIndex.set(task.requestId, task.parentTaskId);
2884
+ aggregateTask(task);
2885
+ }
2886
+ rebuildIndexes();
2887
+ restoringSnapshot = false;
2888
+ notifyPersistence();
2889
+ // schedule() 会清除 restoreDispatchSuppressed 并排入一次 drain,因此 resume 的
2890
+ // 重派发仍由唯一调度状态机(dispatchAvailable + role concurrency + affectedPaths
2891
+ // 冲突判定)决定,restore 不复制任何调度规则。
2892
+ if (resume && rolesToSchedule.size > 0) schedule();
2893
+ return { restored: true, tasks: tasks.size };
2894
+ }
2895
+
2896
+ /**
2897
+ * 关闭 manager:先封锁调度入口,再让所有未终态 attempt 以 manager_closed 收敛。
2898
+ * running attempt 先调用取消器,随后立即写入 cancelled,避免 whenIdle/关闭流程等待
2899
+ * 永不完成的 child;迟到的 admission 或 completion 仍只能走拒绝诊断路径。
2900
+ */
2901
+ function close() {
2902
+ if (closed) return { accepted: false, changed: false, code: "MANAGER_CLOSED", reason: "already_closed" };
2903
+ closed = true;
2904
+ restoreDispatchSuppressed = true;
2905
+ workRequested = false;
2906
+ let changed = false;
2907
+ for (const task of tasks.values()) {
2908
+ const activeShards = task.shards.filter((shard) => {
2909
+ const attempt = activeShard(shard) ?? shard.attempts[shard.attempts.length - 1];
2910
+ return attempt !== undefined && !isTerminalShardStatus(attempt.status);
2911
+ });
2912
+ if (activeShards.length === 0) continue;
2913
+ changed = true;
2914
+ task.parentCancelRequested = true;
2915
+ for (const shard of activeShards) {
2916
+ const attempt = activeShard(shard) ?? shard.attempts[shard.attempts.length - 1];
2917
+ if (attempt === undefined || isTerminalShardStatus(attempt.status)) continue;
2918
+ const cancellationAlreadyRequested = attempt.cancelRequested === true;
2919
+ attempt.cancelRequested = true;
2920
+ shard.cancelRequested = true;
2921
+ if (attempt.status === "running" && !cancellationAlreadyRequested) {
2922
+ requestShardCancellation(task, shard, attempt, "manager_closed");
2923
+ }
2924
+ settleShard(task, shard, attempt, "cancelled", {
2925
+ error: toShardError(
2926
+ { name: "FanoutClosedError", reason: "manager_closed", message: "fan-out manager 已关闭" },
2927
+ "fan-out manager 已关闭",
2928
+ ),
2929
+ });
2930
+ }
2931
+ aggregateTask(task);
2932
+ notifyPersistence();
2933
+ }
2934
+ // 关闭后不再保留实时订阅者;历史诊断仍保留在 eventHistory/快照中。
2935
+ subscribers.clear();
2936
+ // 不取消既有 drainPromise:已排队的 drain 会看到 closed 并自行结束,调用方可以安全 await。
2937
+ return { accepted: true, changed, code: "MANAGER_CLOSED", reason: "manager_closed" };
2938
+ }
2939
+
2940
+ // 公开写入口一律包成"一次调用 = 一个持久化批":调用内的多次状态变化(批量登记、
2941
+ // 批量取消、close 收敛多个任务、restore 重建多个任务)只序列化/落盘一次。
2942
+ // 注意只包装写入口;读接口(getTask/listTasks/getCounts/whenIdle/snapshot/budget)
2943
+ // 不改变状态,保持原样。persistSnapshot 本身就是落盘动作,也不包装。
2944
+ return Object.freeze({
2945
+ submit: (input) => withPersistenceBatch(() => submit(input)),
2946
+ cancelShard: (parentTaskId, shardId) => withPersistenceBatch(() => cancelShard(parentTaskId, shardId)),
2947
+ cancelParent: (parentTaskId) => withPersistenceBatch(() => cancelParent(parentTaskId)),
2948
+ retryShard: (parentTaskId, shardId) => withPersistenceBatch(() => retryShard(parentTaskId, shardId)),
2949
+ continueTask: (input) => withPersistenceBatch(() => continueTask(input)),
2950
+ /** 只读:某 attempt 当前绑定的 messageId(严格 turn 归属校验用,不克隆任务快照)。 */
2951
+ attemptMessageIdOf,
2952
+ /** Host 观察到的「turn 归属无法证明」结构化诊断(只写事件,不改变任务状态)。 */
2953
+ recordAttributionViolation: (input) => withPersistenceBatch(() => recordAttributionViolation(input)),
2954
+ getTask,
2955
+ listTasks,
2956
+ getCounts,
2957
+ subscribe,
2958
+ whenIdle,
2959
+ isIdle,
2960
+ close: () => withPersistenceBatch(() => close()),
2961
+ snapshot: () => serializeSnapshot(),
2962
+ restore: (snapshot, restoreOptions) => withPersistenceBatch(() => restore(snapshot, restoreOptions)),
2963
+ persistSnapshot,
2964
+ /** 只读并发视图:有效默认值 + 各 role 覆盖 + 解析来源(诊断,不泄露任务负载)。 */
2965
+ concurrencyLimits,
2966
+ /**
2967
+ * 只读预算视图:当前用量、冻结上限与最近一次违规。
2968
+ * 不暴露任何调度内部状态,宿主/客户端据此判断是否接近预算。
2969
+ */
2970
+ budget: () => Object.freeze({
2971
+ limits: Object.freeze({ ...budgetPolicy }),
2972
+ tasks: tasks.size,
2973
+ events: eventHistory.length,
2974
+ eventBytes: checkEventHistoryBudget(eventHistory, {
2975
+ maxCount: budgetPolicy.maxEventHistory,
2976
+ maxBytes: budgetPolicy.maxEventHistoryBytes,
2977
+ }).bytes,
2978
+ lastViolation: lastBudgetViolation === null ? null : cloneJson(lastBudgetViolation),
2979
+ }),
2980
+ });
2981
+ }