dsh-expert-group 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +38 -0
- package/README.md +355 -0
- package/THIRD_PARTY_NOTICES.md +35 -0
- package/assets/notification-whoosh.wav +0 -0
- package/bin/prompt-inspector.mjs +63 -0
- package/client-bundle.js +14891 -0
- package/client-expert-group-model.js +426 -0
- package/client-expert-group-sidebar.js +5401 -0
- package/client-freshness.js +364 -0
- package/client-model-config.js +2155 -0
- package/client-notifications.js +38 -0
- package/client.js +6487 -0
- package/cordis.patch.yml +10 -0
- package/fanout-host.js +1507 -0
- package/fanout.js +2981 -0
- package/host/composition.js +155 -0
- package/host/expert-group-runtime.js +822 -0
- package/host/expert-group-snapshot.js +450 -0
- package/host/fanout-runtime.js +822 -0
- package/host/handoff.js +80 -0
- package/host/lineage.js +274 -0
- package/host/notifications.js +189 -0
- package/host/persistence-health.js +389 -0
- package/host/recovery.js +242 -0
- package/host/rpc-transport.js +573 -0
- package/host/settings-runtime.js +168 -0
- package/host/settings-source.js +265 -0
- package/host/storage-binding.js +94 -0
- package/host/system-toast.js +916 -0
- package/image-routing.js +263 -0
- package/index.js +9327 -0
- package/install.ps1 +219 -0
- package/orchestrator.js +239 -0
- package/package.json +142 -0
- package/prompt-contracts.js +148 -0
- package/prompt-inspector.js +584 -0
- package/prompt-sidecar.js +610 -0
- package/resource-budget.js +540 -0
- package/role-registry.js +286 -0
- package/rpc-host.js +310 -0
- package/scripts/assemble-client.mjs +605 -0
- package/scripts/build.mjs +124 -0
- package/shared.js +4003 -0
- package/storage.js +190 -0
- package/uninstall.ps1 +47 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 持久化旁路健康状态。
|
|
3
|
+
*
|
|
4
|
+
* 该模块只维护内存中的可观察状态,不写 storage、不触发回调,也不持有 manager 句柄。
|
|
5
|
+
* 失败记录因此不会重新进入持久化链;Host 可以在外层使用 retryDecision 做有界重试。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const PERSISTENCE_FAILURE_CATEGORIES = Object.freeze([
|
|
9
|
+
"transient",
|
|
10
|
+
"generation-stale",
|
|
11
|
+
"capacity",
|
|
12
|
+
"permanent",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const CATEGORY_SET = new Set(PERSISTENCE_FAILURE_CATEGORIES);
|
|
16
|
+
const CATEGORY_CODES = Object.freeze({
|
|
17
|
+
transient: "PERSISTENCE_TRANSIENT",
|
|
18
|
+
"generation-stale": "PERSISTENCE_GENERATION_STALE",
|
|
19
|
+
capacity: "PERSISTENCE_CAPACITY",
|
|
20
|
+
permanent: "PERSISTENCE_PERMANENT",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function isRecord(value) {
|
|
24
|
+
return value !== null && typeof value === "object";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function textOf(value) {
|
|
28
|
+
return typeof value === "string" ? value : "";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function nonNegativeInteger(value) {
|
|
32
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function attemptNumber(value) {
|
|
36
|
+
return Number.isSafeInteger(value) && value > 0 ? value : 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function clone(value) {
|
|
40
|
+
if (value === undefined) return undefined;
|
|
41
|
+
return JSON.parse(JSON.stringify(value));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function categoryOf(value) {
|
|
45
|
+
if (CATEGORY_SET.has(value)) return value;
|
|
46
|
+
if (value === "generation_stale" || value === "generation-stale") return "generation-stale";
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function errorSource(input) {
|
|
51
|
+
if (isRecord(input) && isRecord(input.error)) return input.error;
|
|
52
|
+
return input;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 把 storage adapter 的错误归一为稳定类别和 code。
|
|
57
|
+
* 未知错误默认按 permanent 处理,只有明确声明 retryable/transient 的错误才会重试。
|
|
58
|
+
*/
|
|
59
|
+
export function classifyFailure(input) {
|
|
60
|
+
const source = errorSource(input);
|
|
61
|
+
const explicit = categoryOf(input?.category) ?? categoryOf(source?.category);
|
|
62
|
+
if (explicit !== undefined) {
|
|
63
|
+
return {
|
|
64
|
+
category: explicit,
|
|
65
|
+
code: CATEGORY_CODES[explicit],
|
|
66
|
+
retryable: explicit === "transient" || explicit === "generation-stale",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const code = textOf(source?.code).toLowerCase();
|
|
71
|
+
const name = textOf(source?.name).toLowerCase();
|
|
72
|
+
const message = textOf(source?.message).toLowerCase();
|
|
73
|
+
const reason = textOf(source?.reason).toLowerCase();
|
|
74
|
+
const detached = input?.detached === true || source?.detached === true;
|
|
75
|
+
if (detached
|
|
76
|
+
|| code.includes("generation")
|
|
77
|
+
|| code.includes("detached")
|
|
78
|
+
|| code.includes("binding_replaced")
|
|
79
|
+
|| code.includes("binding-replaced")
|
|
80
|
+
|| code.includes("stale")
|
|
81
|
+
|| reason.includes("generation")
|
|
82
|
+
|| reason.includes("detached")
|
|
83
|
+
|| message.includes("binding was replaced")
|
|
84
|
+
|| message.includes("generation stale")
|
|
85
|
+
|| message.includes("generation changed")) {
|
|
86
|
+
return { category: "generation-stale", code: CATEGORY_CODES["generation-stale"], retryable: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (name === "quotaexceedederror"
|
|
90
|
+
|| code.includes("quota")
|
|
91
|
+
|| code.includes("capacity")
|
|
92
|
+
|| code.includes("enospc")
|
|
93
|
+
|| code.includes("efbig")
|
|
94
|
+
|| code.includes("payload_too_large")
|
|
95
|
+
|| code.includes("payload-too-large")
|
|
96
|
+
|| message.includes("quota")
|
|
97
|
+
|| message.includes("capacity")
|
|
98
|
+
|| message.includes("out of space")
|
|
99
|
+
|| message.includes("storage full")
|
|
100
|
+
|| message.includes("too large")) {
|
|
101
|
+
return { category: "capacity", code: CATEGORY_CODES.capacity, retryable: false };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (source?.retryable === true
|
|
105
|
+
|| source?.transient === true
|
|
106
|
+
|| code === "etimedout"
|
|
107
|
+
|| code === "econnreset"
|
|
108
|
+
|| code === "eagain"
|
|
109
|
+
|| code === "ebusy"
|
|
110
|
+
|| code.includes("transient")
|
|
111
|
+
|| code.includes("temporary")
|
|
112
|
+
|| message.includes("timed out")
|
|
113
|
+
|| message.includes("temporarily unavailable")
|
|
114
|
+
|| message.includes("try again")) {
|
|
115
|
+
return { category: "transient", code: CATEGORY_CODES.transient, retryable: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { category: "permanent", code: CATEGORY_CODES.permanent, retryable: false };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 保留上一轮失败轮次使用的命名,供现有 Host adapter 兼容调用。 */
|
|
122
|
+
export function classifyPersistenceFailure(input) {
|
|
123
|
+
return classifyFailure(input);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function positionOf({ sequence, generation } = {}) {
|
|
127
|
+
const position = {
|
|
128
|
+
sequence: nonNegativeInteger(sequence),
|
|
129
|
+
generation: nonNegativeInteger(generation),
|
|
130
|
+
};
|
|
131
|
+
return position.sequence === null && position.generation === null ? null : position;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* generation 优先,随后比较同一 generation 内的 event sequence。
|
|
136
|
+
*
|
|
137
|
+
* 前置不变式:唯一调用方 staleAgainstLatest 已把「任一侧档位缺失」的输入提前返回
|
|
138
|
+
* (接受,不判 stale),因此这里只会收到「双侧都有 generation 与 sequence」或
|
|
139
|
+
* 「双侧都是 null」两种输入。函数内部仍保留单侧缺失分支,作为独立可读的比较器契约,
|
|
140
|
+
* 不依赖调用方的不变式才能正确。
|
|
141
|
+
*/
|
|
142
|
+
function comparePosition(left, right) {
|
|
143
|
+
if (left === null && right === null) return 0;
|
|
144
|
+
if (left === null) return -1;
|
|
145
|
+
if (right === null) return 1;
|
|
146
|
+
if (left.generation !== null || right.generation !== null) {
|
|
147
|
+
if (left.generation === null) return -1;
|
|
148
|
+
if (right.generation === null) return 1;
|
|
149
|
+
if (left.generation !== right.generation) return left.generation - right.generation;
|
|
150
|
+
}
|
|
151
|
+
if (left.sequence === null && right.sequence === null) return 0;
|
|
152
|
+
if (left.sequence === null) return -1;
|
|
153
|
+
if (right.sequence === null) return 1;
|
|
154
|
+
return left.sequence - right.sequence;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function atOf(value, now) {
|
|
158
|
+
return value === undefined || value === null ? now() : value;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function normalizeDrainState(value) {
|
|
162
|
+
if (typeof value === "string") {
|
|
163
|
+
return { status: value, pending: value === "draining" ? 1 : 0 };
|
|
164
|
+
}
|
|
165
|
+
if (!isRecord(value)) return undefined;
|
|
166
|
+
const status = typeof value.status === "string" ? value.status : "idle";
|
|
167
|
+
const pending = Number.isSafeInteger(value.pending) && value.pending >= 0
|
|
168
|
+
? value.pending
|
|
169
|
+
: status === "draining" ? 1 : 0;
|
|
170
|
+
return { status, pending };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 创建健康状态。maxRetries 表示同一次提交最多允许追加的 retry 次数;它不限制之后
|
|
175
|
+
* 由新的 manager sequence 发起的独立提交。
|
|
176
|
+
*/
|
|
177
|
+
export function createPersistenceHealth({ now = () => Date.now(), maxRetries = 2 } = {}) {
|
|
178
|
+
const retryLimit = Number.isSafeInteger(maxRetries) && maxRetries >= 0 ? maxRetries : 2;
|
|
179
|
+
let tokenSequence = 0;
|
|
180
|
+
const pendingTokens = new Set();
|
|
181
|
+
let explicitDrain = { status: "idle", pending: 0 };
|
|
182
|
+
let lastSuccess = null;
|
|
183
|
+
let lastFailure = null;
|
|
184
|
+
let consecutiveFailures = 0;
|
|
185
|
+
let latestPosition = null;
|
|
186
|
+
let latestKind;
|
|
187
|
+
/**
|
|
188
|
+
* session/child domain 写入的独立观察通道。
|
|
189
|
+
*
|
|
190
|
+
* 这两类写入没有 fanout 快照的 manager sequence,若复用 recordFailure 会覆盖
|
|
191
|
+
* latestPosition/latestKind,从而把 fanout 的有界重试判定(retryDecision)与
|
|
192
|
+
* "已成功"的陈旧判定一起污染。因此这里单独计数与记录:它是纯内存状态,不触发
|
|
193
|
+
* 任何持久化回调,失败记录不可能形成 persistence feedback loop。
|
|
194
|
+
* 成功也不清除 fanout 的 lastFailure——两个通道各自表达自己的最近结果。
|
|
195
|
+
*/
|
|
196
|
+
let storageObserved = 0;
|
|
197
|
+
let storageFailures = 0;
|
|
198
|
+
let lastStorageFailure = null;
|
|
199
|
+
let lastStorageSuccess = null;
|
|
200
|
+
|
|
201
|
+
function drainState() {
|
|
202
|
+
if (pendingTokens.size > 0) return { status: "draining", pending: pendingTokens.size };
|
|
203
|
+
return { ...explicitDrain };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function applyDrainState(value) {
|
|
207
|
+
const normalized = normalizeDrainState(value);
|
|
208
|
+
if (normalized !== undefined) explicitDrain = normalized;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function stateSnapshot() {
|
|
212
|
+
return clone({
|
|
213
|
+
drain: drainState(),
|
|
214
|
+
lastSuccess,
|
|
215
|
+
consecutiveFailures,
|
|
216
|
+
failureCategory: lastFailure?.category ?? null,
|
|
217
|
+
failureCode: lastFailure?.code ?? null,
|
|
218
|
+
lastFailure,
|
|
219
|
+
maxRetries: retryLimit,
|
|
220
|
+
// session/child 写入通道:只读投影,不参与 fanout 的 latestPosition 比较。
|
|
221
|
+
storage: {
|
|
222
|
+
observed: storageObserved,
|
|
223
|
+
failures: storageFailures,
|
|
224
|
+
lastFailure: lastStorageFailure,
|
|
225
|
+
lastSuccess: lastStorageSuccess,
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 判定一次记录是否相对最近一次被采纳的记录过时(stale)。
|
|
232
|
+
*
|
|
233
|
+
* 无 position(null 档位)的显式语义:**任一侧为 null 时不判 stale,一律接受**。
|
|
234
|
+
* 理由:null 档位只表示「缺少游标信息」,不携带「更旧」这一事实证明。
|
|
235
|
+
* - null-position 成功必须能清零 lastFailure/consecutiveFailures,否则该次失败会
|
|
236
|
+
* 在健康视图里永不清零(审核发现 03 的主症状);
|
|
237
|
+
* - null-position 失败必须照常计数,否则真实故障被静默丢弃。
|
|
238
|
+
* 该规则同时覆盖「单侧 null」:例如 generation 可用但 sequence 非安全整数时,
|
|
239
|
+
* position 为 {generation: n, sequence: null},与前沿比较时 sequence 一侧缺失,
|
|
240
|
+
* 同样不得凭空判旧。
|
|
241
|
+
* 两侧都有 position 时保持既有规则:generation 优先、同 generation 比 sequence,
|
|
242
|
+
* 并且同 position 上「成功之后紧跟失败」仍被抑制(order === 0 分支)。
|
|
243
|
+
*/
|
|
244
|
+
function staleAgainstLatest(position, kind) {
|
|
245
|
+
if (position === null || latestPosition === null) return false;
|
|
246
|
+
if (position.generation === null || latestPosition.generation === null) return false;
|
|
247
|
+
if (position.sequence === null || latestPosition.sequence === null) return false;
|
|
248
|
+
const order = comparePosition(position, latestPosition);
|
|
249
|
+
if (order < 0) return true;
|
|
250
|
+
return order === 0 && kind === "failure" && latestKind === "success";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 采纳一次记录后推进档位前沿:kind 始终更新,position 原样写入(null 也写入)。
|
|
255
|
+
*
|
|
256
|
+
* 保持修复前「前沿 = 最近一次被采纳记录」的语义:一次缺信息的记录会把前沿降级为
|
|
257
|
+
* 缺信息,之后的记录不再与更早的档位比较。取舍是宁可不做推断,也不能把「缺少游标」
|
|
258
|
+
* 当成「已知更旧」——后者会把迟到的正常记录永久丢进 stale,正是本次修复要消除的失效模式。
|
|
259
|
+
*/
|
|
260
|
+
function advancePosition(position, kind) {
|
|
261
|
+
latestPosition = position;
|
|
262
|
+
latestKind = kind;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const api = {
|
|
266
|
+
beginDrain({ generation, sequence, drainState: suppliedDrainState } = {}) {
|
|
267
|
+
const token = Object.freeze({
|
|
268
|
+
id: ++tokenSequence,
|
|
269
|
+
generation: nonNegativeInteger(generation),
|
|
270
|
+
sequence: nonNegativeInteger(sequence),
|
|
271
|
+
});
|
|
272
|
+
pendingTokens.add(token.id);
|
|
273
|
+
explicitDrain = { status: "draining", pending: pendingTokens.size };
|
|
274
|
+
applyDrainState(suppliedDrainState);
|
|
275
|
+
return token;
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
endDrain(token) {
|
|
279
|
+
if (!isRecord(token) || !pendingTokens.has(token.id)) return false;
|
|
280
|
+
pendingTokens.delete(token.id);
|
|
281
|
+
explicitDrain = {
|
|
282
|
+
status: pendingTokens.size > 0 ? "draining" : "idle",
|
|
283
|
+
pending: pendingTokens.size,
|
|
284
|
+
};
|
|
285
|
+
return true;
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
recordSuccess({ sequence, generation, at, drainState: suppliedDrainState } = {}) {
|
|
289
|
+
// 无 position 的成功同样被采纳:它必须能清零失败计数(见 staleAgainstLatest 注释)。
|
|
290
|
+
const position = positionOf({ sequence, generation });
|
|
291
|
+
if (staleAgainstLatest(position, "success")) return stateSnapshot();
|
|
292
|
+
advancePosition(position, "success");
|
|
293
|
+
lastSuccess = {
|
|
294
|
+
sequence: nonNegativeInteger(sequence),
|
|
295
|
+
generation: nonNegativeInteger(generation),
|
|
296
|
+
at: atOf(at, now),
|
|
297
|
+
};
|
|
298
|
+
lastFailure = null;
|
|
299
|
+
consecutiveFailures = 0;
|
|
300
|
+
applyDrainState(suppliedDrainState);
|
|
301
|
+
return stateSnapshot();
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
recordFailure({ error, category, sequence, generation, at, drainState: suppliedDrainState, attempt = 1 } = {}) {
|
|
305
|
+
const normalized = classifyFailure({ error, category });
|
|
306
|
+
// 无 position 的失败照常计数(accepted: true),只有明确更旧的档位才返回 stale 丢弃。
|
|
307
|
+
const position = positionOf({ sequence, generation });
|
|
308
|
+
if (staleAgainstLatest(position, "failure")) {
|
|
309
|
+
return { ...normalized, accepted: false, stale: true, health: stateSnapshot() };
|
|
310
|
+
}
|
|
311
|
+
advancePosition(position, "failure");
|
|
312
|
+
const failure = {
|
|
313
|
+
category: normalized.category,
|
|
314
|
+
code: normalized.code,
|
|
315
|
+
sequence: nonNegativeInteger(sequence),
|
|
316
|
+
generation: nonNegativeInteger(generation),
|
|
317
|
+
at: atOf(at, now),
|
|
318
|
+
attempt: attemptNumber(attempt),
|
|
319
|
+
message: textOf(error?.message ?? error),
|
|
320
|
+
};
|
|
321
|
+
lastFailure = failure;
|
|
322
|
+
consecutiveFailures += 1;
|
|
323
|
+
applyDrainState(suppliedDrainState);
|
|
324
|
+
return { ...normalized, accepted: true, failure, health: stateSnapshot() };
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
retryDecision(categoryOrOptions = {}, suppliedAttempt = 1) {
|
|
328
|
+
const input = typeof categoryOrOptions === "string"
|
|
329
|
+
? { category: categoryOrOptions, attempt: suppliedAttempt }
|
|
330
|
+
: categoryOrOptions;
|
|
331
|
+
const normalized = classifyFailure(input);
|
|
332
|
+
const attempt = attemptNumber(input?.attempt);
|
|
333
|
+
if (normalized.category !== "transient" && normalized.category !== "generation-stale") {
|
|
334
|
+
return { retry: false, merge: false, nextAttempt: null, reason: normalized.category };
|
|
335
|
+
}
|
|
336
|
+
if (attempt > retryLimit) {
|
|
337
|
+
return { retry: false, merge: false, nextAttempt: null, reason: "retry-exhausted" };
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
retry: true,
|
|
341
|
+
merge: normalized.category === "generation-stale",
|
|
342
|
+
nextAttempt: attempt + 1,
|
|
343
|
+
reason: normalized.category,
|
|
344
|
+
};
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
shouldRetry(category, attempt = 1) {
|
|
348
|
+
return api.retryDecision(category, attempt);
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* 记录一次 session/child domain 写入的结果(同步 throw 或异步 reject 都归一到这里)。
|
|
353
|
+
*
|
|
354
|
+
* - 失败:分类为 transient/generation-stale/capacity/permanent 并只更新本通道;
|
|
355
|
+
* 不触碰 fanout 的 lastFailure/latestPosition,因此不会改变其重试判定;
|
|
356
|
+
* - 成功:只更新本通道的 lastSuccess,不掩盖 fanout 自己的失败状态。
|
|
357
|
+
*
|
|
358
|
+
* @param {object} input - { ok, error, generation, at, source }
|
|
359
|
+
* @returns {{ok:boolean, category:string|null, code:string|null, health:object}}
|
|
360
|
+
*/
|
|
361
|
+
observeStorageWrite({ ok = false, error, generation, at, source } = {}) {
|
|
362
|
+
const position = nonNegativeInteger(generation);
|
|
363
|
+
const observedAt = atOf(at, now);
|
|
364
|
+
if (ok === true) {
|
|
365
|
+
storageObserved += 1;
|
|
366
|
+
lastStorageSuccess = { generation: position, at: observedAt, source: textOf(source) };
|
|
367
|
+
return { ok: true, category: null, code: null, health: stateSnapshot() };
|
|
368
|
+
}
|
|
369
|
+
const normalized = classifyFailure({ error });
|
|
370
|
+
storageObserved += 1;
|
|
371
|
+
storageFailures += 1;
|
|
372
|
+
lastStorageFailure = {
|
|
373
|
+
category: normalized.category,
|
|
374
|
+
code: normalized.code,
|
|
375
|
+
generation: position,
|
|
376
|
+
at: observedAt,
|
|
377
|
+
source: textOf(source),
|
|
378
|
+
message: textOf(error?.message ?? error),
|
|
379
|
+
};
|
|
380
|
+
return { ok: false, category: normalized.category, code: normalized.code, health: stateSnapshot() };
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
classifyFailure,
|
|
384
|
+
snapshot: stateSnapshot,
|
|
385
|
+
describe: stateSnapshot,
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
return Object.freeze(api);
|
|
389
|
+
}
|
package/host/recovery.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host 关注点模块:普通专家自动恢复的 runtime 状态与判定。
|
|
3
|
+
*
|
|
4
|
+
* 接管此前散落在 index.js 的 recovery 上下文存储(原 record.taskRecoveryContexts)、
|
|
5
|
+
* 待消费的重建意图队列(原 pendingRecoveryRebuilds)、失败文案分类、终止性投递
|
|
6
|
+
* 错误分类与恢复预算判定。recovery record 本身仍由组合根持有(被多个事件路径
|
|
7
|
+
* 直接读写),本模块只持有"纯状态 + 纯判定"。
|
|
8
|
+
*
|
|
9
|
+
* 语义保持:自动恢复最多 5 次、成功重置、明确取消不恢复、非空输出不恢复;
|
|
10
|
+
* 不涉及 fan-out 手工 retry。
|
|
11
|
+
*
|
|
12
|
+
* 生命周期(任务 06):模块暴露 describe()/dispose() 标准形态,可作为生命周期参与者
|
|
13
|
+
* 装配进 host/composition,也可由入口在 ctx.effect 清理里直接调用。dispose 同步关闭
|
|
14
|
+
* 并清空重建意图与上下文存储,随后按注册逆序执行释放回调(timer、AbortController、
|
|
15
|
+
* pending operation、相关绑定),异步回调会被等待;重复 dispose 返回同一个 promise,
|
|
16
|
+
* 回调只执行一次。关闭之后到达的迟到 promise 一律读到空状态:takeRebuild/contextFor/
|
|
17
|
+
* promptFor 为空、canAutoRecover 为 false、queueRebuild 静默丢弃,因此不可能复活任务
|
|
18
|
+
* 或启动新的 child(也不能把错误变成未处理的 rejection)。
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-expert-group/host/recovery
|
|
21
|
+
*/
|
|
22
|
+
import { appendRecoveryRetryCount, lastTurnErrorMessage, shouldAutoRecover } from "../shared.js";
|
|
23
|
+
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return value !== null && typeof value === "object";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function evictOldest(store, limit) {
|
|
29
|
+
while (store.size > limit) {
|
|
30
|
+
const oldest = store.keys().next();
|
|
31
|
+
if (oldest.done === true) return;
|
|
32
|
+
store.delete(oldest.value);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} [options]
|
|
38
|
+
* @param {number} [options.limit] - 自动恢复上限。
|
|
39
|
+
* @param {number} [options.taskBindingLimit] - 每个 record 保留的任务上下文上限。
|
|
40
|
+
* @param {string} [options.effortMarker] - child.options 上的推理深度 marker 名。
|
|
41
|
+
* @param {Function[]} [options.disposeHooks] - 释放回调(timer 清理、AbortController
|
|
42
|
+
* 取消、pending operation 与绑定释放);按注册逆序执行,异步回调会被等待。
|
|
43
|
+
*/
|
|
44
|
+
export function createRecoveryModule({ limit = 5, taskBindingLimit = 64, effortMarker = "__degExpertEffort", disposeHooks = [] } = {}) {
|
|
45
|
+
if (!Number.isSafeInteger(limit) || limit < 0) throw new TypeError("recovery limit 必须是非负整数");
|
|
46
|
+
if (!Number.isSafeInteger(taskBindingLimit) || taskBindingLimit <= 0) throw new TypeError("taskBindingLimit 必须是正整数");
|
|
47
|
+
if (!Array.isArray(disposeHooks) || disposeHooks.some((hook) => typeof hook !== "function")) {
|
|
48
|
+
throw new TypeError("disposeHooks 必须全部是函数");
|
|
49
|
+
}
|
|
50
|
+
let contexts = new WeakMap();
|
|
51
|
+
let contextCount = 0;
|
|
52
|
+
const rebuilds = [];
|
|
53
|
+
// 释放回调按注册顺序保存、按注册逆序执行;dispose 时整体摘除,保证每个回调只跑一次。
|
|
54
|
+
const hooks = disposeHooks.slice();
|
|
55
|
+
let disposed = false;
|
|
56
|
+
let disposePromise;
|
|
57
|
+
|
|
58
|
+
const api = {
|
|
59
|
+
/**
|
|
60
|
+
* 保存恢复重建所需的原任务上下文:route、prompt、provider/model/effort、
|
|
61
|
+
* persona、toolFilter 与 maxDepth。非文本 prompt 不建立上下文。
|
|
62
|
+
*/
|
|
63
|
+
rememberContext(record, { routeKey, request, label, taskKey } = {}) {
|
|
64
|
+
if (disposed || !isRecord(record) || request === void 0 || request === null) return undefined;
|
|
65
|
+
const promptText = request.prompt?.[0]?.text;
|
|
66
|
+
if (typeof promptText !== "string" || promptText.length === 0) return undefined;
|
|
67
|
+
const context = {
|
|
68
|
+
routeKey,
|
|
69
|
+
label,
|
|
70
|
+
promptText,
|
|
71
|
+
provider: request.agentOptions?.provider,
|
|
72
|
+
model: request.agentOptions?.model,
|
|
73
|
+
reasoningEffort: request.agentOptions?.[effortMarker],
|
|
74
|
+
persona: request.persona,
|
|
75
|
+
toolFilter: request.toolFilter,
|
|
76
|
+
maxDepth: request.maxDepth,
|
|
77
|
+
};
|
|
78
|
+
let store = contexts.get(record);
|
|
79
|
+
if (store === undefined) {
|
|
80
|
+
store = new Map();
|
|
81
|
+
contexts.set(record, store);
|
|
82
|
+
contextCount += 1;
|
|
83
|
+
}
|
|
84
|
+
// routeKey 与 recoveryRequest 是既有 record 字段(重建路径会读 route 变化),
|
|
85
|
+
// 继续写回以保持兼容;按任务键的查找由本模块持有。
|
|
86
|
+
record.routeKey = routeKey;
|
|
87
|
+
record.recoveryRequest = context;
|
|
88
|
+
store.set(taskKey, context);
|
|
89
|
+
evictOldest(store, taskBindingLimit);
|
|
90
|
+
return context;
|
|
91
|
+
},
|
|
92
|
+
/** 优先按 op.taskKeys 取本次任务的上下文,缺失时回落最近一次上下文。 */
|
|
93
|
+
contextFor(record, op) {
|
|
94
|
+
// 关闭之后连宿主持有的兼容字段(record.recoveryRequest)也不再读取:迟到的恢复
|
|
95
|
+
// 流程不能借它复活 prompt。
|
|
96
|
+
if (disposed) return undefined;
|
|
97
|
+
const store = isRecord(record) ? contexts.get(record) : undefined;
|
|
98
|
+
if (store !== undefined) {
|
|
99
|
+
for (const taskKey of op?.taskKeys ?? []) {
|
|
100
|
+
const context = store.get(taskKey);
|
|
101
|
+
if (context !== undefined) return context;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return record?.recoveryRequest;
|
|
105
|
+
},
|
|
106
|
+
promptFor(record, op) {
|
|
107
|
+
const context = api.contextFor(record, op);
|
|
108
|
+
return typeof context?.promptText === "string" ? context.promptText : void 0;
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
/** 登记一个待消费的重建意图(agent/created 时按 parent+parentId+role 取用)。 */
|
|
112
|
+
queueRebuild(entry) {
|
|
113
|
+
// 关闭后重新排队是"迟到 promise"最常见的复活入口:静默丢弃。这里不能抛错,
|
|
114
|
+
// 否则迟到回调会把异常变成未处理的 rejection。
|
|
115
|
+
if (disposed) return undefined;
|
|
116
|
+
if (!isRecord(entry) || typeof entry.parentId !== "string" || typeof entry.role !== "string") {
|
|
117
|
+
throw new TypeError("queueRebuild 需要 parentId/role");
|
|
118
|
+
}
|
|
119
|
+
rebuilds.push(entry);
|
|
120
|
+
return entry;
|
|
121
|
+
},
|
|
122
|
+
/** 取用并移除最早匹配的重建意图;父 Agent 必须是同一 exact 对象。 */
|
|
123
|
+
takeRebuild(parent, parentId, role) {
|
|
124
|
+
const index = rebuilds.findIndex((item) => item.parent === parent && item.parentId === parentId && item.role === role);
|
|
125
|
+
return index === -1 ? void 0 : rebuilds.splice(index, 1)[0];
|
|
126
|
+
},
|
|
127
|
+
/** 移除一个具体的重建意图(startContinuable 失败/成功后清理)。 */
|
|
128
|
+
removeRebuild(entry) {
|
|
129
|
+
const index = rebuilds.indexOf(entry);
|
|
130
|
+
if (index === -1) return false;
|
|
131
|
+
rebuilds.splice(index, 1);
|
|
132
|
+
return true;
|
|
133
|
+
},
|
|
134
|
+
dropRebuildsFor(parent) {
|
|
135
|
+
let removed = 0;
|
|
136
|
+
for (let index = rebuilds.length - 1; index >= 0; index -= 1) {
|
|
137
|
+
if (rebuilds[index].parent === parent) {
|
|
138
|
+
rebuilds.splice(index, 1);
|
|
139
|
+
removed += 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return removed;
|
|
143
|
+
},
|
|
144
|
+
rebuildsFor: (parent) => rebuilds.filter((item) => item.parent === parent),
|
|
145
|
+
rebuildCount: () => rebuilds.length,
|
|
146
|
+
|
|
147
|
+
/** 失败原因文案:底层原因 + 恢复次数后缀(供任务终态与后续 feedback)。 */
|
|
148
|
+
failureMessage(record, reason, session) {
|
|
149
|
+
const kind = reason?.kind;
|
|
150
|
+
let message;
|
|
151
|
+
if (kind === "error") {
|
|
152
|
+
const detail = reason?.error?.message;
|
|
153
|
+
if (typeof detail === "string" && detail.length > 0) {
|
|
154
|
+
message = "subagent run failed: " + detail;
|
|
155
|
+
} else {
|
|
156
|
+
const latest = lastTurnErrorMessage(session?.events);
|
|
157
|
+
message = latest === null ? "subagent run failed" : "subagent run failed: " + latest;
|
|
158
|
+
}
|
|
159
|
+
} else if (kind === "aborted") {
|
|
160
|
+
message = "subagent run was cancelled";
|
|
161
|
+
// B9:blocked(pre-step 拒绝,消息被认领但未落盘)与 refusal 统一为 declined 文案。
|
|
162
|
+
} else if (kind === "refusal" || kind === "blocked") {
|
|
163
|
+
message = "subagent declined the task";
|
|
164
|
+
} else if (kind === "max-tokens") {
|
|
165
|
+
message = "subagent run hit its token limit before finishing";
|
|
166
|
+
} else if (kind === "completed") {
|
|
167
|
+
message = "subagent returned no text output";
|
|
168
|
+
} else {
|
|
169
|
+
message = "subagent run ended abnormally (" + String(kind ?? "unknown") + ")";
|
|
170
|
+
}
|
|
171
|
+
return appendRecoveryRetryCount(message, record?.recoveryCount);
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
/** 终止性投递错误:取消、权限、父/根生命周期变化不再消耗恢复预算。 */
|
|
175
|
+
isTerminalDispatchError(error) {
|
|
176
|
+
const code = typeof error?.code === "string" ? error.code.toUpperCase() : "";
|
|
177
|
+
const detail = error instanceof Error ? error.message : String(error ?? "");
|
|
178
|
+
if (error?.name === "AbortError"
|
|
179
|
+
|| ["ABORT_ERR", "UNAUTHORIZED", "FORBIDDEN", "PERMISSION_DENIED", "DRAINING", "PARENT_DISPOSED", "ROOT_DISPOSED"].includes(code)) {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
return /root generation is no longer live|parent.*(?:destroyed|disposed|not live|不可用)|cannot rebuild recovery child because|cancel(?:led|ed)|unauthori[sz]ed|permission denied|forbidden/i.test(detail);
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
/** 归一化后的连续恢复次数(非法值按 0)。 */
|
|
186
|
+
recoveryCount(record) {
|
|
187
|
+
return Number.isSafeInteger(record?.recoveryCount) && record.recoveryCount >= 0 ? record.recoveryCount : 0;
|
|
188
|
+
},
|
|
189
|
+
/** 是否仍有自动恢复预算:非空输出、明确取消、超限都返回 false。 */
|
|
190
|
+
canAutoRecover(record, reason, hasOutput) {
|
|
191
|
+
// 卸载之后不得再派发新的恢复 child:预算一律为 0。
|
|
192
|
+
if (disposed) return false;
|
|
193
|
+
return shouldAutoRecover(reason, hasOutput, api.recoveryCount(record)) && api.recoveryCount(record) < limit;
|
|
194
|
+
},
|
|
195
|
+
/** 成功完成后重置连续恢复预算。 */
|
|
196
|
+
resetBudget(record) {
|
|
197
|
+
if (isRecord(record)) record.recoveryCount = 0;
|
|
198
|
+
return 0;
|
|
199
|
+
},
|
|
200
|
+
describe: () => ({ rebuilds: rebuilds.length, trackedContexts: contextCount, disposed }),
|
|
201
|
+
/**
|
|
202
|
+
* 注册一个释放回调:timer 清理、AbortController 取消、pending operation 与绑定
|
|
203
|
+
* 释放都通过它挂到模块生命周期上。关闭之后注册被拒绝(返回 false),避免回调
|
|
204
|
+
* 在关闭之后才登记而永远不执行。
|
|
205
|
+
*/
|
|
206
|
+
addDisposeHook(hook) {
|
|
207
|
+
if (typeof hook !== "function") throw new TypeError("addDisposeHook 需要函数");
|
|
208
|
+
if (disposed) return false;
|
|
209
|
+
hooks.push(hook);
|
|
210
|
+
return true;
|
|
211
|
+
},
|
|
212
|
+
/**
|
|
213
|
+
* 幂等清理:同步关闭并清空重建意图与上下文存储(迟到 promise 因此读不到任何状态),
|
|
214
|
+
* 随后按注册逆序执行释放回调。异步回调会被等待,单个回调的异常记录下来后继续
|
|
215
|
+
* 执行其余回调,绝不向外抛出,也不会成为未处理的 rejection。重复调用返回同一个
|
|
216
|
+
* promise,回调只执行一次。
|
|
217
|
+
*/
|
|
218
|
+
dispose() {
|
|
219
|
+
if (disposePromise !== undefined) return disposePromise;
|
|
220
|
+
disposed = true;
|
|
221
|
+
rebuilds.length = 0;
|
|
222
|
+
contexts = new WeakMap();
|
|
223
|
+
contextCount = 0;
|
|
224
|
+
const registered = hooks.splice(0, hooks.length);
|
|
225
|
+
disposePromise = (async () => {
|
|
226
|
+
const errors = [];
|
|
227
|
+
for (let index = registered.length - 1; index >= 0; index -= 1) {
|
|
228
|
+
try {
|
|
229
|
+
const result = registered[index]();
|
|
230
|
+
// 同步回调不额外让出微任务:卸载路径上的 timer/AbortController 立即释放。
|
|
231
|
+
if (result !== null && typeof result === "object" && typeof result.then === "function") await result;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
errors.push(error);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return Object.freeze({ errors: Object.freeze(errors) });
|
|
237
|
+
})();
|
|
238
|
+
return disposePromise;
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
return Object.freeze(api);
|
|
242
|
+
}
|