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
@@ -0,0 +1,822 @@
1
+ /**
2
+ * Host 关注点模块:fan-out runtime 编排。
3
+ *
4
+ * 真正持有 createFanoutRuntime 实例、恢复标志(restoring/restorePending)、
5
+ * 快照合并与父任务编排状态;fanout.js 的 manager 仍是唯一调度状态机。
6
+ * 组合根只注入依赖并把工具/RPC/事件接线到本模块的小接口。
7
+ *
8
+ * @module dsh-expert-group/host/fanout-runtime
9
+ */
10
+ import { createPersistenceHealth } from "./persistence-health.js";
11
+
12
+ function describeError(error) {
13
+ return error instanceof Error ? error.message : String(error);
14
+ }
15
+
16
+ function taskNeedsRoot(task) {
17
+ return Array.isArray(task?.shards) && task.shards.some((shard) => (
18
+ shard?.status === "queued" && shard?.cancelRequested !== true
19
+ ));
20
+ }
21
+
22
+ const TERMINAL_SHARD_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out", "interrupted"]);
23
+
24
+ /** 默认退避节奏:第 1 次失败后等 50ms,此后翻倍,封顶 2s。 */
25
+ const DEFAULT_RETRY_BACKOFF = Object.freeze({ baseDelayMs: 50, maxDelayMs: 2000 });
26
+
27
+ /**
28
+ * manager 权威视角的父任务终态判定,与 snapshotTaskIsTerminal(只按恢复负载推导)互补。
29
+ *
30
+ * 为什么两套判定都要:恢复负载里的任务可能被 manager 在**同一个同步恢复窗口内**聚合到
31
+ * 终态(例如无 shard 的恢复任务直接聚合为 completed,或恢复重排后立即收敛),此时
32
+ * manager 因 restoringSnapshot 不再发布 task_settled,归属只能按这里的权威状态归还;
33
+ * 只看恢复负载会让归属 id 一直滞留到 close(审核发现 04 的轻微泄漏)。
34
+ *
35
+ * 只接受 manager 的任务投影(getTask/taskSnapshot):status 非 pending 或 settledAt 是
36
+ * 数字即视为终态;stub/未知形态(null、缺字段)一律判非终态,宁可保留 binding 也不能
37
+ * 误释放活动任务的 ownership。
38
+ */
39
+ function managerTaskIsTerminal(taskView) {
40
+ if (taskView === null || typeof taskView !== "object") return false;
41
+ if (typeof taskView.settledAt === "number") return true;
42
+ return typeof taskView.status === "string" && taskView.status !== "pending";
43
+ }
44
+
45
+ /**
46
+ * 第 attempt 次持久化尝试失败后的退避等待时长(毫秒):baseDelayMs * 2^(attempt-1),
47
+ * 并受 maxDelayMs 封顶。
48
+ *
49
+ * 纯函数:不读时钟、不 sleep、不持有状态,退避节奏因此可以被确定性断言;真实等待由
50
+ * 注入的 sleep 完成(测试可换成延时记录器,不依赖真实定时器)。
51
+ */
52
+ function retryDelayForAttempt(attempt, baseDelayMs, maxDelayMs) {
53
+ const safeAttempt = Number.isSafeInteger(attempt) && attempt > 0 ? attempt : 1;
54
+ const base = Number.isFinite(baseDelayMs) && baseDelayMs >= 0 ? baseDelayMs : DEFAULT_RETRY_BACKOFF.baseDelayMs;
55
+ const cap = Number.isFinite(maxDelayMs) && maxDelayMs >= base ? maxDelayMs : base;
56
+ const raw = base * 2 ** (safeAttempt - 1);
57
+ return Number.isFinite(raw) ? Math.min(cap, raw) : cap;
58
+ }
59
+
60
+ /** 生产用的退避等待:真实定时器;delayMs <= 0 时立即返回,不占用事件循环。 */
61
+ function defaultRetrySleep(delayMs) {
62
+ if (!(delayMs > 0)) return Promise.resolve();
63
+ return new Promise((resolve) => { setTimeout(resolve, delayMs); });
64
+ }
65
+
66
+ /**
67
+ * 快照任务是否已经全部终态:只有非终态恢复任务需要保留 ownership。
68
+ * 参数命名为 snapshotTask,避免与外层恢复循环里的 `task` 同名(TDZ 陷阱)。
69
+ *
70
+ * 终态判定与 fanout.js 的 shard 状态集合保持一致:最后一个 attempt 是终态时,
71
+ * 恢复后的 shard 仍是终态(running attempt 会被恢复流程改写为 interrupted)。
72
+ */
73
+ function snapshotTaskIsTerminal(snapshotTask) {
74
+ const shards = Array.isArray(snapshotTask?.shards) ? snapshotTask.shards : [];
75
+ if (shards.length === 0) return typeof snapshotTask?.settledAt === "number";
76
+ return shards.every((shard) => {
77
+ const attempts = Array.isArray(shard?.attempts) ? shard.attempts : [];
78
+ const latest = attempts[attempts.length - 1];
79
+ const status = latest?.status ?? shard?.status;
80
+ return TERMINAL_SHARD_STATUSES.has(status);
81
+ });
82
+ }
83
+
84
+ /**
85
+ * @param {object} options
86
+ * @param {Function} options.createRuntime - (runtimeOptions) => createFanoutRuntime 实例。
87
+ * @param {object} [options.runtimeOptions] - concurrencyLimit/writeRoles 等。
88
+ * concurrencyLimit 支持传解析函数:由 manager 在每次新建父任务读取一次当前 settings,
89
+ * 这样设置变更只影响之后创建的任务,已存在(含恢复)任务沿用创建时的限制。
90
+ * @param {Function} options.buildRequest - 每个 shard 的请求装配。
91
+ * @param {Function} options.startContinuable
92
+ * @param {Function} [options.interrupt]
93
+ * @param {object} options.lineage - 父任务 ownership 小接口。
94
+ * @param {Function} options.getAgent - agents.get(id)。
95
+ * @param {Function} options.buildFeedback - (task) => 注入给父会话的反馈文本。
96
+ * @param {Function} options.injectFeedback - (parent, text) => void。
97
+ * @param {Function} [options.onParentSettled] - 父任务真实终态回调
98
+ * (parentTaskId, parentAgent, parentAgentId) => void;exact Agent 在 lineage 活动绑定
99
+ * 释放前已捕获,回调本身在归还之后触发,宿主据此释放该 fan-out 任务登记的 goal
100
+ * 等待原因。closed/restoring/非终态一律不调用。
101
+ * @param {Function} [options.writeSnapshot] - 持久化合并后的快照。
102
+ * @param {Function} [options.readSnapshot] - 读取最近一次快照。
103
+ * @param {object} [options.persistenceHealth] - 持久化健康状态注入 seam。
104
+ * @param {object} [options.retryBackoff] - 瞬时持久化失败的退避 seam:
105
+ * { baseDelayMs, maxDelayMs, sleep };sleep 可注入记录器以确定性断言退避节奏。
106
+ * @param {Function} [options.getPersistenceGeneration] - 获取当前 storage generation。
107
+ * @param {object} [options.logger]
108
+ */
109
+ export function createFanoutRuntimeModule({
110
+ createRuntime,
111
+ runtimeOptions = {},
112
+ /** 动态有效的 per-role 默认并发上限(settings 解析函数);缺省时沿用 runtimeOptions。 */
113
+ concurrencyLimit,
114
+ buildRequest,
115
+ startContinuable,
116
+ continueMessage,
117
+ resolveChildSession,
118
+ interrupt,
119
+ onAgentCreated,
120
+ lineage,
121
+ getAgent,
122
+ buildFeedback,
123
+ injectFeedback,
124
+ onParentSettled,
125
+ writeSnapshot,
126
+ readSnapshot,
127
+ persistenceHealth: injectedPersistenceHealth,
128
+ retryBackoff,
129
+ getPersistenceGeneration,
130
+ logger,
131
+ } = {}) {
132
+ if (typeof createRuntime !== "function") throw new TypeError("createFanoutRuntimeModule 需要 createRuntime");
133
+ if (lineage === null || typeof lineage !== "object"
134
+ || typeof lineage.bindParentTask !== "function" || typeof lineage.parentAgentOf !== "function"
135
+ || typeof lineage.allocateParentTaskId !== "function" || typeof lineage.releaseParentTask !== "function"
136
+ || typeof lineage.deferredTasks !== "function" || typeof lineage.parentSessionsSnapshot !== "function") {
137
+ throw new TypeError("createFanoutRuntimeModule 需要 lineage 小接口");
138
+ }
139
+ if (typeof getAgent !== "function") throw new TypeError("createFanoutRuntimeModule 需要 getAgent");
140
+ if (typeof buildRequest !== "function") throw new TypeError("createFanoutRuntimeModule 需要 buildRequest");
141
+
142
+ let runtime;
143
+ let closed = false;
144
+ let restoring = false;
145
+ let restorePending;
146
+ /**
147
+ * 本次进程内由 restore() 建立 ownership 的父任务 id。
148
+ * 恢复任务的 ownership 必须保留到真实终态或关闭路径释放:RPC 查询、
149
+ * 事件归属过滤与持久化投影都依赖 lineage 的 parentSessions 映射;
150
+ * 恢复时立即释放会让任务无法寻址,并让后续 snapshot 丢掉 parentSessions。
151
+ * 只跟踪本进程内恢复的任务,不使用“bind 时就记账”的写法——submit 的绑定由
152
+ * manager 快照路径负责,避免在这里维护第二套所有权真相。
153
+ */
154
+ const restoredOwnership = new Set();
155
+ /**
156
+ * 已终态、且活动 ownership 已到期的恢复任务 -> 父会话 id。
157
+ *
158
+ * 只保存 parentSessions 索引(与持久化快照同形):任务数据永远来自 manager 的权威
159
+ * listTasks()/getTask(),这里不复制 task/result,也不做第二套任务数据库。
160
+ *
161
+ * 为什么需要它:lineage 的 parentSessions 只表达**活动**绑定(RPC 寻址、反馈投递、
162
+ * 事件归属过滤都依赖它),终态任务在 restore()/真实终态时必须释放,这是既有语义。
163
+ * 但任务树仍要按根列出「已恢复或持久化下来的终态 fan-out」,否则冷启动后这些任务
164
+ * 会从任何根的任务树中消失。归属解析顺序:lineage 当前绑定 → 本索引。
165
+ */
166
+ const settledParentSessions = new Map();
167
+ /**
168
+ * 任务创建/恢复时快照的父会话归属:parentTaskId -> parentSessionId。
169
+ *
170
+ * 为什么需要第三份索引:lineage 的 parentSessions 只表达**活动**绑定,root 被销毁时
171
+ * (index.js 走 lineage.releaseAgent)会按父会话整批摘除。若 root 先销毁、任务后结算,
172
+ * 归还路径就再也解析不到归属——持久化投影丢映射,重启后该任务从任务树消失(审核发现 04)。
173
+ *
174
+ * 边界:这份快照只用于**归还路径的兜底**,绝不参与 resolvedOwner 的寻址与访问控制
175
+ * (那必须 fail-closed 地只看活动绑定,见 index.js 的 sessionId 校验);任务在归还
176
+ * 路径结算后立即删除,因此长会话不会随历史任务无限增长。
177
+ */
178
+ const ownershipSnapshot = new Map();
179
+ /**
180
+ * 冷恢复读凭据:parentTaskId -> { agent, token, ownerId }。
181
+ *
182
+ * 只由 `restore()` 为一个**本进程确实恢复过**的任务写入,并绑定当时的 exact root Agent
183
+ * 对象与它的 generation token。用途仅限 continuation 的只读幂等重放:让「冷恢复后同一
184
+ * requestId 仍能回答 duplicate=true」有明确的恢复来源证据,而不是靠字符串 parent id 或
185
+ * settledParentSessions 兜底(后者无法区分合法冷恢复与同 id replacement)。
186
+ * 不参与任何授权写决策;close() 时整体丢弃。
187
+ */
188
+ const restoreReadCredentials = new Map();
189
+ const persistenceHealth = injectedPersistenceHealth === undefined
190
+ ? createPersistenceHealth()
191
+ : injectedPersistenceHealth;
192
+ if (persistenceHealth === null || typeof persistenceHealth !== "object"
193
+ || typeof persistenceHealth.beginDrain !== "function"
194
+ || typeof persistenceHealth.endDrain !== "function"
195
+ || typeof persistenceHealth.recordSuccess !== "function"
196
+ || typeof persistenceHealth.recordFailure !== "function"
197
+ || typeof persistenceHealth.retryDecision !== "function"
198
+ || typeof persistenceHealth.snapshot !== "function") {
199
+ throw new TypeError("createFanoutRuntimeModule 需要 persistenceHealth 小接口");
200
+ }
201
+
202
+ /**
203
+ * 瞬时/代际失效持久化失败的退避参数与等待实现。
204
+ *
205
+ * sleep 可注入:测试用延时记录器替代真实定时器,从而对退避节奏做确定性断言,不依赖
206
+ * 真实等待;生产环境用真实 setTimeout。退避只作用于 transient / generation-stale 这类
207
+ * 可重试失败,容量与永久失败在 retryDecision 处直接收口,不进入等待。
208
+ */
209
+ const retryBackoffConfig = {
210
+ baseDelayMs: Number.isFinite(retryBackoff?.baseDelayMs) && retryBackoff.baseDelayMs >= 0
211
+ ? retryBackoff.baseDelayMs
212
+ : DEFAULT_RETRY_BACKOFF.baseDelayMs,
213
+ maxDelayMs: Number.isFinite(retryBackoff?.maxDelayMs) && retryBackoff.maxDelayMs >= 0
214
+ ? retryBackoff.maxDelayMs
215
+ : DEFAULT_RETRY_BACKOFF.maxDelayMs,
216
+ sleep: typeof retryBackoff?.sleep === "function" ? retryBackoff.sleep : defaultRetrySleep,
217
+ };
218
+
219
+ /**
220
+ * 动态默认并发上限的解析函数。
221
+ *
222
+ * 只接受函数或数字:Host 传 settings 读取闭包,测试可传固定数字;非法类型直接
223
+ * 忽略(不能让模块装配把配置错误变成运行期调度异常)。
224
+ */
225
+ const dynamicConcurrencyLimit = typeof concurrencyLimit === "function" || typeof concurrencyLimit === "number"
226
+ ? concurrencyLimit
227
+ : undefined;
228
+
229
+ const warn = (message) => {
230
+ try { logger?.warn?.(message); } catch { /* 日志失败不影响调度 */ }
231
+ };
232
+ const closedResult = () => ({ accepted: false, changed: false, code: "RUNTIME_CLOSED", reason: "runtime_closed" });
233
+
234
+ /**
235
+ * 父任务归属解析:优先当前代际的活动绑定,其次终态任务的持久化归属索引。
236
+ * 两者都只回答「这个父任务属于哪个根会话」,不回答任务是否还在活动。
237
+ */
238
+ function resolvedOwner(parentTaskId) {
239
+ const active = typeof lineage.resolveOwner === "function"
240
+ ? lineage.resolveOwner(parentTaskId)
241
+ : (typeof lineage.parentSessionOf === "function" ? lineage.parentSessionOf(parentTaskId) : undefined);
242
+ if (typeof active === "string" && active.length > 0) return active;
243
+ const settled = settledParentSessions.get(parentTaskId);
244
+ return typeof settled === "string" && settled.length > 0 ? settled : null;
245
+ }
246
+
247
+ /**
248
+ * 归还路径的归属解析:先活动绑定(与寻址/事件过滤同一语义),活动绑定已消失时
249
+ * 回退到任务创建/恢复时快照的父会话归属。
250
+ *
251
+ * 回退只服务「写 settled 索引」这一件事:root 先被销毁时 lineage 已无绑定,若直接
252
+ * 放弃,持久化投影就丢映射,重启后该任务从任务树消失。
253
+ */
254
+ function returnOwner(parentTaskId) {
255
+ const active = resolvedOwner(parentTaskId);
256
+ if (typeof active === "string" && active.length > 0) return active;
257
+ const snapshotOwner = ownershipSnapshot.get(parentTaskId);
258
+ return typeof snapshotOwner === "string" && snapshotOwner.length > 0 ? snapshotOwner : null;
259
+ }
260
+
261
+ /**
262
+ * continuation 归属拒绝的可诊断分类(只读、不泄露任务负载)。
263
+ *
264
+ * 只用于 Host 侧日志/结构化解构,不参与任何授权决策:授权只看
265
+ * 「活动绑定 + exact parent Agent + 绑定代际 token 仍 live」这三个硬条件。
266
+ */
267
+ function describeContinuationOwnership({ owner, boundAgent, parent, bindingToken }) {
268
+ if (typeof owner !== "string" || owner.length === 0) return "no_active_binding";
269
+ if (owner !== parent?.id) return "owner_mismatch";
270
+ if (boundAgent === undefined) return "parent_agent_unresolved";
271
+ if (boundAgent !== parent) return "parent_agent_replaced";
272
+ if (bindingToken === undefined) return "generation_evidence_missing";
273
+ if (bindingToken.agent !== parent) return "generation_owner_mismatch";
274
+ return "generation_not_live";
275
+ }
276
+
277
+ /**
278
+ * continuation 只读幂等重放的证据判定。
279
+ *
280
+ * 两级证据,都要求「exact Agent 对象 + 仍然 live 的 generation token」,绝不接受单纯的
281
+ * 字符串 parent id 或 settledParentSessions 兜底:
282
+ * 1. `active_binding`:同进程的活动绑定(owner 命中活动 parentSessions、绑定的 exact
283
+ * parent Agent 就是调用方、绑定时的 generation token 仍 live);
284
+ * 2. `restore_credential`:冷恢复时由 restore() 记录的 exact Agent + token(同 id 的
285
+ * replacement 不是凭据里的对象,因此拿不到读权限)。
286
+ *
287
+ * 返回 null 表示没有可证明的来源 → 调用方必须结构化拒绝(fail-closed)。
288
+ */
289
+ function continuationReplayEvidence(parent, parentTaskId, activeBinding) {
290
+ if (parent === undefined || parent === null || typeof parent.id !== "string") return null;
291
+ if (activeBinding !== null) return activeBinding;
292
+ const credential = restoreReadCredentials.get(parentTaskId);
293
+ if (credential === undefined || credential.agent !== parent || credential.token === undefined) return null;
294
+ if (typeof credential.ownerId === "string" && credential.ownerId !== parent.id) return null;
295
+ return lineage.isLiveGeneration(parent, credential.token) ? "restore_credential" : null;
296
+ }
297
+
298
+ /**
299
+ * 活动绑定的读证据(与写路径同一套硬条件):owner + exact parent Agent + 绑定代际 token。
300
+ * 只回答「这次读取是否有可证明的来源」,不回答任务是否可写。
301
+ */
302
+ function activeBindingReplayEvidence(parent, parentTaskId) {
303
+ const owner = typeof lineage.resolveOwner === "function" ? lineage.resolveOwner(parentTaskId) : undefined;
304
+ if (typeof owner !== "string" || owner !== parent.id) return null;
305
+ const boundAgent = typeof lineage.parentAgentOf === "function" ? lineage.parentAgentOf(parentTaskId) : undefined;
306
+ if (boundAgent !== parent) return null;
307
+ const bindingToken = typeof lineage.parentTokenOf === "function" ? lineage.parentTokenOf(parentTaskId) : undefined;
308
+ if (bindingToken === undefined || bindingToken.agent !== parent) return null;
309
+ return lineage.isLiveGeneration(parent, bindingToken) ? "active_binding" : null;
310
+ }
311
+
312
+ /**
313
+ * 归还父任务归属:把归属写进 settled 索引并把创建/恢复时的快照用完即删。
314
+ *
315
+ * settled 索引是任务树与持久化投影的归属来源:lineage 释放活动绑定后,任务数据仍由
316
+ * manager 权威持有,但只有靠这份索引才能按根找回(否则重启后丢节点)。
317
+ *
318
+ * @param {string} parentTaskId
319
+ * @param {boolean} [releaseBinding] - 是否同时释放 lineage 的活动绑定。三个真实释放点
320
+ * (settle 事件的真实终态、restore 窗口内的同步收口、close)传 true;submit 的任务
321
+ * 沿用既有语义不在这里释放——它的绑定由 root 销毁/关闭路径负责,保持 RPC 的
322
+ * fail-closed 行为不变,也避免二次释放。
323
+ */
324
+ function settleOwnership(parentTaskId, releaseBinding = false) {
325
+ const owner = returnOwner(parentTaskId);
326
+ if (typeof owner === "string" && owner.length > 0) settledParentSessions.set(parentTaskId, owner);
327
+ ownershipSnapshot.delete(parentTaskId);
328
+ if (releaseBinding) {
329
+ restoredOwnership.delete(parentTaskId);
330
+ lineage.releaseParentTask(parentTaskId);
331
+ }
332
+ }
333
+
334
+ const api = {
335
+ /** 提交父任务:分配 id、绑定 ownership、失败时释放。 */
336
+ submit(parent, args = {}) {
337
+ if (closed) return { ...closedResult(), shardIds: [] };
338
+ const parentTaskId = lineage.allocateParentTaskId(parent);
339
+ lineage.bindParentTask({ parentTaskId, parentId: parent.id, parentAgent: parent });
340
+ // 创建时快照归属:root 早于任务结算被销毁时,lineage 的活动绑定已被整批摘除,
341
+ // 归还路径只能靠这份快照把归属写回 settled 索引(任务树与重启投影都依赖它)。
342
+ ownershipSnapshot.set(parentTaskId, parent.id);
343
+ try {
344
+ const result = runtime.manager.submit({ parentTaskId, ...args });
345
+ if (result.parentTaskId !== parentTaskId) {
346
+ lineage.releaseParentTask(parentTaskId);
347
+ ownershipSnapshot.delete(parentTaskId);
348
+ }
349
+ return { ...result, task: runtime.manager.getTask(result.parentTaskId) };
350
+ } catch (error) {
351
+ lineage.releaseParentTask(parentTaskId);
352
+ ownershipSnapshot.delete(parentTaskId);
353
+ throw error;
354
+ }
355
+ },
356
+ /** manager 事件回调:父任务终态后向父会话注入一次聚合反馈。 */
357
+ parentSettled(event) {
358
+ if (closed || restoring || event?.type !== "task_settled") return false;
359
+ const parentTaskId = event.parentTaskId;
360
+ if (typeof parentTaskId !== "string" || parentTaskId.length === 0) return false;
361
+ // 父 Agent 必须**先**捕获:下面的归还路径会释放 lineage 绑定,释放后再查就只剩
362
+ // undefined。反馈投递本身走 microtask,用捕获的 exact 对象与已算好的文本。
363
+ const parent = lineage.parentAgentOf(parentTaskId);
364
+ const task = runtime.manager.getTask(parentTaskId);
365
+ if (task === null) return false;
366
+ // 归属结算先于反馈投递,且不依赖父 Agent 是否还活着:root 先被销毁时 parentAgentOf
367
+ // 返回 undefined,但归属仍必须写进 settled 索引。事件类型是终态边界,这里再用
368
+ // manager 状态复核一次,避免外部误传 task_settled(测试 seam)把活动任务提前归还。
369
+ // 恢复任务的 ownership 到这里才到期:aggregateTask 只对从 pending 进入终态的结算
370
+ // 发布 task_settled,因此这是「真实终态」的唯一边界;已终态的恢复任务直接在
371
+ // restore() 内归还(不会再有任何事件把它带回活动态)。
372
+ if (managerTaskIsTerminal(task)) {
373
+ // 归属 id 必须在归还前解析:settleOwnership 会消费 ownershipSnapshot 并清掉
374
+ // 活动绑定,之后再查就只剩 settled 索引(root 已销毁时需要它兜底定位)。
375
+ const ownerId = returnOwner(parentTaskId);
376
+ // 恢复任务的活动 ownership 到真实终态才到期;submit 的任务保持既有绑定语义。
377
+ settleOwnership(parentTaskId, restoredOwnership.has(parentTaskId));
378
+ // goal 等待原因必须由真实终态边界释放。这里传的是已捕获的 exact Agent(而不是
379
+ // 重新按 id 解析),因为宿主侧 release 需要对象身份一致才认;ownerId 只是
380
+ // exact Agent 已不可用时的兜底根 id。非终态的伪 task_settled 走不到这里,
381
+ // 因此不会提前释放仍在活动的等待原因。
382
+ if (typeof onParentSettled === "function") {
383
+ try {
384
+ onParentSettled(parentTaskId, parent, typeof ownerId === "string" && ownerId.length > 0 ? ownerId : undefined);
385
+ } catch (error) {
386
+ warn("dsh-expert-group: fan-out parent settle hook failed: " + describeError(error));
387
+ }
388
+ }
389
+ }
390
+ if (parent === void 0 || typeof parent.inject !== "function") return false;
391
+ const text = buildFeedback(task);
392
+ // manager 在状态写入路径内同步发布事件;放到 microtask 避免重入 dispatch/Session.append。
393
+ queueMicrotask(() => {
394
+ if (closed || restoring || getAgent(parent.id) !== parent) return;
395
+ try { injectFeedback(parent, text); } catch (error) {
396
+ warn("dsh-expert-group: fan-out parent feedback failed: " + describeError(error));
397
+ }
398
+ });
399
+ return true;
400
+ },
401
+ // 关闭后的迟到 fan-out 事件仍交给 runtime 识别:已知 child/session 返回 true,
402
+ // 这样 index.js 不会把它们误交给旧 recovery;未知对象仍返回 false,保留正常事件路由。
403
+ handleAgentCreated: (agent, persistedIdentity) => runtime.handleAgentCreated(agent, persistedIdentity),
404
+ handleAgentDisposed: (agent, persistedIdentity) => runtime.handleAgentDisposed(agent, persistedIdentity),
405
+ handleSessionEvent: (session, event) => runtime.handleSessionEvent(session, event),
406
+ restoreEvents: (events) => (closed ? false : runtime.restoreEvents(events)),
407
+ whenIdle: () => runtime.manager.whenIdle(),
408
+ /**
409
+ * 只读预算投影(用量 / 冻结上限 / 最近一次违规)。不暴露任何调度内部状态,
410
+ * 关闭后不再提供(与 getTask/listTasks 的关闭语义一致)。
411
+ */
412
+ budget: () => (closed || typeof runtime?.manager?.budget !== "function"
413
+ ? null
414
+ : runtime.manager.budget()),
415
+ getTask: (parentTaskId) => (closed ? null : runtime.manager.getTask(parentTaskId)),
416
+ listTasks: () => (closed ? [] : runtime.manager.listTasks()),
417
+ /**
418
+ * 按根会话过滤 manager 的权威任务(任务树的唯一数据来源)。
419
+ *
420
+ * 与 lineage.tasksOwnedBy 的差别只有一个:这里同时覆盖**活动 ownership 已到期**
421
+ * 的已终态恢复任务。归属仍按 parentSessions 语义解析,因此不会把任务串到别的根,
422
+ * 也不暴露任何跨根历史。
423
+ */
424
+ tasksOwnedBy(sessionId) {
425
+ if (closed || typeof sessionId !== "string" || sessionId.length === 0) return [];
426
+ return runtime.manager.listTasks().filter((task) => {
427
+ const parentTaskId = task?.parentTaskId;
428
+ return typeof parentTaskId === "string" && resolvedOwner(parentTaskId) === sessionId;
429
+ });
430
+ },
431
+ /**
432
+ * 单任务归属解析(C7 冷恢复用):返回当前活动绑定,其次已终态任务的归属索引。
433
+ *
434
+ * 宿主据此判断「这个从快照恢复出来的父任务属于哪个 root,root 是否还存活」;
435
+ * 它只回答归属,不回答任务是否活动(也不要暴露 manager 内部状态)。关闭后
436
+ * 返回 null,与 getTask/listTasks 的关闭语义一致。
437
+ */
438
+ ownerOf(parentTaskId) {
439
+ if (closed || typeof parentTaskId !== "string" || parentTaskId.length === 0) return null;
440
+ return resolvedOwner(parentTaskId);
441
+ },
442
+ getEvents: (options) => (closed
443
+ ? { events: [], nextSequence: Number.isSafeInteger(options?.after) && options.after >= 0 ? options.after : 0 }
444
+ : runtime.getEvents(options)),
445
+ cancelParent: (parentTaskId) => (closed ? closedResult() : runtime.manager.cancelParent(parentTaskId)),
446
+ cancelShard: (parentTaskId, shardId) => (closed ? closedResult() : runtime.manager.cancelShard(parentTaskId, shardId)),
447
+ retryShard: (parentTaskId, shardId) => (closed ? closedResult() : runtime.manager.retryShard(parentTaskId, shardId)),
448
+ /**
449
+ * 多轮复用:对**本 root 拥有的**父任务续作一轮(同一 parentTaskId / childId,新 attempt)。
450
+ *
451
+ * 全部前置条件都在 manager 状态修改**之前**判定,任一不满足即结构化拒绝、不追加 attempt、
452
+ * 不改变任务状态(fail-closed)。判定顺序:
453
+ * 1. 调用方必须是当前代际的 exact live root(同 id replacement / 已销毁 root 一律拒绝);
454
+ * 2. 父任务必须由**当前代际**的活动绑定拥有:仅当 `lineage.resolveOwner` 命中活动
455
+ * parentSessions、绑定的 exact parent Agent 就是调用方、且绑定当时的 generation token
456
+ * 仍然 `isLiveGeneration(parent, token)` 时才成立。**不使用 settledParentSessions 兜底**
457
+ * (那只是冷恢复后的寻址索引,不构成可写权限证据),因此冷恢复后已终态、且 restore
458
+ * 已归还 ownership 的任务不会被误接受后异步失败;
459
+ * 3. 执行路由必须就绪:continueMessage 已接线,且每个分片既有 child 的 session 可解析
460
+ * (否则无法绑定本轮 turn 归属,也会在投递阶段失败)。
461
+ *
462
+ * manager 自己负责 role/shard 结构一致性、上一轮 wait-all 是否完成、attempt/requestId
463
+ * 预算与幂等;这里不重复实现。
464
+ *
465
+ * @param {object} parent - 调用方的 exact root Agent。
466
+ * @param {{parentTaskId:string, role:string, basePrompt:string, shards:object[], requestId?:string}} args
467
+ * @returns {object} 结构化结果(accepted/reason/round/nextAttemptByShard/task)。
468
+ */
469
+ continueTask(parent, args = {}) {
470
+ if (closed) return { ...closedResult() };
471
+ const parentTaskId = typeof args?.parentTaskId === "string" ? args.parentTaskId.trim() : "";
472
+ if (parentTaskId.length === 0) return { accepted: false, changed: false, reason: "missing_parent_task_id" };
473
+ /**
474
+ * 幂等重放(**只读**,不新增 attempt/child/事件):已记录的 requestId 直接返回上次结果。
475
+ *
476
+ * 读权限边界(终审问题 1):重放不是写操作,但同样必须有**明确的来源证据**,不能用
477
+ * 「字符串 parent id 相同」或 settledParentSessions 兜底来授权——否则同 id
478
+ * replacement root 可以读到旧代际的续作结果。只接受两类证据(见
479
+ * continuationReplayEvidence):
480
+ * - 同进程活动绑定 + 绑定代际 token(同一 live root 的正常重放);
481
+ * - 冷恢复时 restore() 记录在案的 exact Agent + 其 generation token(恢复来源证据)。
482
+ * 都没有时**不在这里返回**:继续走下面的写路径前置条件,最终以 `not_owned_by_root`
483
+ * 结构化拒绝(fail-closed),且不产生任何状态修改。
484
+ */
485
+ const replayRequestId = typeof args?.requestId === "string" && args.requestId.length > 0 ? args.requestId : undefined;
486
+ if (replayRequestId !== undefined) {
487
+ const replayEvidence = continuationReplayEvidence(parent, parentTaskId, activeBindingReplayEvidence(parent, parentTaskId));
488
+ if (replayEvidence !== null) {
489
+ const currentTask = runtime.manager.getTask(parentTaskId);
490
+ const recorded = currentTask?.continuationRequests?.[replayRequestId];
491
+ if (recorded !== undefined && recorded !== null && typeof recorded.result === "object") {
492
+ // 重放不触发任何 goal handoff 重新登记(index.js 按 duplicate 标记跳过)。
493
+ return { ...recorded.result, duplicate: true, ...(currentTask === null ? {} : { task: currentTask }) };
494
+ }
495
+ }
496
+ }
497
+ // 1. 调用方代际:token 必须已存在(activateRoot 时铸造)且仍是 exact live generation。
498
+ const callerToken = typeof lineage.tokenOf === "function" ? lineage.tokenOf(parent) : undefined;
499
+ if (callerToken === undefined || !lineage.isLiveGeneration(parent, callerToken)) {
500
+ return { accepted: false, changed: false, reason: "not_live_root", parentTaskId, ownership: "caller_not_live" };
501
+ }
502
+ // 2. 活动 ownership + 绑定代际证据:不做 settled 兜底,也不接受 replacement 继承。
503
+ // 与只读重放共用同一个证据函数(activeBindingReplayEvidence),保证读写边界一致:
504
+ // 读能过 ⇒ exact Agent + 绑定 token 均 live;写还要额外满足执行路由就绪。
505
+ const owner = typeof lineage.resolveOwner === "function" ? lineage.resolveOwner(parentTaskId) : undefined;
506
+ const boundAgent = typeof lineage.parentAgentOf === "function" ? lineage.parentAgentOf(parentTaskId) : undefined;
507
+ const bindingToken = typeof lineage.parentTokenOf === "function" ? lineage.parentTokenOf(parentTaskId) : undefined;
508
+ if (activeBindingReplayEvidence(parent, parentTaskId) === null) {
509
+ return {
510
+ accepted: false,
511
+ changed: false,
512
+ reason: "not_owned_by_root",
513
+ parentTaskId,
514
+ ownership: describeContinuationOwnership({ owner, boundAgent, parent, bindingToken }),
515
+ };
516
+ }
517
+ // 3. 任务存在 + 执行路由就绪(两者都只读)。
518
+ const task = runtime.manager.getTask(parentTaskId);
519
+ if (task === null) return { accepted: false, changed: false, reason: "unknown_parent_task", parentTaskId };
520
+ const readiness = typeof runtime.continuationReadiness === "function"
521
+ ? runtime.continuationReadiness(task)
522
+ : { ok: false, reason: "continue_route_unavailable", shardIds: [] };
523
+ if (readiness?.ok !== true) {
524
+ return {
525
+ accepted: false,
526
+ changed: false,
527
+ reason: typeof readiness?.reason === "string" ? readiness.reason : "continue_route_unavailable",
528
+ parentTaskId,
529
+ shardIds: Array.isArray(readiness?.shardIds) ? readiness.shardIds : [],
530
+ };
531
+ }
532
+ const result = runtime.manager.continueTask({ ...args, parentTaskId });
533
+ const nextTask = runtime.manager.getTask(parentTaskId);
534
+ return { ...result, ...(nextTask === null ? {} : { task: nextTask }) };
535
+ },
536
+ /**
537
+ * 恢复快照:只恢复当前已存在的 root 任务;缺失 root 的 queued 任务留在
538
+ * lineage deferred 中,等 root 出现后再恢复。
539
+ */
540
+ restore() {
541
+ if (closed) return { ...closedResult(), restored: 0, deferred: 0, pending: restorePending === true };
542
+ if (typeof readSnapshot !== "function") return { restored: 0, deferred: 0, pending: restorePending === true };
543
+ const snapshot = readSnapshot();
544
+ if (snapshot === void 0 || snapshot === null || !Array.isArray(snapshot.tasks) || snapshot.tasks.length === 0) {
545
+ return { restored: 0, deferred: 0, pending: restorePending === true };
546
+ }
547
+ const existingTaskIds = new Set(runtime.manager.listTasks().map((task) => task.parentTaskId));
548
+ const restorableTasks = [];
549
+ let deferredCount = 0;
550
+ for (const task of snapshot.tasks) {
551
+ if (existingTaskIds.has(task?.parentTaskId)) continue;
552
+ const parentId = lineage.parentSessionOf(task?.parentTaskId);
553
+ if (taskNeedsRoot(task) && (typeof parentId !== "string" || getAgent(parentId) === void 0)) {
554
+ deferredCount += 1;
555
+ lineage.deferTask({ parentTaskId: task.parentTaskId, parentId, task });
556
+ continue;
557
+ }
558
+ if (typeof parentId === "string") {
559
+ lineage.bindParentTask({ parentTaskId: task.parentTaskId, parentId, parentAgent: getAgent(parentId) });
560
+ }
561
+ restorableTasks.push(task);
562
+ }
563
+ // 快照被外部替换时移除已不存在的 deferred 条目;不能按字符串 id 复活旧代际。
564
+ const snapshotTaskIds = new Set(snapshot.tasks.map((task) => task?.parentTaskId));
565
+ for (const parentTaskId of lineage.deferredTasks().map((task) => task.parentTaskId)) {
566
+ if (!snapshotTaskIds.has(parentTaskId)) lineage.releaseParentTask(parentTaskId);
567
+ }
568
+ restorePending = deferredCount > 0 || lineage.deferredTasks().length > 0;
569
+ if (restorableTasks.length === 0) return { restored: 0, deferred: deferredCount, pending: restorePending };
570
+ restoring = true;
571
+ try {
572
+ // resume: true 让唯一调度状态机按原规则继续执行未终态 work(queued attempt 复用、
573
+ // running→interrupted 换新 attempt、role concurrency / affectedPaths / 取消意图
574
+ // 全部由 manager 自己判定);本模块只负责 ownership 与寻址。
575
+ runtime.manager.restore({ ...snapshot, tasks: restorableTasks }, { resume: true, merge: existingTaskIds.size > 0 });
576
+ const snapshotParents = snapshot.parentSessions !== null && typeof snapshot.parentSessions === "object"
577
+ && !Array.isArray(snapshot.parentSessions) ? snapshot.parentSessions : {};
578
+ for (const task of restorableTasks) {
579
+ // 非终态任务保持 ownership:它仍可能被 RPC 查询、取消/重试、事件过滤或
580
+ // 聚合反馈寻址,释放只能发生在真实终态或关闭路径。
581
+ const parentTaskId = task?.parentTaskId;
582
+ if (typeof parentTaskId !== "string") continue;
583
+ // 顺序不可颠倒:必须在 lineage 释放之前取归属,否则只剩快照可查。
584
+ const owner = resolvedOwner(parentTaskId) ?? snapshotParents[parentTaskId];
585
+ if (typeof owner === "string" && owner.length > 0) ownershipSnapshot.set(parentTaskId, owner);
586
+ /**
587
+ * 冷恢复读凭据:本次 restore 究竟把该任务恢复给了**哪个 exact root Agent**。
588
+ *
589
+ * 只在「本进程确实从这个快照恢复过该任务」时记录,并把凭据绑定到当时的 exact
590
+ * Agent 对象与它的 generation token。它只服务 continuation 的**只读幂等重放**
591
+ * (duplicate 回执),不构成任何写权限证据:
592
+ * - 同 id replacement R2 不是凭据里的 agent 对象 → 拒绝;
593
+ * - 凭据 agent 被销毁/替换(token 失活)→ `isLiveGeneration` 为假 → 拒绝;
594
+ * - 没有凭据(例如 RPC 读路径只看到 settledParentSessions)→ 拒绝。
595
+ * 非终态任务另有活动绑定,重放优先走绑定证据;凭据对它们只是冗余兜底。
596
+ */
597
+ const parentId = typeof owner === "string" && owner.length > 0 ? owner : lineage.parentSessionOf(parentTaskId);
598
+ const restoredAgent = typeof parentId === "string" ? getAgent(parentId) : undefined;
599
+ if (restoredAgent !== undefined && typeof lineage.tokenFor === "function") {
600
+ const token = lineage.tokenFor(restoredAgent);
601
+ if (token !== undefined) {
602
+ restoreReadCredentials.set(parentTaskId, { agent: restoredAgent, token, ownerId: parentId });
603
+ }
604
+ }
605
+ // manager 可能在**恢复窗口内**就把任务聚合到终态(restoringSnapshot 会抑制
606
+ // task_settled,此后不会再有事件把它带回来),因此这里按 manager 的权威状态
607
+ // 复核一次并立即归还:否则归属 id 会一直滞留到 close(轻微泄漏),持久化
608
+ // 投影也只能靠活动绑定而拿不到映射。
609
+ const managerTask = typeof runtime?.manager?.getTask === "function"
610
+ ? runtime.manager.getTask(parentTaskId)
611
+ : null;
612
+ if (snapshotTaskIsTerminal(task) || managerTaskIsTerminal(managerTask)) {
613
+ // 这里释放的是本次 restore 刚建立的绑定,因此无条件释放(不依赖记账集合)。
614
+ settleOwnership(parentTaskId, true);
615
+ } else {
616
+ restoredOwnership.add(parentTaskId);
617
+ }
618
+ }
619
+ restorePending = lineage.deferredTasks().length > 0;
620
+ return { restored: restorableTasks.length, deferred: deferredCount, pending: restorePending };
621
+ } catch (error) {
622
+ warn("dsh-expert-group: fan-out snapshot restore failed: " + describeError(error));
623
+ return { restored: 0, deferred: deferredCount, pending: restorePending };
624
+ } finally {
625
+ restoring = false;
626
+ }
627
+ },
628
+ /** 持久化投影:合并 deferred 任务的 parentSessions,避免丢失不可寻址任务。 */
629
+ snapshotForStorage(snapshot) {
630
+ const parentSessions = lineage.parentSessionsSnapshot();
631
+ const tasksById = new Map((Array.isArray(snapshot?.tasks) ? snapshot.tasks : []).map((task) => [task?.parentTaskId, task]));
632
+ for (const task of lineage.deferredTasks()) {
633
+ const parentTaskId = task?.parentTaskId;
634
+ if (!tasksById.has(parentTaskId)) tasksById.set(parentTaskId, task);
635
+ const parentId = lineage.deferredParentOf(parentTaskId);
636
+ if (typeof parentId === "string" && parentId.length > 0) parentSessions[parentTaskId] = parentId;
637
+ }
638
+ // 已终态任务的归属是**寻址索引**而非活动 ownership:lineage 已按既有语义释放
639
+ // (或在 root 销毁时被整批摘除),但持久化必须保留它,否则重启后这些任务不再属于
640
+ // 任何根(冷终态 fan-out 与 root 销毁后结算的任务都会从任务树消失)。
641
+ for (const [parentTaskId, parentId] of settledParentSessions) {
642
+ if (parentSessions[parentTaskId] === undefined) parentSessions[parentTaskId] = parentId;
643
+ }
644
+ return { ...snapshot, tasks: [...tasksById.values()], parentSessions };
645
+ },
646
+ /**
647
+ * 落盘旁路:对同一次提交只做有界重试。generation stale 会重新读取 manager 最新快照
648
+ * 合并后再写;健康记录只在内存中更新,不触发新的 persistence 回调。
649
+ */
650
+ async persist(snapshot) {
651
+ if (typeof writeSnapshot !== "function") return false;
652
+ let payload = api.snapshotForStorage(snapshot);
653
+ const token = persistenceHealth.beginDrain({
654
+ generation: typeof getPersistenceGeneration === "function" ? getPersistenceGeneration() : payload.generation,
655
+ sequence: payload.sequence,
656
+ });
657
+ let attempt = 1;
658
+ try {
659
+ while (true) {
660
+ const generation = typeof getPersistenceGeneration === "function" ? getPersistenceGeneration() : payload.generation;
661
+ try {
662
+ const written = await writeSnapshot(payload);
663
+ if (written !== true && written?.ok !== true) {
664
+ const writeError = written?.ok === false ? written.error : written;
665
+ if (writeError instanceof Error) throw writeError;
666
+ if (writeError !== null && typeof writeError === "object" && !Array.isArray(writeError)) throw writeError;
667
+ throw { category: "permanent", message: "持久化写入未返回成功确认" };
668
+ }
669
+ persistenceHealth.recordSuccess({ generation, sequence: payload.sequence });
670
+ return true;
671
+ } catch (error) {
672
+ const failure = persistenceHealth.recordFailure({ error, generation, sequence: payload.sequence, attempt });
673
+ const decision = persistenceHealth.retryDecision({ category: failure.category, attempt });
674
+ if (!decision.retry) {
675
+ warn("dsh-expert-group: fan-out snapshot persistence failed [" + failure.category + "]: " + describeError(error));
676
+ return false;
677
+ }
678
+ // 指数退避:第 attempt 次失败后等待 base * 2^(attempt-1)(封顶 maxDelayMs)。
679
+ // 容量/永久失败在上面的分支直接收口,不会进入等待;这里的等待保证存储抖动时
680
+ // 不形成无退避的重写紧循环。等待时长是纯计算量,sleep 可注入记录器以便测试
681
+ // 对退避节奏做确定性断言(不依赖真实定时器)。
682
+ const backoffMs = retryDelayForAttempt(attempt, retryBackoffConfig.baseDelayMs, retryBackoffConfig.maxDelayMs);
683
+ try {
684
+ await retryBackoffConfig.sleep(backoffMs);
685
+ } catch (waitError) {
686
+ // 注入的等待实现异常不能让持久化旁路变成未处理拒绝:退避失败等价于立即重试。
687
+ warn("dsh-expert-group: fan-out persistence retry backoff failed: " + describeError(waitError));
688
+ }
689
+ attempt = decision.nextAttempt;
690
+ if (decision.merge && typeof runtime?.manager?.snapshot === "function") {
691
+ try {
692
+ const previous = payload;
693
+ const latest = api.snapshotForStorage(runtime.manager.snapshot());
694
+ const previousSequence = Number.isSafeInteger(previous?.sequence) ? previous.sequence : 0;
695
+ const latestSequence = Number.isSafeInteger(latest?.sequence) ? latest.sequence : 0;
696
+ const events = new Map();
697
+ for (const event of Array.isArray(previous?.events) ? previous.events : []) {
698
+ if (Number.isSafeInteger(event?.sequence)) events.set(event.sequence, event);
699
+ }
700
+ for (const event of Array.isArray(latest?.events) ? latest.events : []) {
701
+ if (Number.isSafeInteger(event?.sequence)) events.set(event.sequence, event);
702
+ }
703
+ payload = {
704
+ ...latest,
705
+ sequence: Math.max(previousSequence, latestSequence),
706
+ events: [...events.values()].sort((left, right) => left.sequence - right.sequence),
707
+ };
708
+ } catch (mergeError) {
709
+ warn("dsh-expert-group: fan-out snapshot merge failed: " + describeError(mergeError));
710
+ }
711
+ }
712
+ }
713
+ }
714
+ } finally {
715
+ persistenceHealth.endDrain(token);
716
+ }
717
+ },
718
+ describe: () => ({
719
+ closed,
720
+ restorePending: restorePending === true,
721
+ activeAttempts: closed ? 0 : runtime.activeAttemptCount,
722
+ tasks: closed ? 0 : runtime.manager.listTasks().length,
723
+ // 已终态任务的归属索引(只读观测,不参与调度):包含已释放活动 ownership 的恢复
724
+ // 任务,以及 root 先销毁、任务后结算的任务。
725
+ settledTasks: settledParentSessions.size,
726
+ persistenceHealth: persistenceHealth.snapshot(),
727
+ // 只读并发视图(有效默认值 / 各 role 覆盖 / 解析来源):让"设置页显示的并发上限"
728
+ // 与实际调度值在同一处可诊断,不暴露任务负载。
729
+ ...(typeof runtime?.manager?.concurrencyLimits === "function"
730
+ ? { concurrencyLimits: runtime.manager.concurrencyLimits() }
731
+ : {}),
732
+ // 只读预算视图(用量 / 冻结上限 / 最近一次违规);关闭后不再有意义。
733
+ ...(typeof runtime?.manager?.budget === "function" ? { budget: runtime.manager.budget() } : {}),
734
+ }),
735
+ /**
736
+ * 显式落盘一次当前快照(C7 归属证据变化后由宿主调用)。
737
+ *
738
+ * 为什么需要显式入口:goal handoff 归属证据(插件自己暂停过哪个 goal、暂停时的
739
+ * exact revision、覆盖哪些父任务)不在 manager 的状态机里,manager 的状态变化
740
+ * 不会触发它落盘。证据只在两个时刻变化(pause 建立 / release 完成),因此这里的
741
+ * 显式 flush 不会被高频调用;它复用既有 persist 旁路(合并、退避、健康记录、
742
+ * generation 校验都不复制)。
743
+ *
744
+ * 关闭后返回 false:卸载路径不得再发起新的 domain 写入。
745
+ * @returns {Promise<boolean>} 是否成功落盘。
746
+ */
747
+ flushSnapshot() {
748
+ if (closed) return Promise.resolve(false);
749
+ try {
750
+ return Promise.resolve(api.persist(runtime.manager.snapshot())).then((written) => written === true, (error) => {
751
+ warn("dsh-expert-group: fan-out snapshot flush failed: " + describeError(error));
752
+ return false;
753
+ });
754
+ } catch (error) {
755
+ warn("dsh-expert-group: fan-out snapshot flush failed: " + describeError(error));
756
+ return Promise.resolve(false);
757
+ }
758
+ },
759
+ /**
760
+ * 幂等关闭:先封锁本模块入口,再让 manager 写入关闭终态并由 runtime 处理中断。
761
+ * manager 必须先收敛,避免 runtime 的 deferred reject 被误判成普通 failed。
762
+ */
763
+ close() {
764
+ if (closed) return false;
765
+ closed = true;
766
+ // 关闭是 ownership 的另一个真实释放点:模块已封锁,不可能再有 settle 事件。
767
+ // 归还走 settleOwnership:manager.close() 会把仍未终态的恢复任务写成 cancelled,
768
+ // 且关闭时的最终持久化投影仍要按 settled 索引取归属,否则重启后这些任务丢节点。
769
+ for (const parentTaskId of [...restoredOwnership]) settleOwnership(parentTaskId, true);
770
+ restoredOwnership.clear();
771
+ // 模块已封锁:创建时快照不再有任何归还路径可消费(未结算任务的归属随模块一起丢弃)。
772
+ ownershipSnapshot.clear();
773
+ // 读凭据同样随模块生命周期结束而丢弃(只读幂等重放不再可服务,读写都 fail-closed)。
774
+ restoreReadCredentials.clear();
775
+ try {
776
+ const managerClose = typeof runtime.manager?.close === "function" ? runtime.manager.close() : undefined;
777
+ if (managerClose !== null && typeof managerClose === "object" && typeof managerClose.catch === "function") {
778
+ managerClose.catch((error) => warn("dsh-expert-group: fan-out manager close failed: " + describeError(error)));
779
+ }
780
+ } catch (error) {
781
+ warn("dsh-expert-group: fan-out manager close failed: " + describeError(error));
782
+ }
783
+ try {
784
+ const runtimeClose = runtime.close();
785
+ if (runtimeClose !== null && typeof runtimeClose === "object" && typeof runtimeClose.catch === "function") {
786
+ runtimeClose.catch((error) => warn("dsh-expert-group: fan-out runtime close failed: " + describeError(error)));
787
+ }
788
+ } catch (error) {
789
+ warn("dsh-expert-group: fan-out runtime close failed: " + describeError(error));
790
+ }
791
+ return true;
792
+ },
793
+ dispose() {
794
+ api.close();
795
+ },
796
+ };
797
+
798
+ runtime = createRuntime({
799
+ ...runtimeOptions,
800
+ // 模块级解析函数(settings 接线)优先于 runtimeOptions.concurrencyLimit 的静态默认值:
801
+ // 静态值只是"没有动态来源时"的兜底,不能让组合根的历史常量把 settings 压住。
802
+ ...(dynamicConcurrencyLimit === undefined ? {} : { concurrencyLimit: dynamicConcurrencyLimit }),
803
+ buildRequest,
804
+ startContinuable,
805
+ interrupt,
806
+ onAgentCreated,
807
+ /**
808
+ * 多轮复用接线:把本轮 prompt 投递给既有 child,并解析它的 session。
809
+ *
810
+ * 两者都只在 Host 提供时透传;缺失时 runtime 侧的 continuation admission 一律
811
+ * fail-closed(绝不静默新建 child 顶替复用)。
812
+ */
813
+ ...(continueMessage === undefined ? {} : { continueMessage }),
814
+ ...(resolveChildSession === undefined ? {} : { resolveChildSession }),
815
+ // 多轮复用的父权限解析:continueMessage 需要 exact parent Agent 才能投递给同一 child。
816
+ parentAgentOf: (parentTaskId) => lineage.parentAgentOf(parentTaskId),
817
+ persistence: { save: (snapshot) => api.persist(snapshot) },
818
+ onEvent: (event) => api.parentSettled(event),
819
+ log: (message, error) => warn("dsh-expert-group: " + message + (error instanceof Error ? ": " + error.message : "")),
820
+ });
821
+ return Object.freeze(api);
822
+ }