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/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
- e.state = e.observed === 'healthy' ? 'healthy' : 'unknown'
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
- e.observed = ok ? 'healthy' : 'unknown'
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 ? '主动探测通过' : ('主动探测失败' + (opts.note ? ':' + opts.note : ''))
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
- function healthyModel(sid, modelId) {
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
- function pickHealthyFallback(sid, fbList) {
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 (healthyModel(sid, fbList[fi])) return fbList[fi]
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 极短直调(无子代理/系统提示/工具,maxTokens=1)=====
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(), ac = new AbortController(), timedOut = false, disposeTimer = null
415
+ var started = Date.now()
216
416
  var promise = (async function () {
217
417
  try {
218
- if (ctx.timer && typeof ctx.timer.timeout === 'function') disposeTimer = ctx.timer.timeout(function () { timedOut = true; ac.abort() }, 15000)
219
- else { var nativeTimer = setTimeout(function () { timedOut = true; ac.abort() }, 15000); disposeTimer = function () { clearTimeout(nativeTimer) } }
220
- var stream = llm.stream({ provider: model.slice(0, slash), model: model.slice(slash + 1), messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }], maxTokens: 1, signal: ac.signal })
221
- var finished = false, failure = ''
222
- for await (var chunk of stream) {
223
- if (!chunk || chunk.type !== 'finish') continue
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 (!finished) throw new Error('模型探测流未返回 finish')
229
- if (failure) throw new Error(failure)
230
- observeModel(sid, model, true, { probe: true, latencyMs: Date.now() - started })
231
- return true
232
- } catch (e) {
233
- var text = timedOut ? 'probe timeout 15000ms' : String(e && e.message || e).slice(0, 300)
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 ok = await probeModel(sid, models[m])
258
- if (ok === null) continue
493
+ var outcome = await probeModel(sid, models[m])
494
+ if (outcome === null) continue
259
495
  if (before) probed++
260
- if (ok === true) healthy++
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 双闸:不过 badModels 熔断、不在 modelCooldown 冷却期;偏好序非故障序——
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
- : { id: String(run.id), run: run, role: role, taskId: t.id, startedAt: Date.now(), model: modelOverride, modelSource: modelSource, size: laneSize || undefined, settled: false }
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 && !healthyModel(sid, origModel)) {
950
- return { fallback: '续跑放弃:原模型 ' + origModel + ' 熔断/冷却中(续跑健康闸)——改走健康取模链重跑' }
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
- // 通知走 pushSysNote 且**不带 taskId**:源卡停在 draft/pending,带 taskId 的系统通知会在回执冲刷时
1185
- // 被「任务仍在执行中」过滤器整行丢弃(notify.mjs deliver 段)——提案到位是必须送达的信号。
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
- if (applied && applied.ok && applied.verdict === 'split') pushSysNote(sid, '🧩 拆解提案已就位(' + applied.children.length + ' 张草稿子卡,源卡 ' + rec.taskId + '),确认后逐张 publish')
1209
- else if (applied && applied.ok && applied.verdict === 'keep') pushSysNote(sid, '🧩 拆解官判定源卡(' + rec.taskId + ')无需拆分,理由已落源卡消息')
1210
- else pushSysNote(sid, '🚨 拆解官 run 未产出可用提案(源卡 ' + rec.taskId + '):' + String((applied && applied.error) || errText || 'structured 缺失').slice(0, 200))
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,