dsh-agent-board 1.9.0 → 1.9.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/README.md +5 -5
- package/index.mjs +1 -0
- package/lib/client/board-list.js +1 -1
- package/lib/client/dashboard.js +52 -7
- package/lib/client/task-detail.js +2 -2
- package/lib/client.js +55 -10
- package/lib/core.mjs +37 -12
- package/lib/dispatch.mjs +482 -48
- package/lib/notify.mjs +37 -4
- package/lib/policy.mjs +19 -9
- package/package.json +1 -1
- package/test/core.test.mjs +229 -11
- package/test/decompose.test.mjs +70 -16
- package/test/fault-e2e-circuit.test.mjs +187 -0
- package/test/fault-e2e-fallback-chain.test.mjs +249 -0
- package/test/fault-e2e-hung.test.mjs +504 -0
- package/test/fault-e2e-recovery.test.mjs +255 -0
- package/test/fault-e2e-softgate.test.mjs +206 -0
- package/test/fault-e2e-stale-dir.test.mjs +226 -0
- package/test/fault-injection-docs.test.mjs +114 -0
- package/test/fault-provider.test.mjs +458 -0
- package/test/helpers/fault-provider.mjs +475 -0
- package/test/helpers/mock-ctx.mjs +9 -0
- package/test/hung-frame-signal.test.mjs +630 -0
- package/test/integration.test.mjs +41 -0
- package/test/lane-gui-bysize.test.mjs +287 -0
- package/test/lane-models.test.mjs +134 -0
- package/test/popover-clamp.test.mjs +92 -0
- package/test/settle-stop-interrupt.test.mjs +285 -3
- package/test/ui-zoom-client.test.mjs +9 -4
package/lib/dispatch.mjs
CHANGED
|
@@ -61,6 +61,8 @@ export function createDispatch(ctx, state, deps) {
|
|
|
61
61
|
var notifyLateApprove = (typeof deps.notifyLateApprove === 'function') ? deps.notifyLateApprove : null // 迟到复核 notice 级通知(三连根修②,未接线 → 静默跳过)
|
|
62
62
|
// 派发即回执:spawn 成功后入 45s 聚合队列的「🚀 已派发」区(老 host 未注入 → 静默跳过)
|
|
63
63
|
var notifyDispatched = deps.notifyDispatched
|
|
64
|
+
// 拆解官回执(task-mvrqiu6x):拆解官落定 split/keep → 「🧩 已拆解」聚合分区(老 host 未注入 → 静默跳过)
|
|
65
|
+
var notifyDecomposed = deps.notifyDecomposed
|
|
64
66
|
// 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
|
|
65
67
|
var dispatchedEver = state.dispatchedEver
|
|
66
68
|
var badModels = state.badModels
|
|
@@ -86,7 +88,8 @@ export function createDispatch(ctx, state, deps) {
|
|
|
86
88
|
}
|
|
87
89
|
function syncModelHealth(sid, model, cooldownOverride) {
|
|
88
90
|
var e = healthEntry(sid, model), until = typeof cooldownOverride === 'number' ? cooldownOverride : cooldownLive(model)
|
|
89
|
-
|
|
91
|
+
// task-mvrqdw4k:degraded 派生(状态优先级 circuit > cooldown > degraded > healthy/unknown——覆盖臂在下方,顺序即优先级)
|
|
92
|
+
e.state = e.observed === 'healthy' ? 'healthy' : (e.observed === 'degraded' ? 'degraded' : 'unknown')
|
|
90
93
|
e.note = e.observedNote || '尚无健康信号'
|
|
91
94
|
delete e.until
|
|
92
95
|
if (until) { e.state = 'cooldown'; e.until = until; e.note = e.cooldownNote || '模型冷却中' }
|
|
@@ -99,11 +102,26 @@ export function createDispatch(ctx, state, deps) {
|
|
|
99
102
|
var e = healthEntry(sid, model), at = new Date().toISOString()
|
|
100
103
|
var latency = Number(opts.latencyMs)
|
|
101
104
|
e.lastSeen = at
|
|
102
|
-
|
|
105
|
+
// ③ 探测超时/预算耗尽只更新 note(判定保留,task-mvsv8zym):observed/state/latencyMs/recentRuns
|
|
106
|
+
// 全部保持原值——避免已 healthy/degraded 条目被超时倒退成 unknown(超时非模型健康信号)。
|
|
107
|
+
if (opts.preserveObserved) {
|
|
108
|
+
if (opts.probe) {
|
|
109
|
+
e.lastProbe = at
|
|
110
|
+
e.observedNote = '主动探测超时(慢模型,判定保留)' + (opts.note ? ':' + opts.note : '')
|
|
111
|
+
e.note = e.observedNote
|
|
112
|
+
}
|
|
113
|
+
return syncModelHealth(sid, model)
|
|
114
|
+
}
|
|
115
|
+
// task-mvrqdw4k:探测空响应分治——ok=true 🟢健康;opts.degraded 🟡降级(链路存活,供应商怪癖,
|
|
116
|
+
// 只由探测降级存活臂置入);其余照旧 unknown(失败臂)。被动 run 观察臂语义逐字不变。
|
|
117
|
+
e.observed = ok ? 'healthy' : (opts.degraded ? 'degraded' : 'unknown')
|
|
103
118
|
if (isFinite(latency) && latency >= 0) e.latencyMs = Math.round(latency)
|
|
104
119
|
if (opts.probe) {
|
|
105
120
|
e.lastProbe = at
|
|
106
|
-
e.observedNote = ok ? '主动探测通过'
|
|
121
|
+
e.observedNote = ok ? '主动探测通过'
|
|
122
|
+
: (opts.degraded
|
|
123
|
+
? '主动探测:链路存活但极简请求零内容(EMPTY_RESPONSE 供应商怪癖)' + (opts.note ? ':' + opts.note : '')
|
|
124
|
+
: '主动探测失败' + (opts.note ? ':' + opts.note : ''))
|
|
107
125
|
} else {
|
|
108
126
|
e.recentRuns.push({ ok: !!ok, at: at, latencyMs: isFinite(latency) && latency >= 0 ? Math.round(latency) : null })
|
|
109
127
|
if (e.recentRuns.length > 5) e.recentRuns = e.recentRuns.slice(-5)
|
|
@@ -154,21 +172,91 @@ export function createDispatch(ctx, state, deps) {
|
|
|
154
172
|
} catch (_) {}
|
|
155
173
|
return until
|
|
156
174
|
}
|
|
175
|
+
// ===== 模型目录获取(task-mvd3li6 目录失效闸数据源):与 rpc.mjs enumModels 同源(llm.listProviders+listModels)=====
|
|
176
|
+
// 返回 { "provider/model": true } 目录映射;null = 目录不可得 → 目录闸整体跳过(保守语义③,不误伤):
|
|
177
|
+
// ① llm 服务缺失/缺 listProviders/listModels 方法(老宿主);② listProviders 未返回数组或抛错;
|
|
178
|
+
// ③ 任一 provider 的 listModels 抛错或未返回数组(部分枚举=目录不完整,宁可跳过闸也不误杀在册模型);
|
|
179
|
+
// ④ 枚举结果为空(退化形态:网关零模型时全候选都会被误判目录外,同样保守跳过)。
|
|
180
|
+
// 每次 spawnOneShot/续跑闸现取一次(刻意不缓存:模型从宿主配置删除/改名要当轮生效,缓存会放走已删模型)。
|
|
181
|
+
async function modelDirectory() {
|
|
182
|
+
var llm = ctx.get && ctx.get('llm')
|
|
183
|
+
if (!llm || typeof llm.listProviders !== 'function' || typeof llm.listModels !== 'function') return null
|
|
184
|
+
var providers = null
|
|
185
|
+
try { providers = llm.listProviders() } catch (_) { return null }
|
|
186
|
+
if (!Array.isArray(providers)) return null
|
|
187
|
+
var out = {}
|
|
188
|
+
for (var i = 0; i < providers.length; i++) {
|
|
189
|
+
try {
|
|
190
|
+
var models = await llm.listModels(providers[i].id)
|
|
191
|
+
if (!Array.isArray(models)) return null
|
|
192
|
+
for (var j = 0; j < models.length; j++) { if (models[j] && models[j].id) out[providers[i].id + '/' + models[j].id] = true }
|
|
193
|
+
} catch (_) { return null } // 任一 provider 枚举失败 → 目录不完整 → 保守跳过(不误伤)
|
|
194
|
+
}
|
|
195
|
+
if (!Object.keys(out).length) return null
|
|
196
|
+
return out
|
|
197
|
+
}
|
|
198
|
+
// 存在闸谓词(同步;dir=modelDirectory() 结果):目录可得且模型不在目录 → true(配置已失效)。
|
|
199
|
+
// dir 为 null(目录不可得)恒 false——闸整体跳过,只留连败软闸生效。
|
|
200
|
+
function outOfDir(modelId, dir) { return !!(dir && modelId && !dir[modelId]) }
|
|
201
|
+
// ===== 目录失效留痕(task-mvd3li6):取模候选不在当前模型目录 → 卡 messages 记「配置已失效」,per 模型去重 =====
|
|
202
|
+
// 同模型重复取模只记一条(防每轮重派刷屏,与冷却嗅探「未后延零留痕」同纪律);
|
|
203
|
+
// 顺带在健康表 observedNote 落同款分态(GUI 健康点可读出「为什么被跳过」)。写卡失败静默吞掉
|
|
204
|
+
// (留痕是附属动作,绝不阻断取模主流程)。
|
|
205
|
+
async function traceStaleModel(sid, taskId, model) {
|
|
206
|
+
if (!model) return
|
|
207
|
+
console.error('[task-board] 配置已失效:' + model + '(已从模型目录移除),取模跳过该候选')
|
|
208
|
+
try {
|
|
209
|
+
var he = healthEntry(sid, model)
|
|
210
|
+
if (he.observedNote !== '配置已失效(已从模型目录移除)') { he.observedNote = '配置已失效(已从模型目录移除)'; he.note = he.observedNote }
|
|
211
|
+
} catch (_) {}
|
|
212
|
+
try {
|
|
213
|
+
await mutateLocked(sid, function (d) {
|
|
214
|
+
var t2 = d.tasks.find(function (x) { return x.id === taskId })
|
|
215
|
+
if (!t2) return null
|
|
216
|
+
if (!Array.isArray(t2.messages)) t2.messages = []
|
|
217
|
+
var text = '⚠️ 配置已失效:' + model + '(已从模型目录移除),本 run 取模跳过'
|
|
218
|
+
for (var i = t2.messages.length - 1; i >= 0; i--) {
|
|
219
|
+
if (t2.messages[i] && t2.messages[i].kind === 'stale-model' && t2.messages[i].text === text) return null // 已记过,零改动零留痕
|
|
220
|
+
}
|
|
221
|
+
t2.messages.push({ kind: 'stale-model', text: text, at: new Date().toISOString(), by: 'system' })
|
|
222
|
+
return { ok: true }
|
|
223
|
+
}, true) // skipKick:只是留痕,不踢 poolCycle
|
|
224
|
+
} catch (_) {}
|
|
225
|
+
}
|
|
157
226
|
// ===== 备用模型链健康判定(task-mvnqxlpn):不在 badModels 熔断名单且不在 modelCooldown 冷却期 =====
|
|
158
227
|
// 两张表各管各的(熔断=连续失败布尔,冷却=额度窗口时间戳),任一命中即不健康。
|
|
159
228
|
// 消费点=spawnOneShot 取模链:候选被判不健康时先扫板级 fallbackModels 找首个健康者(链本身逐项过本检查)。
|
|
160
|
-
|
|
229
|
+
// ===== 健康面拓宽(task-mvd3li6):在既有熔断/冷却双闸之上增读 modelHealth 聚合表 =====
|
|
230
|
+
// ① 近期连败软闸:聚合表 recentRuns(近 5 次 run 成败,settleRunRecord 每次结算喂给)≥2 且全败 →
|
|
231
|
+
// 不健康(软熔断:不落 badModels 黑名单,仅取模/续跑跳过)。一次成功即复位——recentRuns 滚动
|
|
232
|
+
// 窗口本就有归零语义(成功条目进窗「全败」即不成立),确认复用零新增簿记。原形:glm-5.2
|
|
233
|
+
// 「78s 报错一次+挂死一次」病了还被优先调度/续跑——第二次派发即被本闸拦下改走健康取模链。
|
|
234
|
+
// ② 目录失效闸:dir 可得且模型不在目录(已从宿主配置删除/改名)→ 视为不健康跳过。
|
|
235
|
+
// dir 为可选第三参:既有调用点(不带 dir)= 目录闸跳过,行为逐字不变(零回归)。
|
|
236
|
+
function healthyModel(sid, modelId, dir) {
|
|
161
237
|
if (!modelId) return false
|
|
162
238
|
var h = syncModelHealth(sid, modelId)
|
|
163
239
|
if (h.state === 'circuit') return false
|
|
164
240
|
if (h.state === 'cooldown') return false
|
|
241
|
+
// task-mvrqdw4k:degraded(🟡空响应降级存活)刻意不拦——探测管可调性,模型仍可被路由选用;
|
|
242
|
+
// 行为质量另有 recentRuns 连败软闸兜底(下方),探测放宽不放跑真坏模型。
|
|
243
|
+
if (outOfDir(modelId, dir)) return false
|
|
244
|
+
var rr = h.recentRuns
|
|
245
|
+
if (Array.isArray(rr) && rr.length >= 2) {
|
|
246
|
+
var allBad = true
|
|
247
|
+
for (var i = 0; i < rr.length; i++) { if (rr[i] && rr[i].ok) { allBad = false; break } }
|
|
248
|
+
if (allBad) return false
|
|
249
|
+
}
|
|
165
250
|
return true
|
|
166
251
|
}
|
|
167
252
|
// 扫备用链找首个健康模型(保持链序=主窗口配置的能力水位优先级);链尽/空链返回 ''。
|
|
168
|
-
|
|
253
|
+
// task-mvd3li6 起 async + dir + taskId:链上目录外模型跳过并留痕(traceStaleModel,per 模型去重);
|
|
254
|
+
// 目录不可得(dir 缺省/null)零留痕,行为与旧签名逐字一致(既有调用点零回归)。
|
|
255
|
+
async function pickHealthyFallback(sid, fbList, dir, taskId) {
|
|
169
256
|
if (!Array.isArray(fbList)) return ''
|
|
170
257
|
for (var fi = 0; fi < fbList.length; fi++) {
|
|
171
|
-
if (
|
|
258
|
+
if (outOfDir(fbList[fi], dir)) { await traceStaleModel(sid, taskId, fbList[fi]); continue }
|
|
259
|
+
if (healthyModel(sid, fbList[fi], dir)) return fbList[fi]
|
|
172
260
|
}
|
|
173
261
|
return ''
|
|
174
262
|
}
|
|
@@ -179,15 +267,46 @@ export function createDispatch(ctx, state, deps) {
|
|
|
179
267
|
// ② activation 异常兜底臂:「子会话 activation 异常:…」(spawn 机制/provider 面失败,同属开工前)。
|
|
180
268
|
// worker 自己的失误(空输出/超时中断/文本降级失败)不算——好模型不该为 worker 的错误背锅。
|
|
181
269
|
var WORKER_MODEL_CIRCUIT_AFTER = 3 // 连续 N 次模型面失败才拉黑(与 worker 三连败阻塞同一三击口径)
|
|
270
|
+
// ===== 挂死零产出检测常量(task-mvqcoz9l)=====
|
|
271
|
+
// 原形(2026-10-27 降级实测):glm-5.2 续跑挂上「连接开着永不响应」形态——会话不死(死会话哨兵
|
|
272
|
+
// listChildren 查不出)、不报错(无 settle 事件)、零产出(日志零增长 7 分钟),running 假相直到
|
|
273
|
+
// 人工终止。熔断/冷却/哨兵全部够不着它(哨兵只盖「会话不在目录」,这里盖「会话在但不干活」)。
|
|
274
|
+
// 判死口径:连续 HUNG_ZERO_GROWTH_ROUNDS 轮(≈40 ≈ 600s/15s 心跳)日志零增长 **且距基线
|
|
275
|
+
// ≥600s 墙钟地板**——poolCycle 不只心跳驱动,每次写盘都 kickCycle(写风暴下轮数可在 1s
|
|
276
|
+
// 内跑完),纯轮数会对刚 spawn 的 run 秒判;双闸 = 「≥40 轮零增长且真跨 600s 墙钟」。
|
|
277
|
+
// 常量化暂不开放配置(实证再调)。
|
|
278
|
+
// ===== 挂死墙钟阈值定稿(task-mvrt9tn5 二度校准):固定 600s,放弃延迟缩放 =====
|
|
279
|
+
// 三轮生产误杀实证(2026-10-28):①慢网关模型长生成段 180s+ 零日志被 180s 闸误杀;
|
|
280
|
+
// ②kimi-k3(健康表实测延迟 2s 的快模型)正常干活 10 分钟同样被 180s 闸误杀(=3×2s 地板)——
|
|
281
|
+
// 结论:延迟(首 token 速度)与「长生成静默时长」无关,慢模型慢在长生成,快模型做大任务
|
|
282
|
+
// 同样有 3 分钟+ 无日志窗口,3×latencyMs 缩放(task-mvrg3962)方向作废。定稿:墙钟地板直接
|
|
283
|
+
// 600s(10 分钟,对齐观测到的最长正常静默),删延迟缩放(简化,也根除「3×0s=0s 假精确回落」
|
|
284
|
+
// 的延迟数据缺失洞)。轮数闸按墙钟换算常量化:600s/15s 心跳 ≈ 40 轮。判死语义不变:
|
|
285
|
+
// 600s 零增长 + 轮数达线才判(真挂死 7 分钟实证,600s 仍能抓住,只是晚 3 分钟——可接受)。
|
|
286
|
+
// 熔断喂连击/重派链路均不动。
|
|
287
|
+
var HUNG_WALL_FLOOR_MS = 600000
|
|
288
|
+
var HUNG_ZERO_GROWTH_ROUNDS = 40 // ≈ HUNG_WALL_FLOOR_MS / 15s 心跳:轮数半边与墙钟半边同尺度(写风暴防秒判语义保留)
|
|
289
|
+
// 挂死检测 v2:assistant-stream 帧活性优先,日志双闸只兜无帧流/不可映射的旧路径。
|
|
290
|
+
// 120s 只标疑似不杀;600s 才走既有判死结算。帧时间戳只在内存刷新,持久化仅承担 sweep 基线与状态转换。
|
|
291
|
+
var HUNG_FRAME_SUSPECT_MS = 120000
|
|
292
|
+
var HUNG_FRAME_KILL_MS = 600000
|
|
293
|
+
var HUNG_KILL_PREFIX = 'run 挂死:' // 判死失败文本前缀(进 isModelKillFailure 白名单 = 计入熔断连击)
|
|
294
|
+
var HUNG_CIRCUIT_WHY = '挂死零产出(会话日志字节持续不涨),已熔断' // 挂死触发的熔断文案分态(挂死≠秒拆)
|
|
295
|
+
// 挂死型模型面失败识别:判死结算的错误文本(HUNG_KILL_PREFIX 开头)与宿主秒拆/activation 异常
|
|
296
|
+
// 同属模型面故障——计入熔断连击(feedModelFailure)。文案避开冷却嗅探关键词(连接/超时/网关等),
|
|
297
|
+
// 挂死只进连击,不登记冷却(冷却表与熔断表语义分治)。
|
|
298
|
+
function isHungKillFailure(errText) { return String(errText || '').indexOf(HUNG_KILL_PREFIX) === 0 }
|
|
182
299
|
function isModelKillFailure(errText) {
|
|
183
300
|
var s = String(errText || '')
|
|
301
|
+
if (isHungKillFailure(s)) return true
|
|
184
302
|
if (s.indexOf('子会话被宿主拆除(stopReason: ') === 0) return s.indexOf('子会话被宿主拆除(stopReason: completed') !== 0
|
|
185
303
|
return s.indexOf('子会话 activation 异常:') === 0
|
|
186
304
|
}
|
|
187
305
|
// 连击 +1;达 N 且尚未拉黑 → badModels 落黑名单(取模链/降档候选的既有熔断判定照常消费,零新分支)
|
|
188
306
|
// + 健康表 circuit + 系统通知(不带 taskId:与验收熔断通知同口径,不受任务回执冲刷丢弃)。
|
|
307
|
+
// why 可选(task-mvqcoz9l):挂死触发的熔断文案分态(挂死≠秒拆),缺省=既有秒拆文案(既有调用点零回归)。
|
|
189
308
|
// 返回是否本次调用触发拉黑(测试/留痕用)。
|
|
190
|
-
function feedModelFailure(sid, model) {
|
|
309
|
+
function feedModelFailure(sid, model, why) {
|
|
191
310
|
if (!model) return false
|
|
192
311
|
var k = modelKey(sid, model)
|
|
193
312
|
var n = (modelFailStreak[k] || 0) + 1
|
|
@@ -195,16 +314,97 @@ export function createDispatch(ctx, state, deps) {
|
|
|
195
314
|
if (n < WORKER_MODEL_CIRCUIT_AFTER) return false
|
|
196
315
|
if (!badModels[k]) {
|
|
197
316
|
badModels[k] = true
|
|
198
|
-
markModelCircuit(sid, model, '连续 ' + n + ' 次 spawn/续跑即被宿主拆除(模型失败),已熔断')
|
|
199
|
-
pushSysNote(sid, '模型 ' + model + ' 连续 ' + n + ' 次 spawn
|
|
317
|
+
markModelCircuit(sid, model, why || ('连续 ' + n + ' 次 spawn/续跑即被宿主拆除(模型失败),已熔断'))
|
|
318
|
+
pushSysNote(sid, '模型 ' + model + ' ' + (why || ('连续 ' + n + ' 次 spawn/续跑即被宿主秒拆(模型失败),已熔断')) + '——后续取模链跳过该模型')
|
|
200
319
|
}
|
|
201
320
|
return true
|
|
202
321
|
}
|
|
203
322
|
// 连击归零:任一可归因 run 正常完成即证明模型链路恢复(settleRunRecord completed 路径调用)
|
|
204
323
|
function resetModelFailure(sid, model) { if (model) delete modelFailStreak[modelKey(sid, model)] }
|
|
205
|
-
// ===== 主动模型嗅探:llm.stream
|
|
324
|
+
// ===== 主动模型嗅探:llm.stream 极短直调(无子代理/系统提示/工具,首探 maxTokens=1)=====
|
|
206
325
|
// finish=stop/max-tokens 等均证明模型链路可用;error/aborted/无 finish 才算失败。失败文本复用既有冷却登记,
|
|
207
326
|
// 但不改 badModels 熔断判定。同模型全局只在飞一次,15s 硬超时带 AbortSignal,绝不阻塞 poolCycle 派发。
|
|
327
|
+
// ===== 空响应重试(task-mvrfp1rj)+ 错误码分治(task-mvrqdw4k 用户拍板):探测只裁存活性 =====
|
|
328
|
+
// 实证(cmss/zhanlu 系 glm-5.2、deepseek-v4.1-flash):maxTokens=1 的极简请求被供应商以「terminal stop
|
|
329
|
+
// 零内容块」退回,宿主 adapter 归成 EMPTY_RESPONSE 错误(逐字文案,宿主 checkout grep 实测):
|
|
330
|
+
// ① dsh-llm-pi-ai mapStopReason:model "<m>" returned a completed response with no content
|
|
331
|
+
// ② dsh-llm-deepseek :DeepSeek Messages returned no content
|
|
332
|
+
// 可用性实证(task-mvrqdw4k 三重,决定性):v4.1-flash 正产出当轮对话、两次「失败」皆我方固定 60s 挂死闸
|
|
333
|
+
// 误杀(已修为自适应)——它是慢不是死,纯空响应=供应商怪癖,不该判死。
|
|
334
|
+
// 校准口径(用户拍板·错误码分治矩阵):
|
|
335
|
+
// ① 首探 maxTokens=1(最省 token;一次通即证明链路可用);
|
|
336
|
+
// ② 首探「空响应」→ 自动以 maxTokens=8 重试一次(给模型吐 token 的余地,消掉 length 截断型空响应大头);
|
|
337
|
+
// ③ 重试有内容 → 🟢 探测通过(存活 = 可调用);
|
|
338
|
+
// ④ 重试仍空 → 按文本/码信号分治(classifyEmptyResponse):纯空响应=🟡 degraded 存活(链路通,供应商
|
|
339
|
+
// 怪癖)——observeModel 记降级不记失败、不喂冷却嗅探、路由仍可选用;额度/凭据/连接信号=真错,维持
|
|
340
|
+
// 失败臂照旧 observeModel 失败 + 喂冷却嗅探(quota 文本优先级高于空响应判定,⑬b/⑬d 契约保留);
|
|
341
|
+
// ⑤ 真错误/aborted/超时/无 finish 语义逐字不变,且不触发重试(一次定性,省一次请求也省一次误判)。
|
|
342
|
+
// 放宽不放跑真坏模型(闭环论证):额度真耗尽有两条独立通道——①宿主直接抛 QUOTA_EXCEEDED(探测即降级);
|
|
343
|
+
// ②若网关伪装成空响应,真派发 run 会失败 → recentRuns 软闸(≥2 全败)跳过它——探测管可调性、行为闸管质量。
|
|
344
|
+
// 边界:只动探测判定/健康状态派生/UI 映射与断言;探测调度节奏(probeNextAt/probeIntervalMin)与熔断联动不动。
|
|
345
|
+
var PROBE_MAX_TOKENS = 1 // 首探极简:1 token 证链路通
|
|
346
|
+
var PROBE_MAX_TOKENS_RETRY = 8 // 空响应重试:给模型吐 token 的余地
|
|
347
|
+
var PROBE_ATTEMPT_TIMEOUT_MS = 20000 // 每次尝试独立超时(task-mvsv8zym:两次尝试各拿满 20s,非共享递减——
|
|
348
|
+
// 旧 15s 共享预算下慢模型首探耗 ~10s 后重试只剩残余必超时,被我方预算饿死误记成模型失败)。
|
|
349
|
+
// 总上限 40s 不违反「绝不阻塞 poolCycle」:探测经 maybeProbeModels 后台异步跑(不 await 进派发主链)。
|
|
350
|
+
// EMPTY_RESPONSE 识别(双路):结构化 code 优先(宿主 finish.failure.code,常量见 dsh-llm/lib/types/error.js),
|
|
351
|
+
// 文本特征兜底(兼容只给 message 的旧宿主/网关包装文本)。只认「completed 但零内容」;
|
|
352
|
+
// 429/连接/超时等真错误文本一律 false,走既有失败臂(冷却登记口径不变)。
|
|
353
|
+
function isEmptyResponse(code, text) {
|
|
354
|
+
if (String(code || '') === 'EMPTY_RESPONSE') return true
|
|
355
|
+
return /no content/i.test(String(text || ''))
|
|
356
|
+
}
|
|
357
|
+
// 错误码分治信号(task-mvrqdw4k):宿主把额度/凭据问题单独编码(QUOTA_EXCEEDED_CODE/INVALID_CREDENTIAL_CODE
|
|
358
|
+
// 与 EMPTY_RESPONSE_CODE 并列导出),网关包装文本同样带字样——空响应文本/码命中额度/凭据/连接信号 → 真错,
|
|
359
|
+
// 维持失败臂(冷却嗅探接管);纯空响应(无任何真错信号)→ 🟡degraded 存活(供应商怪癖:链路通但极简请求零内容)。
|
|
360
|
+
var QUOTA_SIGNAL_RE = /429|RATE_LIMIT|QUOTA_EXCEEDED|rate.?limit|限额|额度|quota/i
|
|
361
|
+
var CREDENTIAL_SIGNAL_RE = /INVALID_CREDENTIAL|\b401\b|invalid[ _-]?api[ _-]?key|unauthorized|凭据|凭证/i
|
|
362
|
+
// 空响应分治:返回 'degraded'(纯空响应=链路存活)| 'fail'(额度/凭据/连接信号=真错,走失败臂)。
|
|
363
|
+
// 判定顺序:额度 > 凭据 > 连接 > degraded(与 parseModelCooldown 先限流后连接的保守口径一致)。
|
|
364
|
+
function classifyEmptyResponse(code, text) {
|
|
365
|
+
var s = String(text || ''), c = String(code || '')
|
|
366
|
+
if (QUOTA_SIGNAL_RE.test(s) || /QUOTA/i.test(c)) return 'fail'
|
|
367
|
+
if (CREDENTIAL_SIGNAL_RE.test(s) || /CREDENTIAL/i.test(c)) return 'fail'
|
|
368
|
+
if (CONNECTIVITY_RE.test(s)) return 'fail'
|
|
369
|
+
return 'degraded'
|
|
370
|
+
}
|
|
371
|
+
// 单次探测请求:返回 { status:'ok' } | { status:'empty', text, code } | { status:'fail', text }
|
|
372
|
+
// | { status:'timeout', text }(task-mvsv8zym:超时单列形态,不再与真错误混记 fail——
|
|
373
|
+
// 超时可能只是慢模型+我方预算约束,由上层按首次尝试判定落定或保留既有观测)。
|
|
374
|
+
// attemptMs = 单次尝试独立超时(每尝试各拿满全额预算,非共享递减;task-mvsv8zym)。
|
|
375
|
+
// 每次尝试独立 AbortController/计时器;probeAbort 指向当前在飞尝试(dispose 时统一 abort)。
|
|
376
|
+
async function probeOnce(llm, model, maxTokens, attemptMs) {
|
|
377
|
+
var ac = new AbortController(), timedOut = false, disposeTimer = null
|
|
378
|
+
var attempt = Math.max(1, Number(attemptMs) || 0)
|
|
379
|
+
var slash = model.indexOf('/')
|
|
380
|
+
try {
|
|
381
|
+
if (ctx.timer && typeof ctx.timer.timeout === 'function') disposeTimer = ctx.timer.timeout(function () { timedOut = true; ac.abort() }, attempt)
|
|
382
|
+
else { var nativeTimer = setTimeout(function () { timedOut = true; ac.abort() }, attempt); disposeTimer = function () { clearTimeout(nativeTimer) } }
|
|
383
|
+
probeAbort[model] = ac
|
|
384
|
+
var stream = llm.stream({ provider: model.slice(0, slash), model: model.slice(slash + 1), messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }], maxTokens: maxTokens, signal: ac.signal })
|
|
385
|
+
var finished = false, failure = '', failureCode = ''
|
|
386
|
+
for await (var chunk of stream) {
|
|
387
|
+
if (!chunk || chunk.type !== 'finish') continue
|
|
388
|
+
finished = true
|
|
389
|
+
var reason = chunk.reason || {}
|
|
390
|
+
if (reason.kind === 'error' || reason.kind === 'aborted') {
|
|
391
|
+
failure = String((reason.failure && reason.failure.message) || reason.kind || '模型探测失败')
|
|
392
|
+
failureCode = String((reason.failure && reason.failure.code) || '')
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (timedOut) return { status: 'timeout', text: 'probe timeout ' + Math.round(attempt) + 'ms' }
|
|
396
|
+
if (!finished) return { status: 'fail', text: '模型探测流未返回 finish' }
|
|
397
|
+
if (failure) return isEmptyResponse(failureCode, failure) ? { status: 'empty', text: failure, code: failureCode } : { status: 'fail', text: failure }
|
|
398
|
+
return { status: 'ok' }
|
|
399
|
+
} catch (e) {
|
|
400
|
+
// 抛错形态(provider 直接 throw LlmError):code 可能带 EMPTY_RESPONSE,文本兜底同一谓词
|
|
401
|
+
if (timedOut) return { status: 'timeout', text: 'probe timeout ' + Math.round(attempt) + 'ms' }
|
|
402
|
+
var text = String(e && e.message || e)
|
|
403
|
+
return isEmptyResponse(e && e.code, text) ? { status: 'empty', text: text, code: String(e && e.code || '') } : { status: 'fail', text: text }
|
|
404
|
+
} finally {
|
|
405
|
+
try { if (typeof disposeTimer === 'function') disposeTimer() } catch (_) {}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
208
408
|
async function probeModel(sid, model) {
|
|
209
409
|
if (probeDisposed) return null
|
|
210
410
|
if (probeInFlight[model]) return probeInFlight[model]
|
|
@@ -212,54 +412,92 @@ export function createDispatch(ctx, state, deps) {
|
|
|
212
412
|
if (!llm || typeof llm.stream !== 'function') return null
|
|
213
413
|
var slash = model.indexOf('/')
|
|
214
414
|
if (slash <= 0 || slash === model.length - 1) return null
|
|
215
|
-
var started = Date.now()
|
|
415
|
+
var started = Date.now()
|
|
216
416
|
var promise = (async function () {
|
|
217
417
|
try {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
finished = true
|
|
225
|
-
var reason = chunk.reason || {}
|
|
226
|
-
if (reason.kind === 'error' || reason.kind === 'aborted') failure = String((reason.failure && reason.failure.message) || reason.kind || '模型探测失败')
|
|
418
|
+
var outcome = await probeOnce(llm, model, PROBE_MAX_TOKENS, PROBE_ATTEMPT_TIMEOUT_MS)
|
|
419
|
+
if (outcome.status === 'timeout') {
|
|
420
|
+
// ③ 首探即超时(task-mvsv8zym):无重试(⑬e 契约保留),且不覆盖既有观测——只更新 note
|
|
421
|
+
// (判定保留):超时可能只是慢模型+我方预算约束,已 healthy/degraded 条目不倒退成 unknown。
|
|
422
|
+
observeModel(sid, model, false, { probe: true, preserveObserved: true, note: String(outcome.text || '').slice(0, 300) })
|
|
423
|
+
return false
|
|
227
424
|
}
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
425
|
+
if (outcome.status === 'empty') {
|
|
426
|
+
// 空响应 = 唯一触发重试的形态(真错误/超时/无 finish 一次定性,不重试)
|
|
427
|
+
if (probeDisposed) {
|
|
428
|
+
// 探测已停(dispose 中断):无重试机会,仍按错误码分治落定(停机是我方调度约束,非模型健康信号)
|
|
429
|
+
var budgetText = '空响应且探测已停(不重试,零内容):' + outcome.text
|
|
430
|
+
outcome = classifyEmptyResponse(outcome.code, outcome.text) === 'degraded'
|
|
431
|
+
? { status: 'degraded', text: budgetText }
|
|
432
|
+
: { status: 'fail', text: budgetText }
|
|
433
|
+
} else {
|
|
434
|
+
var first = outcome
|
|
435
|
+
outcome = await probeOnce(llm, model, PROBE_MAX_TOKENS_RETRY, PROBE_ATTEMPT_TIMEOUT_MS)
|
|
436
|
+
// 重试仍空 = 按错误码分治(task-mvrqdw4k 用户拍板):纯空响应=🟡degraded 存活(供应商怪癖,链路通)
|
|
437
|
+
// ——不喂冷却、路由仍可选用;额度/凭据/连接信号=真错,维持失败臂(quota 文本优先级高于空响应判定,
|
|
438
|
+
// ⑬b/⑬d 契约保留)。文案标明重试口径便于排查与嗅探归因。
|
|
439
|
+
if (outcome.status === 'empty') {
|
|
440
|
+
var emptyText = 'maxTokens=' + PROBE_MAX_TOKENS_RETRY + ' 重试仍空(EMPTY_RESPONSE):' + outcome.text
|
|
441
|
+
outcome = classifyEmptyResponse(outcome.code, outcome.text) === 'degraded'
|
|
442
|
+
? { status: 'degraded', text: emptyText }
|
|
443
|
+
: { status: 'fail', text: emptyText }
|
|
444
|
+
} else if (outcome.status === 'timeout') {
|
|
445
|
+
// ② 重试超时≠失败(task-mvsv8zym):每尝试独立 20s 预算仍耗尽=慢模型,沿用首次尝试判定落定
|
|
446
|
+
// ——与「探测已停」分支同原则(我方预算/停机约束非模型健康信号,L407-412 对称补齐):
|
|
447
|
+
// 首探纯空响应→🟡degraded;首探文本带额度/凭据/连接信号→fail(嗅探接管)。
|
|
448
|
+
var firstVerdict = classifyEmptyResponse(first.code, first.text)
|
|
449
|
+
outcome = firstVerdict === 'degraded'
|
|
450
|
+
? { status: 'degraded', text: '重试预算耗尽(慢模型),沿用首探判定:' + String(first.text || '') }
|
|
451
|
+
: { status: 'fail', text: '重试预算耗尽(慢模型),沿用首探判定:' + String(first.text || '') }
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (outcome.status === 'ok') {
|
|
456
|
+
observeModel(sid, model, true, { probe: true, latencyMs: Date.now() - started })
|
|
457
|
+
return true
|
|
458
|
+
}
|
|
459
|
+
if (outcome.status === 'degraded') {
|
|
460
|
+
// 🟡 降级存活臂(task-mvrqdw4k):记存活不记失败——observeModel 落 degraded 派生态;
|
|
461
|
+
// 不调 sniffModelCooldown(冷却表零登记);recentRuns 不动(探测本就不进 recentRuns,观察臂已隔开)。
|
|
462
|
+
observeModel(sid, model, false, { probe: true, degraded: true, latencyMs: Date.now() - started, note: String(outcome.text || '').slice(0, 300) })
|
|
463
|
+
return 'degraded'
|
|
464
|
+
}
|
|
465
|
+
var text = String(outcome.text || '模型探测失败').slice(0, 300)
|
|
234
466
|
observeModel(sid, model, false, { probe: true, latencyMs: Date.now() - started, note: text })
|
|
235
467
|
if (!probeDisposed) { try { await sniffModelCooldown(sid, '', model, text) } catch (_) {} }
|
|
236
468
|
return false
|
|
469
|
+
} catch (e) {
|
|
470
|
+
// 兜底臂(probeOnce 自吞错,理论不可达):语义与旧实现逐字一致
|
|
471
|
+
var failText = String(e && e.message || e).slice(0, 300)
|
|
472
|
+
observeModel(sid, model, false, { probe: true, latencyMs: Date.now() - started, note: failText })
|
|
473
|
+
if (!probeDisposed) { try { await sniffModelCooldown(sid, '', model, failText) } catch (_) {} }
|
|
474
|
+
return false
|
|
237
475
|
} finally {
|
|
238
|
-
try { if (typeof disposeTimer === 'function') disposeTimer() } catch (_) {}
|
|
239
476
|
delete probeInFlight[model]
|
|
240
477
|
delete probeAbort[model]
|
|
241
478
|
}
|
|
242
479
|
})()
|
|
243
480
|
probeInFlight[model] = promise
|
|
244
|
-
probeAbort[model] = ac
|
|
245
481
|
return promise
|
|
246
482
|
}
|
|
247
483
|
async function probeConfiguredModels(sid, d) {
|
|
248
484
|
var c = cfg(d), models = core.configuredModelIds(d)
|
|
249
485
|
for (var i = 0; i < models.length; i++) syncModelHealth(sid, models[i])
|
|
250
|
-
if (!c.probeEnabled) return { disabled: true, models: models, probed: 0 }
|
|
486
|
+
if (!c.probeEnabled) return { disabled: true, models: models, probed: 0, degraded: 0 }
|
|
251
487
|
var llm = ctx.get && ctx.get('llm')
|
|
252
|
-
if (!llm || typeof llm.stream !== 'function') return { disabled: false, unavailable: true, models: models, probed: 0 }
|
|
253
|
-
var probed = 0, healthy = 0
|
|
488
|
+
if (!llm || typeof llm.stream !== 'function') return { disabled: false, unavailable: true, models: models, probed: 0, degraded: 0 }
|
|
489
|
+
var probed = 0, healthy = 0, degraded = 0
|
|
254
490
|
for (var m = 0; m < models.length; m++) {
|
|
255
491
|
if (probeDisposed) break
|
|
256
492
|
var before = probeInFlight[models[m]] ? false : true
|
|
257
|
-
var
|
|
258
|
-
if (
|
|
493
|
+
var outcome = await probeModel(sid, models[m])
|
|
494
|
+
if (outcome === null) continue
|
|
259
495
|
if (before) probed++
|
|
260
|
-
|
|
496
|
+
// task-mvrqdw4k:probeModel 三态消费——true=🟢存活、'degraded'=🟡降级存活(供应商怪癖)、false=失败
|
|
497
|
+
if (outcome === true) healthy++
|
|
498
|
+
else if (outcome === 'degraded') degraded++
|
|
261
499
|
}
|
|
262
|
-
return { disabled: false, models: models, probed: probed, healthy: healthy }
|
|
500
|
+
return { disabled: false, models: models, probed: probed, healthy: healthy, degraded: degraded }
|
|
263
501
|
}
|
|
264
502
|
// 15s 调度心跳驱动间隔闸:配置变化立即重排,探测在后台跑,不 await 进派发主链。
|
|
265
503
|
function maybeProbeModels(sid, d) {
|
|
@@ -541,6 +779,10 @@ export function createDispatch(ctx, state, deps) {
|
|
|
541
779
|
var dsnap = await rt(sid)
|
|
542
780
|
if (role === 'worker' || role === 'verifier') {
|
|
543
781
|
var roleKey = role === 'verifier' ? 'verifier' : 'worker'
|
|
782
|
+
// 目录失效闸数据源(task-mvd3li6):取一次当前模型目录(llm.listProviders+listModels,与 rpc enumModels
|
|
783
|
+
// 同源);null=目录不可得(llm 缺失老宿主/枚举异常/空目录)→ 目录闸整体跳过不误伤(保守语义③,
|
|
784
|
+
// 既有熔断/冷却/连败软闸照常生效,行为不因宿主能力缺失而变)。
|
|
785
|
+
var dir = await modelDirectory()
|
|
544
786
|
var cands = []
|
|
545
787
|
var tm = typeof t.model === 'string' ? t.model.trim() : ''
|
|
546
788
|
if (tm) cands.push([tm, 'card'])
|
|
@@ -549,12 +791,13 @@ export function createDispatch(ctx, state, deps) {
|
|
|
549
791
|
var laneCfg = cfg(dsnap).laneModels
|
|
550
792
|
if (sz && laneCfg[sz]) {
|
|
551
793
|
// 档位槽=有序数组(task-mvnxckhq 同档优先序,第一位=首选):槽内按序取第一个**健康**模型
|
|
552
|
-
// (healthyModel
|
|
794
|
+
// (healthyModel 四闸:不过 badModels 熔断、不在 modelCooldown 冷却期、目录可得时不在目录外
|
|
795
|
+
// 【目录失效闸 task-mvd3li6】、近期连败未达软闸线【连败软闸 task-mvd3li6】;偏好序非故障序——
|
|
553
796
|
// 首选病即次选赢,不降级不绕链)。槽内全病 → 以首项为代表照常入链,由既有熔断/冷却分支
|
|
554
797
|
// 照旧消费 fallbackModels → 降档 → parent(既有链逐字不动);空数组槽位=不命中,降下一级。
|
|
555
798
|
var lvList = Array.isArray(laneCfg[sz][roleKey]) ? laneCfg[sz][roleKey] : []
|
|
556
799
|
var lv = ''
|
|
557
|
-
for (var li = 0; li < lvList.length; li++) { if (healthyModel(sid, lvList[li])) { lv = lvList[li]; break } }
|
|
800
|
+
for (var li = 0; li < lvList.length; li++) { if (healthyModel(sid, lvList[li], dir)) { lv = lvList[li]; break } }
|
|
558
801
|
if (!lv && lvList.length) lv = lvList[0] // 槽内全病:首项代表入链(既有熔断/冷却分支照旧兜底)
|
|
559
802
|
if (lv) cands.push([lv, 'lane'])
|
|
560
803
|
}
|
|
@@ -568,9 +811,13 @@ export function createDispatch(ctx, state, deps) {
|
|
|
568
811
|
// 留痕:命中即 run.modelSource='fallback' + 卡 messages 记「原模型 X 冷却/熔断,切备用 Y」。
|
|
569
812
|
var fbList = cfg(dsnap).fallbackModels
|
|
570
813
|
for (var mi = 0; mi < cands.length; mi++) {
|
|
814
|
+
// ===== 目录失效闸(task-mvd3li6):候选不在当前模型目录(已从宿主配置删除/改名)→ 跳过该候选 =====
|
|
815
|
+
// 覆盖卡级特例/档位/板级全路径——卡级特例只豁免健康闸(连败软闸/熔断/冷却),**不豁免存在闸**
|
|
816
|
+
// (原形:本体板已删除的失效模型钉在卡级特例上还在被派)。目录不可得(dir=null)→ 整体跳过不误伤。
|
|
817
|
+
if (outOfDir(cands[mi][0], dir)) { await traceStaleModel(sid, t.id, cands[mi][0]); continue }
|
|
571
818
|
if (badModels[modelKey(sid, cands[mi][0])]) {
|
|
572
819
|
// 熔断候选:先扫备用链找首个健康者(链本身逐项过健康检查),命中即切备用(不再降下一跳)
|
|
573
|
-
var fbHit = pickHealthyFallback(sid, fbList)
|
|
820
|
+
var fbHit = await pickHealthyFallback(sid, fbList, dir, t.id)
|
|
574
821
|
if (fbHit) {
|
|
575
822
|
console.error('[task-board] model ' + cands[mi][0] + ' (' + cands[mi][1] + ' 级) circuited, switch to fallback ' + fbHit)
|
|
576
823
|
try {
|
|
@@ -601,7 +848,7 @@ export function createDispatch(ctx, state, deps) {
|
|
|
601
848
|
if (cdUntil) {
|
|
602
849
|
var cdFrom = modelOverride
|
|
603
850
|
// 冷却候选:同样先扫备用链找首个健康者,链尽才走 C5 降档链
|
|
604
|
-
var fbCd = pickHealthyFallback(sid, fbList)
|
|
851
|
+
var fbCd = await pickHealthyFallback(sid, fbList, dir, t.id)
|
|
605
852
|
if (fbCd) {
|
|
606
853
|
modelOverride = fbCd
|
|
607
854
|
modelSource = 'fallback'
|
|
@@ -625,6 +872,7 @@ export function createDispatch(ctx, state, deps) {
|
|
|
625
872
|
var dmList = dl && Array.isArray(dl[roleKey]) ? dl[roleKey] : []
|
|
626
873
|
for (var dmi = 0; dmi < dmList.length; dmi++) {
|
|
627
874
|
var dm = dmList[dmi]
|
|
875
|
+
if (outOfDir(dm, dir)) { console.error('[task-board] cooldown 降档候选 ' + dm + ' 不在当前模型目录(配置已失效),继续降档'); await traceStaleModel(sid, t.id, dm); continue }
|
|
628
876
|
if (badModels[modelKey(sid, dm)]) { console.error('[task-board] cooldown 降档候选 ' + dm + ' 熔断中,继续降档'); continue }
|
|
629
877
|
if (cooldownLive(dm)) { console.error('[task-board] cooldown 降档候选 ' + dm + ' 同在冷却期,继续降档'); continue }
|
|
630
878
|
cdPicked = dm; break
|
|
@@ -767,7 +1015,8 @@ export function createDispatch(ctx, state, deps) {
|
|
|
767
1015
|
// hold 终止据此 dispose 销毁子会话(老契约路径无 activation,act=null,保持 interrupt 留存)。
|
|
768
1016
|
var rec = useContinuable
|
|
769
1017
|
? { id: childId, childId: childId, continuable: true, ran: false, run: null, act: actForSettle, role: role, taskId: t.id, startedAt: Date.now(), model: modelOverride, modelSource: modelSource, size: laneSize || undefined, settled: false }
|
|
770
|
-
|
|
1018
|
+
// activation 的 run.id=act.childId,可接 assistant-stream;老 start 的 run.id=provider run id,留空走日志闸。
|
|
1019
|
+
: { id: String(run.id), frameSessionId: hasActivationApi(subagents) ? String(run.id) : '', run: run, role: role, taskId: t.id, startedAt: Date.now(), model: modelOverride, modelSource: modelSource, size: laneSize || undefined, settled: false }
|
|
771
1020
|
// ===== 新宿主 continuable 结算兜底臂(v0.2.1 实证 2026-10-24:activation.result 只认拆除落定)=====
|
|
772
1021
|
// 原形:rate-limit 子会话 turn/end error → 管理器拆除 activation → result 落定——
|
|
773
1022
|
// 但 agent/status 事件面在 v0.2.1 的 Scoped 事件体系下对插件全局订阅不再可达(子会话死亡 8 分钟零结算实证),
|
|
@@ -945,9 +1194,14 @@ export function createDispatch(ctx, state, deps) {
|
|
|
945
1194
|
// 已被熔断/冷却时照样续跑(实证:病模型续跑 38 分钟零产出)。修:续跑前查目标会话模型健康
|
|
946
1195
|
// (badModels 熔断 + modelCooldown 冷却,healthyModel 双闸同款),病模型 → 放弃续跑返回 fallback,
|
|
947
1196
|
// 调用方落回 fresh spawn 走健康取模链(链自动跳过病模型),原因随 resume-fallback 消息留档。
|
|
1197
|
+
// task-mvd3li6 起健康面拓宽同款适用:近期连败软闸(recentRuns ≥2 全败)+ 目录失效闸(原模型
|
|
1198
|
+
// 已从模型目录移除,续跑也救不回已删模型)一并拦下;目录不可得(dir=null)时目录闸跳过不误伤。
|
|
948
1199
|
var origModel = resumeModelOf(t)
|
|
949
|
-
if (origModel
|
|
950
|
-
|
|
1200
|
+
if (origModel) {
|
|
1201
|
+
var dir = await modelDirectory()
|
|
1202
|
+
if (!healthyModel(sid, origModel, dir)) {
|
|
1203
|
+
return { fallback: '续跑放弃:原模型 ' + origModel + ' 熔断/冷却/近期连败或配置已失效(续跑健康闸)——改走健康取模链重跑' }
|
|
1204
|
+
}
|
|
951
1205
|
}
|
|
952
1206
|
var parent = rootForSession(sid)
|
|
953
1207
|
if (!parent) return { fallback: 'root 会话不在线,无法授权续跑' }
|
|
@@ -1181,17 +1435,25 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1181
1435
|
// 三分支:verdict=split → applyDecomposeProposal 物化(幂等清旧批 draft 提案)+ 源卡摘要消息 + 通知;
|
|
1182
1436
|
// verdict=keep → 源卡记「无需拆分:rationale」;
|
|
1183
1437
|
// run 失败 / structured 缺失 / 提案畸形 → 源卡记失败原因 + 通知(源卡与旧提案批都不动)。
|
|
1184
|
-
//
|
|
1185
|
-
//
|
|
1438
|
+
// 通知(task-mvrqiu6x):split/keep 成功改走 notifyDecomposed(pushReceipt 聚合内核出「🧩 已拆解」分区,
|
|
1439
|
+
// 受 notifyDone 开关约束)——源卡按设计停在 draft/pending,旧实现借 pushSysNote 系统通道绕行
|
|
1440
|
+
// 「带 taskId 会被仍在执行中过滤器丢弃」的墙,代价是成功信号伪装成「⚠️ 系统异常」;
|
|
1441
|
+
// 失败分支保持 pushSysNote 🚨 不变(异常就该是异常)。
|
|
1186
1442
|
async function settleDecompose(sid, rec, res, err) {
|
|
1187
1443
|
var failed = !!err || (res && res.stopReason && res.stopReason !== 'completed')
|
|
1188
1444
|
var errText = err ? String(err) : (res && (res.diagnostic || res.stopReason) || '')
|
|
1189
1445
|
var structured = (res && res.structured && typeof res.structured === 'object') ? res.structured : null
|
|
1190
1446
|
var applied = null
|
|
1447
|
+
var srcTitle = ''
|
|
1448
|
+
// 完成回执开关(设置区「通知」notifyDone,task-mvrqiu6x):同一次持锁回调里读看板文档(零额外读盘,
|
|
1449
|
+
// 与 settleWorker/settleVerifier 写法一致);缺字段 → cfg 归一为 true,宁可多报不漏报。
|
|
1450
|
+
var doneOn = true
|
|
1191
1451
|
try {
|
|
1192
1452
|
applied = await mutateLocked(sid, function (d) {
|
|
1193
1453
|
var t = d.tasks.find(function (x) { return x.id === rec.taskId })
|
|
1194
1454
|
if (!t) return null
|
|
1455
|
+
srcTitle = t.title
|
|
1456
|
+
doneOn = cfg(d).notifyDone !== false
|
|
1195
1457
|
if (!Array.isArray(t.messages)) t.messages = []
|
|
1196
1458
|
if (failed) {
|
|
1197
1459
|
t.messages.push({ kind: 'decompose', text: '🧩 拆解官 run 失败(' + String(errText || '未知错误').slice(0, 200) + ')——源卡未改动,可重试 task_decompose', at: new Date().toISOString(), by: 'system' })
|
|
@@ -1205,9 +1467,14 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1205
1467
|
}, true) // skipKick:物化的是 draft 子卡(不参与派发),无需踢 poolCycle
|
|
1206
1468
|
} catch (e) { console.error('[task-board] settle decompose failed (task ' + rec.taskId + '):', String(e)); applied = null }
|
|
1207
1469
|
try {
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1470
|
+
// 拆解官正式回执:走 notifyDecomposed(「🧩 已拆解」聚合分区,notifyDone 闸门同完成回执口径;
|
|
1471
|
+
// 源卡 messages 的「🧩 拆解提案已就位/无需拆分」留痕已由 applyDecomposeProposal 落卡,
|
|
1472
|
+
// 关开关时留痕照旧不丢)——成功信号不再借系统通道伪装异常。
|
|
1473
|
+
if (applied && applied.ok && applied.verdict === 'split') {
|
|
1474
|
+
if (doneOn && typeof notifyDecomposed === 'function') notifyDecomposed(sid, { sourceId: rec.taskId, sourceTitle: srcTitle, verdict: 'split', children: applied.children, reason: '' })
|
|
1475
|
+
} else if (applied && applied.ok && applied.verdict === 'keep') {
|
|
1476
|
+
if (doneOn && typeof notifyDecomposed === 'function') notifyDecomposed(sid, { sourceId: rec.taskId, sourceTitle: srcTitle, verdict: 'keep', children: [], reason: structured ? String(structured.rationale == null ? '' : structured.rationale).trim().slice(0, 150) : '' })
|
|
1477
|
+
} else pushSysNote(sid, '🚨 拆解官 run 未产出可用提案(源卡 ' + rec.taskId + '):' + String((applied && applied.error) || errText || 'structured 缺失').slice(0, 200)) // 失败分支:异常就是异常,保持 🚨 系统通道(文案逐字不变)
|
|
1211
1478
|
} catch (e) { console.error('[task-board] decompose notify failed:', String(e)) }
|
|
1212
1479
|
}
|
|
1213
1480
|
|
|
@@ -1531,12 +1798,47 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1531
1798
|
})
|
|
1532
1799
|
} catch (e) { console.error('[task-board] agent/status 处理失败:', String(e)) }
|
|
1533
1800
|
}
|
|
1801
|
+
// 可映射帧流的会话 id:continuable 用 childId;activation 一次性 run 在创建时显式记录 childId。
|
|
1802
|
+
// 老 start 的 provider run id 不是会话身份,frameSessionId 为空,sweep 自动保留日志双闸。
|
|
1803
|
+
function frameSessionIdOf(rec) {
|
|
1804
|
+
if (!rec) return ''
|
|
1805
|
+
return String(rec.continuable ? (rec.childId || '') : (rec.frameSessionId || ''))
|
|
1806
|
+
}
|
|
1807
|
+
var frameStreamSeen = false // 本 host 生命周期是否见过任意 assistant-stream 帧(薄桩/mock 零帧时全局兜底)
|
|
1808
|
+
function onAssistantFrame(payload) {
|
|
1809
|
+
try {
|
|
1810
|
+
if (!payload || !payload.frame) return
|
|
1811
|
+
var agent = payload.agent
|
|
1812
|
+
var cid = agent ? String((agent.session && agent.session.id) || agent.id || '') : ''
|
|
1813
|
+
if (!cid) return
|
|
1814
|
+
frameStreamSeen = true
|
|
1815
|
+
var table = state.activeRuns
|
|
1816
|
+
Object.keys(table).forEach(function (sid) {
|
|
1817
|
+
var rr = table[sid]; if (!rr) return
|
|
1818
|
+
Object.keys(rr).forEach(function (k) {
|
|
1819
|
+
var rec = rr[k]
|
|
1820
|
+
if (!rec || rec.settled) return
|
|
1821
|
+
var mapped = frameSessionIdOf(rec)
|
|
1822
|
+
// 事件本身就是身份证据:手工重建/旧形态 rec 若 id 命中,也可安全建立映射;
|
|
1823
|
+
// 老 start 的 provider run id 通常不命中,保持日志闸。
|
|
1824
|
+
if (!mapped && String(rec.id || '') === cid) { rec.frameSessionId = cid; mapped = cid }
|
|
1825
|
+
if (mapped !== cid) return
|
|
1826
|
+
// start/chunk/end 全算活;chunk 不过滤 chunk.type,reasoning-delta 同样刷新。只进内存,绝不逐帧写盘。
|
|
1827
|
+
rec.lastFrameAt = Date.now()
|
|
1828
|
+
rec.frameDirty = true // sweep 据此识别“本轮前有新帧”;不能拿持久基线与时间戳比大小(E2E 会倒推基线)
|
|
1829
|
+
})
|
|
1830
|
+
})
|
|
1831
|
+
} catch (e) { console.error('[task-board] agent/assistant-stream 处理失败:', String(e)) }
|
|
1832
|
+
}
|
|
1534
1833
|
// 注册(cordis 标准事件订阅):返回的 disposer 交给 ctx.effect 回收。
|
|
1535
1834
|
// 老宿主/测试桩上没有 ctx.on 时静默跳过——派发照常,只是没有事件结算通道(一次性路径本就不需要)。
|
|
1536
1835
|
if (typeof ctx.on === 'function') {
|
|
1537
1836
|
try {
|
|
1538
1837
|
ctx.effect(function () { return ctx.on('agent/status', onAgentStatus) })
|
|
1539
1838
|
} catch (e) { console.error('[task-board] agent/status 订阅失败:', String(e)) }
|
|
1839
|
+
try {
|
|
1840
|
+
ctx.effect(function () { return ctx.on('agent/assistant-stream', onAssistantFrame) })
|
|
1841
|
+
} catch (e) { console.error('[task-board] agent/assistant-stream 订阅失败:', String(e)) }
|
|
1540
1842
|
}
|
|
1541
1843
|
|
|
1542
1844
|
async function settleWorker(sid, rec, output, failed, errText) {
|
|
@@ -1561,7 +1863,7 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1561
1863
|
// = 模型面失败,计入熔断连击——原形 task-mvoutja4:glm-5.3-flash 秒拆 ×3 不计入 badModels,
|
|
1562
1864
|
// 熔断永不触发,档位链每轮重派都再选同一个坏模型。连续 3 次才拉黑(熔断语义不变:badModels
|
|
1563
1865
|
// 落黑名单 → 取模链跳过 + 健康表 circuit);worker 自己的失误不计,成功 run 归零(settleRunRecord)。
|
|
1564
|
-
if (rec.model && isModelKillFailure(errText)) feedModelFailure(sid, rec.model)
|
|
1866
|
+
if (rec.model && isModelKillFailure(errText)) feedModelFailure(sid, rec.model, isHungKillFailure(errText) ? HUNG_CIRCUIT_WHY : undefined)
|
|
1565
1867
|
if (t.retryCount >= 3) { var ps = t.status; t.status = 'blocked'; ah(t, ps, 'blocked', String(rec.id), 'worker 失败 x' + t.retryCount + '(' + String(errText).slice(0, 120) + '),待人工介入'); return { task: t, blocked: true } }
|
|
1566
1868
|
var ps2 = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, ps2, 'pending', String(rec.id), 'worker 失败(' + String(errText).slice(0, 80) + '),重新排队 (' + t.retryCount + '/3)')
|
|
1567
1869
|
return { task: t, retry: true }
|
|
@@ -1906,6 +2208,131 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1906
2208
|
return swept.length
|
|
1907
2209
|
}
|
|
1908
2210
|
|
|
2211
|
+
// ===== 挂死零产出检测(task-mvqcoz9l):在跑 worker/verifier run 日志字节零增长判死重派+喂熔断 =====
|
|
2212
|
+
// 原形(2026-10-27 降级实测):glm-5.2 续跑挂上「连接开着永不响应」形态——会话不死(死会话哨兵
|
|
2213
|
+
// listChildren 查不出)、不报错(无 settle 事件)、零产出(日志零增长 7 分钟),running 假相直到
|
|
2214
|
+
// 人工终止。熔断/冷却/哨兵全部够不着它。修法:每轮对在跑 worker/verifier run 比对会话日志字节数
|
|
2215
|
+
// (logSizeOf)与 run 留档的 lastLogBytes(新字段,每轮写回):连续 HUNG_ZERO_GROWTH_ROUNDS 轮
|
|
2216
|
+
// 零增长且距基线 ≥600s 墙钟地板(固定值,task-mvrt9tn5 二度校准:延迟缩放作废)→ 判定挂死:settleRun 失败臂(记「挂死零产出」,errText
|
|
2217
|
+
// 前缀 HUNG_KILL_PREFIX 进 isModelKillFailure 白名单)+ 计入该模型熔断连击(挂死=模型面故障)+
|
|
2218
|
+
// 卡重派(settleWorker 失败臂回 pending / settleVerifier 失败臂重排审查)。活体增长即清零计数。
|
|
2219
|
+
// 边界(宁少勿多):
|
|
2220
|
+
// ① 只盖 worker/verifier 在跑 rec(!settled)——decomposer/hook 产出形态不同,不进本检测;
|
|
2221
|
+
// ② 留档行缺失(rec 刚 spawn、recordRunHistory 未落盘)→ 无锚点跳过,下轮再判,绝不在无锚点时判死;
|
|
2222
|
+
// ③ 首见 lastLogBytes 只记基线不计数(老数据无字段同理)——绝不在第一眼把可能真在跑的 run 判死;
|
|
2223
|
+
// ④ 活体增长(size>prev)即清零计数+刷新基线(零增长窗口重新起算)——正常 run 行为零变化;
|
|
2224
|
+
// ⑤ 字节预读在锁外(logSizeOf 走 findRunLog/statSync),比对与计数在锁内(与留档行同一次原子写);
|
|
2225
|
+
// ⑥ 判死结算在锁外:settleRun 自带认领守卫(runsFor[taskId]!==rec 早退)+ 自己的锁段——结算通道
|
|
2226
|
+
// 抢先(事件/上报/终止)则跳过,绝不重复判死;幂等(判死后 rec 摘表,下轮零候选)。
|
|
2227
|
+
// ⑦ 返回本轮判死条数(0=无候选/无判死;调用方据此决定要不要重读快照)。
|
|
2228
|
+
async function hungZeroOutputSweep(sid, runs, snap) {
|
|
2229
|
+
var cands = []
|
|
2230
|
+
Object.keys(runs).forEach(function (k) {
|
|
2231
|
+
var rec = runs[k]
|
|
2232
|
+
if (rec && !rec.settled && (rec.role === 'worker' || rec.role === 'verifier')) cands.push(rec)
|
|
2233
|
+
})
|
|
2234
|
+
if (!cands.length || !snap || snap.__noPersist || !Array.isArray(snap.tasks)) return 0
|
|
2235
|
+
// ⑤ 字节预读在锁外且只服务兜底候选:帧流已生效且 rec 可映射时不再读日志。
|
|
2236
|
+
var sizes = {}
|
|
2237
|
+
cands.forEach(function (rec) {
|
|
2238
|
+
if (frameStreamSeen && frameSessionIdOf(rec)) return
|
|
2239
|
+
sizes[String(rec.id)] = logSizeOf(String(rec.childId || rec.id))
|
|
2240
|
+
})
|
|
2241
|
+
var hung = [], hungWhy = {}
|
|
2242
|
+
await mutateLocked(sid, function (d) {
|
|
2243
|
+
if (!d || !Array.isArray(d.tasks)) return null
|
|
2244
|
+
var any = false
|
|
2245
|
+
cands.forEach(function (rec) {
|
|
2246
|
+
try {
|
|
2247
|
+
var rid = String(rec.id)
|
|
2248
|
+
var t = null
|
|
2249
|
+
for (var i = 0; i < d.tasks.length; i++) { if (d.tasks[i] && d.tasks[i].id === rec.taskId) { t = d.tasks[i]; break } }
|
|
2250
|
+
if (!t || !Array.isArray(t.runs)) return // ② 卡不存在/无留档:无锚点不判
|
|
2251
|
+
var row = null
|
|
2252
|
+
for (var j = t.runs.length - 1; j >= 0; j--) {
|
|
2253
|
+
var r0 = t.runs[j]
|
|
2254
|
+
if (r0 && String(r0.id) === rid && r0.outcome === 'running') { row = r0; break } // 本轮在跑的那条(同 id 首派+续跑取最新)
|
|
2255
|
+
}
|
|
2256
|
+
if (!row) return // ② 留档缺失:无锚点不判
|
|
2257
|
+
// ===== v2 帧活性主路径:全局见过帧 + 本 rec 有可映射会话 id 才启用 =====
|
|
2258
|
+
// 薄桩/mock/老 host 零帧,或老 start provider run id 不可映射 → 直接落下方原日志双闸,存量语义逐字不变。
|
|
2259
|
+
if (frameStreamSeen && frameSessionIdOf(rec)) {
|
|
2260
|
+
var nowFrame = Date.now()
|
|
2261
|
+
var lastFrameAt = Number(rec.lastFrameAt || 0)
|
|
2262
|
+
var frameSince = typeof row.frameSilenceSince === 'number' ? row.frameSilenceSince : 0
|
|
2263
|
+
// 事件只置内存 dirty,sweep 消费后才算“帧恢复”:删持久基线;静默未满 600s 时同步清疑似标卡。
|
|
2264
|
+
// 刻意不比较 frameSince < lastFrameAt——E2E 倒推持久基线也满足该大小关系,会把判死编排误吃成恢复。
|
|
2265
|
+
var hadFrame = rec.frameDirty === true
|
|
2266
|
+
rec.frameDirty = false
|
|
2267
|
+
if (hadFrame) {
|
|
2268
|
+
var recoverySilence = frameSince ? nowFrame - frameSince : 0
|
|
2269
|
+
if (frameSince) { delete row.frameSilenceSince; any = true }
|
|
2270
|
+
if (t.stuckSince && recoverySilence < HUNG_FRAME_KILL_MS) {
|
|
2271
|
+
delete t.stuckSince
|
|
2272
|
+
ah(t, t.status, t.status, String(rec.id), 'assistant 帧恢复(静默不足 600s),清除疑似卡顿标记')
|
|
2273
|
+
any = true
|
|
2274
|
+
}
|
|
2275
|
+
return
|
|
2276
|
+
}
|
|
2277
|
+
// 零帧 sweep:基线取最近帧,未见过本会话帧则取 startedAt;仅基线缺失时写,安静轮不刷盘。
|
|
2278
|
+
// E2E 可 patchBoard 倒推 frameSilenceSince(虚拟时钟推不动 Date.now),与旧日志基线同款可测性。
|
|
2279
|
+
if (!frameSince) {
|
|
2280
|
+
frameSince = lastFrameAt || Number(rec.startedAt || nowFrame)
|
|
2281
|
+
row.frameSilenceSince = frameSince
|
|
2282
|
+
any = true
|
|
2283
|
+
}
|
|
2284
|
+
var frameSilentMs = nowFrame - frameSince
|
|
2285
|
+
if (frameSilentMs >= HUNG_FRAME_KILL_MS) {
|
|
2286
|
+
hung.push(rec)
|
|
2287
|
+
hungWhy[rid] = HUNG_KILL_PREFIX + '帧静默 ' + Math.round(HUNG_FRAME_KILL_MS / 1000) + 's,判挂死按模型面故障结算重派'
|
|
2288
|
+
return
|
|
2289
|
+
}
|
|
2290
|
+
if (frameSilentMs >= HUNG_FRAME_SUSPECT_MS && !t.stuckSince) {
|
|
2291
|
+
t.stuckSince = nowFrame
|
|
2292
|
+
ah(t, t.status, t.status, String(rec.id), '🟡 疑似卡顿:assistant 帧静默 ' + Math.round(HUNG_FRAME_SUSPECT_MS / 1000) + 's(仅标卡,不终止)')
|
|
2293
|
+
any = true
|
|
2294
|
+
}
|
|
2295
|
+
return
|
|
2296
|
+
}
|
|
2297
|
+
var size = sizes[rid]
|
|
2298
|
+
var prev = typeof row.lastLogBytes === 'number' ? row.lastLogBytes : null
|
|
2299
|
+
if (prev === null) { row.lastLogBytes = size; row.zeroGrowthSince = Date.now(); any = true; return } // ③ 首见只记基线
|
|
2300
|
+
if (size > prev) { // ④ 活体增长:清零计数 + 刷新基线(零增长窗口重新起算)
|
|
2301
|
+
row.lastLogBytes = size; row.zeroGrowthSince = Date.now()
|
|
2302
|
+
if (row.zeroGrowthRounds) delete row.zeroGrowthRounds
|
|
2303
|
+
any = true; return
|
|
2304
|
+
}
|
|
2305
|
+
row.zeroGrowthRounds = (row.zeroGrowthRounds || 0) + 1
|
|
2306
|
+
any = true
|
|
2307
|
+
var since = typeof row.zeroGrowthSince === 'number' ? row.zeroGrowthSince : 0
|
|
2308
|
+
// 墙钟闸(task-mvrt9tn5 定稿):固定 600s 地板——延迟与长生成静默时长无关(三连误杀实证),不再读健康表缩放
|
|
2309
|
+
if (row.zeroGrowthRounds >= HUNG_ZERO_GROWTH_ROUNDS && since && Date.now() - since >= HUNG_WALL_FLOOR_MS) hung.push(rec)
|
|
2310
|
+
} catch (e) { console.error('[task-board] 挂死检测判定失败 (run ' + (rec && rec.id) + '):', String(e)) }
|
|
2311
|
+
})
|
|
2312
|
+
return any ? d : null // 无改动返回 null → 不写盘(安静板纪律;有活跃 run 时主锁本就每轮写池快照,增量零额外写)
|
|
2313
|
+
}, true) // skipKick:本轮已是 poolCycle;判死结算自会 kick(settleWorker 尾部)
|
|
2314
|
+
// ⑥ 判死结算在锁外:失败臂(记「挂死零产出」)+ 熔断连击 + 卡重派。errText 避开冷却嗅探关键词,
|
|
2315
|
+
// 挂死只进连击不进冷却(settleWorker 失败臂 sniffModelCooldown 对该文本零命中)。
|
|
2316
|
+
var killed = 0
|
|
2317
|
+
for (var hi = 0; hi < hung.length; hi++) {
|
|
2318
|
+
var hrec = hung[hi]
|
|
2319
|
+
try {
|
|
2320
|
+
if (hrec.settled || runsFor(sid)[hrec.taskId] !== hrec) continue // 结算通道已抢先,不重复判
|
|
2321
|
+
killed++
|
|
2322
|
+
// 判死文案带出实际生效阈值(task-mvrt9tn5):固定值直述「阈值 600s」——不引用健康表延迟,
|
|
2323
|
+
// 也不再有「3×0s」假精确 / 「地板值,无实测延迟」分态(缩放已删,阈值对任何模型同值)。
|
|
2324
|
+
var wallTxt = Math.round(HUNG_WALL_FLOOR_MS / 1000) + 's'
|
|
2325
|
+
// 帧路径只换诊断文案;settleRun 失败臂、HUNG_KILL_PREFIX、熔断与重派链全部复用。
|
|
2326
|
+
var why = hungWhy[String(hrec.id)] || (HUNG_KILL_PREFIX + '连续 ' + HUNG_ZERO_GROWTH_ROUNDS + ' 轮会话日志零增长(零产出),超挂死墙钟阈值 ' + wallTxt + ',判挂死按模型面故障结算重派')
|
|
2327
|
+
console.log('[task-board] 挂死零产出判死(' + sid + '):task ' + hrec.taskId + ' run ' + hrec.id + ' ' + why)
|
|
2328
|
+
// err 传裸字符串(settleRun 只做 !!err / String(err)):new Error 会被 String(err) 包上
|
|
2329
|
+
// 'Error: ' 前缀,isModelKillFailure 的前缀锚定(index 0)失效 → 熔断连击漏喂。
|
|
2330
|
+
await settleRun(sid, hrec, null, why)
|
|
2331
|
+
} catch (e) { console.error('[task-board] 挂死判死结算失败 (task ' + hrec.taskId + '):', String(e)) }
|
|
2332
|
+
}
|
|
2333
|
+
return killed
|
|
2334
|
+
}
|
|
2335
|
+
|
|
1909
2336
|
// ===== 幽灵占位回收(池冻结根修,task-muwlepg6)=====
|
|
1910
2337
|
// 病根(活体实证):活跃表项(rec)的**唯一**摘除点是各结算通道(事件通道 settleRun /
|
|
1911
2338
|
// 上报通道 settleReportedRun / 手动终止)。任何一条漏走(worker 经 board_report 完成但当时
|
|
@@ -2037,6 +2464,13 @@ export function createDispatch(ctx, state, deps) {
|
|
|
2037
2464
|
// rpc get-tasks 读到即在「架构健康」区亮一条 info 并消费——幽灵回收从此在 GUI 可见。
|
|
2038
2465
|
poolHealthFor(sid).reapNote = { n: reapedN, at: Date.now() }
|
|
2039
2466
|
}
|
|
2467
|
+
// 挂死零产出检测(task-mvqcoz9l):位置在幽灵回收之后、活跃度计数与派发决策之前——
|
|
2468
|
+
// 判死结算把 rec 摘出活跃表(activeW 当轮即干净、空出的并发位本轮即可被 pickDispatch 复用),
|
|
2469
|
+
// 卡面由失败臂翻回 pending,同一轮即可重派;判死结算会写盘(关账/落卡),有判死时重读快照——
|
|
2470
|
+
// 否则下方空闲快进的 stalePool/staleInfo 判定会拿旧快照误判(与幽灵回收同款纪律)。
|
|
2471
|
+
var hungN = 0
|
|
2472
|
+
try { hungN = await hungZeroOutputSweep(sid, runs, snap) } catch (e) { console.error('[task-board] 挂死零产出检测失败(本轮跳过):', String(e)) }
|
|
2473
|
+
if (hungN > 0) { try { snap = await rt(sid) } catch (_) {} }
|
|
2040
2474
|
var activeW = 0, activeV = 0
|
|
2041
2475
|
// 角色口径:worker 计入 activeW(占 Worker 并发位);verifier 与 hook run(hook-pre/hook-post)
|
|
2042
2476
|
// 统一计入 activeV——hook run 不是 Worker,不该挤占 maxWorkers 并发位,但它确实是一条在跑的 run,
|