dsh-agent-board 1.9.0 → 1.9.2
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 +13 -8
- 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 +48 -12
- package/lib/dispatch.mjs +135 -1142
- package/lib/hung.mjs +202 -0
- package/lib/model-health.mjs +553 -0
- package/lib/notify.mjs +37 -4
- package/lib/policy.mjs +19 -9
- package/lib/rpc.mjs +7 -3
- package/lib/settle.mjs +954 -0
- package/package.json +1 -1
- package/test/core.test.mjs +339 -77
- 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 +132 -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 +410 -0
- package/test/lane-models.test.mjs +134 -0
- package/test/popover-clamp.test.mjs +92 -0
- package/test/runtime-health.test.mjs +2 -1
- package/test/scorecard-data.test.mjs +2 -1
- package/test/settle-stop-interrupt.test.mjs +294 -3
- package/test/ui-zoom-client.test.mjs +9 -4
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
// dsh-agent-board — 模型健康域(lib/model-health.mjs)
|
|
2
|
+
// 拆分自 lib/dispatch.mjs(拆分第 2 刀·叶子模块,task-mvt8c1yx-2):模型冷却嗅探纯函数域
|
|
3
|
+
// (限流 task-mvneq4kk + 连接/网关故障短窗 task-mvnx8hnm)+ 统一健康表(task-mvoulhm7,按会话分桶)
|
|
4
|
+
// + 冷却期判定(cooldownLive)/ 冷却嗅探登记(sniffModelCooldown)。
|
|
5
|
+
// 拆分第 3 刀(task-mvt8c1yx-3)续入:模型目录(modelDirectory/outOfDir/traceStaleModel,与 rpc.mjs
|
|
6
|
+
// enumModels 同源的目录获取+失效留痕)/ 备用链健康判定(healthyModel/pickHealthyFallback)/
|
|
7
|
+
// 熔断连击(WORKER_MODEL_CIRCUIT_AFTER/isModelKillFailure/feedModelFailure/resetModelFailure,
|
|
8
|
+
// task-mvowb2hc 语义注释全量随迁)。
|
|
9
|
+
// 拆分第 4 刀(task-mvt8c1yx-4)续入:主动探测域全量随迁(PROBE_MAX_TOKENS/PROBE_MAX_TOKENS_RETRY/
|
|
10
|
+
// PROBE_ATTEMPT_TIMEOUT_MS/QUOTA_SIGNAL_RE/CREDENTIAL_SIGNAL_RE 常量与 task-mvrfp1rj/task-mvrqdw4k/
|
|
11
|
+
// task-mvsv8zym 校准注释、isEmptyResponse/classifyEmptyResponse 分治纯函数、probeOnce/probeModel/
|
|
12
|
+
// probeConfiguredModels/maybeProbeModels 探测编排、探测态 probeInFlight/probeAbort/probeNextAt/
|
|
13
|
+
// probeDisposed 与统一卸载入口 disposeProbe——插件卸载 effect 注册仍由 dispatch.mjs 执行,回调只调
|
|
14
|
+
// 本工厂暴露的 disposeProbe())。
|
|
15
|
+
// import 面:兄弟域唯一=挂死域(依赖向单行道 health→hung,禁反向——isModelKillFailure 消费
|
|
16
|
+
// isHungKillFailure;hung.mjs 只 import core.mjs,无环);第 4 刀起另直接 import core.mjs 取
|
|
17
|
+
// cfg/configuredModelIds(探测调度闸与配置集合数据源——core.mjs 不 import 本域,无环);会话状态/
|
|
18
|
+
// 留卡以闭包回调注入
|
|
19
|
+
// (createModelHealth 工厂:state 共享同一引用——modelHealth/modelCooldown/badModels/modelFailStreak/
|
|
20
|
+
// probeOkStreak 语义逐字不变;探测态随第 4 刀入厂——probeDisposed 成为本域唯一卸载闸读取源(原 opts.disposed 注入
|
|
21
|
+
// 同步口径随之退役),dispose 后 observeModel/probeModel 不再写表,既有卸载闸语义逐字不变;
|
|
22
|
+
// ctx/pushSysNote 随第 3 刀注入——modelDirectory 的 llm 目录获取 / feedModelFailure 的系统通知 /
|
|
23
|
+
// 探测域的 llm.stream 直调与 timer)。
|
|
24
|
+
import { isHungKillFailure } from './hung.mjs'
|
|
25
|
+
import * as core from './core.mjs'
|
|
26
|
+
|
|
27
|
+
// ===== 模型冷却嗅探:限流(task-mvneq4kk)+ 连接/网关故障短窗(task-mvnx8hnm)=====
|
|
28
|
+
// 纯函数:两类报错形态识别,返回 { until, parsed, kind },均不命中返回 null。
|
|
29
|
+
// ① 限流形态(kind='rate-limit',行为与 C5 落地逐字一致):命中 /429|RATE_LIMIT|rate.?limit|限额|额度/i;
|
|
30
|
+
// 尝试解析「YYYY-MM-DD HH:mm:ss 重置」(中文,glm 原形事故实证形态)或「reset(s) at/on YYYY-MM-DD HH:mm:ss」
|
|
31
|
+
// (英文)样式时间 → until 时间戳(按本地时区构造,与供应商报错文案口径一致);解析不到时间 →
|
|
32
|
+
// until = now + 30min(保守缺省)。
|
|
33
|
+
// ② 连接/网关形态(kind='connectivity'):命中 CONNECTIVITY_RE → until = now + 10min 固定短窗
|
|
34
|
+
// (连接故障多为瞬时,短窗自动恢复——比限流缺省 30min 短;固定不开放配置,要配置化等实证再开)。
|
|
35
|
+
// 判定顺序:先限流后连接(两类关键词并存的文本按限流处理,窗口更保守)。
|
|
36
|
+
// 英文裸词 timeout 刻意不在连接形态内(只认 ETIMEDOUT errno 形态)——供应商「provider timeout after 30s」
|
|
37
|
+
// 类软性报错与 withTimeout 的「label timeout Nms」硬超时文本都不进冷却(C5 既有断言锁定该口径)。
|
|
38
|
+
var RATE_LIMIT_RE = /429|RATE_LIMIT|rate.?limit|限额|额度/i
|
|
39
|
+
var RATE_RESET_ZH_RE = /(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2}):(\d{1,2})\s*重置/
|
|
40
|
+
var RATE_RESET_EN_RE = /resets?(?:\s+at|\s+on)?\s+(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2}):(\d{1,2})/i
|
|
41
|
+
var RATE_LIMIT_DEFAULT_MS = 30 * 60000
|
|
42
|
+
// 连接/网关故障形态识别谓词(export:dispatch.mjs 探测空响应分治 classifyEmptyResponse 同源消费——
|
|
43
|
+
// 判定顺序与 parseModelCooldown 先限流后连接的保守口径一致,单源导出防两处口径漂移)
|
|
44
|
+
export var CONNECTIVITY_RE = /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|ECONNRESET|EAI_AGAIN|fetch failed|network|socket|502|503|504|网关|连接|超时/i
|
|
45
|
+
var CONNECTIVITY_COOLDOWN_MS = 10 * 60000
|
|
46
|
+
export function parseModelCooldown(errText) {
|
|
47
|
+
var s = String(errText || '')
|
|
48
|
+
if (RATE_LIMIT_RE.test(s)) {
|
|
49
|
+
var m = s.match(RATE_RESET_ZH_RE) || s.match(RATE_RESET_EN_RE)
|
|
50
|
+
if (m) {
|
|
51
|
+
var ts = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])).getTime()
|
|
52
|
+
if (isFinite(ts)) return { until: ts, parsed: true, kind: 'rate-limit' }
|
|
53
|
+
}
|
|
54
|
+
return { until: Date.now() + RATE_LIMIT_DEFAULT_MS, parsed: false, kind: 'rate-limit' }
|
|
55
|
+
}
|
|
56
|
+
if (CONNECTIVITY_RE.test(s)) return { until: Date.now() + CONNECTIVITY_COOLDOWN_MS, parsed: false, kind: 'connectivity' }
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
// 冷却消息「至 HH:mm」格式化(本地时区);dispatch.mjs 降档/切备用留痕同源消费(单源导出,防两处格式漂移)
|
|
60
|
+
export function hhmmOf(ts) { var d = new Date(ts); return ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2) }
|
|
61
|
+
// 模型熔断/冷却表键(sid|model 复合串):badModels 熔断名单与 syncModelHealth 熔断判定共用同一键式
|
|
62
|
+
// (单源导出——dispatch.mjs 熔断喂给/取模链/验收熔断同源消费,防两处键式漂移)
|
|
63
|
+
export function modelKey(sid, model) { return sid + '|' + model }
|
|
64
|
+
|
|
65
|
+
// ===== 主动模型嗅探:llm.stream 极短直调(无子代理/系统提示/工具,首探 maxTokens=1)=====
|
|
66
|
+
// finish=stop/max-tokens 等均证明模型链路可用;error/aborted/无 finish 才算失败。失败文本复用既有冷却登记,
|
|
67
|
+
// 但不改 badModels 熔断判定。同模型全局只在飞一次,15s 硬超时带 AbortSignal,绝不阻塞 poolCycle 派发。
|
|
68
|
+
// ===== 空响应重试(task-mvrfp1rj)+ 错误码分治(task-mvrqdw4k 用户拍板):探测只裁存活性 =====
|
|
69
|
+
// 实证(cmss/zhanlu 系 glm-5.2、deepseek-v4.1-flash):maxTokens=1 的极简请求被供应商以「terminal stop
|
|
70
|
+
// 零内容块」退回,宿主 adapter 归成 EMPTY_RESPONSE 错误(逐字文案,宿主 checkout grep 实测):
|
|
71
|
+
// ① dsh-llm-pi-ai mapStopReason:model "<m>" returned a completed response with no content
|
|
72
|
+
// ② dsh-llm-deepseek :DeepSeek Messages returned no content
|
|
73
|
+
// 可用性实证(task-mvrqdw4k 三重,决定性):v4.1-flash 正产出当轮对话、两次「失败」皆我方固定 60s 挂死闸
|
|
74
|
+
// 误杀(已修为自适应)——它是慢不是死,纯空响应=供应商怪癖,不该判死。
|
|
75
|
+
// 校准口径(用户拍板·错误码分治矩阵):
|
|
76
|
+
// ① 首探 maxTokens=1(最省 token;一次通即证明链路可用);
|
|
77
|
+
// ② 首探「空响应」→ 自动以 maxTokens=8 重试一次(给模型吐 token 的余地,消掉 length 截断型空响应大头);
|
|
78
|
+
// ③ 重试有内容 → 🟢 探测通过(存活 = 可调用);
|
|
79
|
+
// ④ 重试仍空 → 按文本/码信号分治(classifyEmptyResponse):纯空响应=🟡 degraded 存活(链路通,供应商
|
|
80
|
+
// 怪癖)——observeModel 记降级不记失败、不喂冷却嗅探、路由仍可选用;额度/凭据/连接信号=真错,维持
|
|
81
|
+
// 失败臂照旧 observeModel 失败 + 喂冷却嗅探(quota 文本优先级高于空响应判定,⑬b/⑬d 契约保留);
|
|
82
|
+
// ⑤ 真错误/aborted/超时/无 finish 语义逐字不变,且不触发重试(一次定性,省一次请求也省一次误判)。
|
|
83
|
+
// 放宽不放跑真坏模型(闭环论证):额度真耗尽有两条独立通道——①宿主直接抛 QUOTA_EXCEEDED(探测即降级);
|
|
84
|
+
// ②若网关伪装成空响应,真派发 run 会失败 → recentRuns 软闸(≥2 全败)跳过它——探测管可调性、行为闸管质量。
|
|
85
|
+
// 边界:只动探测判定/健康状态派生/UI 映射与断言;探测调度节奏(probeNextAt/probeIntervalMin)与熔断联动不动。
|
|
86
|
+
var PROBE_MAX_TOKENS = 1 // 首探极简:1 token 证链路通
|
|
87
|
+
var PROBE_MAX_TOKENS_RETRY = 8 // 空响应重试:给模型吐 token 的余地
|
|
88
|
+
var PROBE_ATTEMPT_TIMEOUT_MS = 20000 // 每次尝试独立超时(task-mvsv8zym:两次尝试各拿满 20s,非共享递减——
|
|
89
|
+
// 旧 15s 共享预算下慢模型首探耗 ~10s 后重试只剩残余必超时,被我方预算饿死误记成模型失败)。
|
|
90
|
+
// 总上限 40s 不违反「绝不阻塞 poolCycle」:探测经 maybeProbeModels 后台异步跑(不 await 进派发主链)。
|
|
91
|
+
// EMPTY_RESPONSE 识别(双路):结构化 code 优先(宿主 finish.failure.code,常量见 dsh-llm/lib/types/error.js),
|
|
92
|
+
// 文本特征兜底(兼容只给 message 的旧宿主/网关包装文本)。只认「completed 但零内容」;
|
|
93
|
+
// 429/连接/超时等真错误文本一律 false,走既有失败臂(冷却登记口径不变)。
|
|
94
|
+
function isEmptyResponse(code, text) {
|
|
95
|
+
if (String(code || '') === 'EMPTY_RESPONSE') return true
|
|
96
|
+
return /no content/i.test(String(text || ''))
|
|
97
|
+
}
|
|
98
|
+
// 错误码分治信号(task-mvrqdw4k):宿主把额度/凭据问题单独编码(QUOTA_EXCEEDED_CODE/INVALID_CREDENTIAL_CODE
|
|
99
|
+
// 与 EMPTY_RESPONSE_CODE 并列导出),网关包装文本同样带字样——空响应文本/码命中额度/凭据/连接信号 → 真错,
|
|
100
|
+
// 维持失败臂(冷却嗅探接管);纯空响应(无任何真错信号)→ 🟡degraded 存活(供应商怪癖:链路通但极简请求零内容)。
|
|
101
|
+
var QUOTA_SIGNAL_RE = /429|RATE_LIMIT|QUOTA_EXCEEDED|rate.?limit|限额|额度|quota/i
|
|
102
|
+
var CREDENTIAL_SIGNAL_RE = /INVALID_CREDENTIAL|\b401\b|invalid[ _-]?api[ _-]?key|unauthorized|凭据|凭证/i
|
|
103
|
+
// 空响应分治:返回 'degraded'(纯空响应=链路存活)| 'fail'(额度/凭据/连接信号=真错,走失败臂)。
|
|
104
|
+
// 判定顺序:额度 > 凭据 > 连接 > degraded(与 parseModelCooldown 先限流后连接的保守口径一致)。
|
|
105
|
+
function classifyEmptyResponse(code, text) {
|
|
106
|
+
var s = String(text || ''), c = String(code || '')
|
|
107
|
+
if (QUOTA_SIGNAL_RE.test(s) || /QUOTA/i.test(c)) return 'fail'
|
|
108
|
+
if (CREDENTIAL_SIGNAL_RE.test(s) || /CREDENTIAL/i.test(c)) return 'fail'
|
|
109
|
+
if (CONNECTIVITY_RE.test(s)) return 'fail'
|
|
110
|
+
return 'degraded'
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ===== 健康表 + 冷却登记(工厂:state 共享同一引用)=====
|
|
114
|
+
export function createModelHealth(opts) {
|
|
115
|
+
var state = opts.state, mutateLocked = opts.mutateLocked
|
|
116
|
+
var badModels = state.badModels
|
|
117
|
+
// 冷却表(task-mvneq4kk 限流 / task-mvnx8hnm 连接故障):与 badModels 同生命周期(纯内存不持久化,
|
|
118
|
+
// 重启即清——额度重置窗口本就短,连接短窗 10min 更短)。语义分治:badModels=连续失败熔断(布尔),
|
|
119
|
+
// modelCooldown=窗口冷却(until 时间戳),两张表各管各的(连接类冷却只进本表,不动熔断判定)。
|
|
120
|
+
// key = 模型 id(provider/model 复合串):限流是账户级额度窗口、连接故障是网关面,都不按会话分键。
|
|
121
|
+
// 缺字段就地补(测试桩/老宿主兼容,与 poolHealthFor 同例)。
|
|
122
|
+
var modelCooldown = state.modelCooldown || (state.modelCooldown = {})
|
|
123
|
+
// 统一健康表(task-mvoulhm7):内部按会话分桶,避免 sid|model 熔断信号串板;get-tasks 公开为 {modelId:entry}。
|
|
124
|
+
// observed=最近主动探测/run 的基础状态,冷却与熔断是覆盖态;调度仍只认这两种覆盖态,未知不等于不可用。
|
|
125
|
+
var modelHealth = state.modelHealth || (state.modelHealth = {})
|
|
126
|
+
// 探测态(拆分第 4 刀随迁,原 dispatch.mjs 同名段):probeInFlight=同模型在飞探测 promise 表
|
|
127
|
+
// (同模型全局只在飞一次);probeAbort=在飞尝试 AbortController 表(dispose 时统一 abort);
|
|
128
|
+
// probeNextAt=15s 心跳间隔闸表(sid → { at, plan },配置变化立即重排——探测节奏唯一闸);
|
|
129
|
+
// probeDisposed=插件卸载闸。拆分第 2 刀时期由 dispatch.mjs 持有并经 opts.disposed 注入同步;第 4 刀
|
|
130
|
+
// 随探测域入厂后成为本域内部状态,卸载入口收敛为下方 disposeProbe()(dispatch.mjs 的卸载 effect 回调
|
|
131
|
+
// 只调它)——dispose 后 observeModel/probeModel 不再写表,既有卸载闸语义逐字不变。
|
|
132
|
+
var probeInFlight = {}, probeAbort = {}, probeNextAt = {}, probeDisposed = false
|
|
133
|
+
var disposed = function () { return probeDisposed }
|
|
134
|
+
// llm 服务句柄(可选注入,拆分第 3 刀):modelDirectory 的目录获取数据源——dispatch.mjs
|
|
135
|
+
// createDispatch 传入 ctx;薄桩/老调用不注入 → modelDirectory 保守返回 null(目录闸整体跳过,不误伤)。
|
|
136
|
+
var ctx = opts.ctx
|
|
137
|
+
// 系统通知(可选注入,拆分第 3 刀):feedModelFailure 三连拉黑臂的 pushSysNote——dispatch.mjs 传入;
|
|
138
|
+
// 与验收熔断通知同口径(不带 taskId,不受任务回执冲刷丢弃)。
|
|
139
|
+
var pushSysNote = opts.pushSysNote
|
|
140
|
+
// 熔断连击表(task-mvowb2hc 洞①):sid|model → 连续「模型面失败」次数(纯内存不持久化,与
|
|
141
|
+
// badModels/modelCooldown 同生命周期,重启即清)。语义:只有「会话被宿主/激活机制在开工前杀掉」
|
|
142
|
+
// 这类模型面失败才计数(worker 自己的失误——空输出/超时等——不算),连续 N 次才拉黑进 badModels
|
|
143
|
+
// (熔断语义不变:拉黑后链跳过 + 健康表 circuit);任一可归因 run 正常完成即归零(settleRunRecord)。
|
|
144
|
+
var modelFailStreak = state.modelFailStreak || (state.modelFailStreak = {})
|
|
145
|
+
// 探测恢复连击表(task-mvvw71nc 探测恢复熔断解除):sid|model → 连续「主动探测通过」次数(纯内存不持久化,
|
|
146
|
+
// 与 modelFailStreak 同生命周期,重启即清)。语义:探测能证「可调用」却从不解除「不可调」判定,与错误码
|
|
147
|
+
// 分治(探测管可调性)的哲学不对称(用户 2026-10-31 实测:熔断后红态再也翻不绿)——探测成功且该模型在
|
|
148
|
+
// badModels 中才 +1,连续 PROBE_OK_RECOVER_AFTER 次(探测间隔 15min 双周期确认)才解除熔断;单次成功不
|
|
149
|
+
// 复位(过滤偶发响应:能回 1 token 但跑长任务即崩的病模型不被放跑),任一非 ok 探测结果(失败/超时/
|
|
150
|
+
// degraded)该模型计数清零(重新计,偶发成功不叠加成复位)。
|
|
151
|
+
var probeOkStreak = state.probeOkStreak || (state.probeOkStreak = {})
|
|
152
|
+
function healthBucket(sid) { return modelHealth[sid] || (modelHealth[sid] = {}) }
|
|
153
|
+
function healthEntry(sid, model) {
|
|
154
|
+
var b = healthBucket(sid)
|
|
155
|
+
return b[model] || (b[model] = { state: 'unknown', observed: 'unknown', lastProbe: null, lastSeen: null, note: '尚无健康信号', observedNote: '尚无健康信号', recentRuns: [] })
|
|
156
|
+
}
|
|
157
|
+
function syncModelHealth(sid, model, cooldownOverride) {
|
|
158
|
+
var e = healthEntry(sid, model), until = typeof cooldownOverride === 'number' ? cooldownOverride : cooldownLive(model)
|
|
159
|
+
// task-mvrqdw4k:degraded 派生(状态优先级 circuit > cooldown > degraded > healthy/unknown——覆盖臂在下方,顺序即优先级)
|
|
160
|
+
e.state = e.observed === 'healthy' ? 'healthy' : (e.observed === 'degraded' ? 'degraded' : 'unknown')
|
|
161
|
+
e.note = e.observedNote || '尚无健康信号'
|
|
162
|
+
delete e.until
|
|
163
|
+
if (until) { e.state = 'cooldown'; e.until = until; e.note = e.cooldownNote || '模型冷却中' }
|
|
164
|
+
if (badModels[modelKey(sid, model)]) { e.state = 'circuit'; e.note = e.circuitNote || '连续失败,模型熔断中' }
|
|
165
|
+
return e
|
|
166
|
+
}
|
|
167
|
+
function observeModel(sid, model, ok, opts) {
|
|
168
|
+
if (!model || disposed()) return null
|
|
169
|
+
opts = opts || {}
|
|
170
|
+
var e = healthEntry(sid, model), at = new Date().toISOString()
|
|
171
|
+
var latency = Number(opts.latencyMs)
|
|
172
|
+
e.lastSeen = at
|
|
173
|
+
// ③ 探测超时/预算耗尽只更新 note(判定保留,task-mvsv8zym):observed/state/latencyMs/recentRuns
|
|
174
|
+
// 全部保持原值——避免已 healthy/degraded 条目被超时倒退成 unknown(超时非模型健康信号)。
|
|
175
|
+
if (opts.preserveObserved) {
|
|
176
|
+
if (opts.probe) {
|
|
177
|
+
e.lastProbe = at
|
|
178
|
+
e.observedNote = '主动探测超时(慢模型,判定保留)' + (opts.note ? ':' + opts.note : '')
|
|
179
|
+
e.note = e.observedNote
|
|
180
|
+
}
|
|
181
|
+
return syncModelHealth(sid, model)
|
|
182
|
+
}
|
|
183
|
+
// task-mvrqdw4k:探测空响应分治——ok=true 🟢健康;opts.degraded 🟡降级(链路存活,供应商怪癖,
|
|
184
|
+
// 只由探测降级存活臂置入);其余照旧 unknown(失败臂)。被动 run 观察臂语义逐字不变。
|
|
185
|
+
e.observed = ok ? 'healthy' : (opts.degraded ? 'degraded' : 'unknown')
|
|
186
|
+
if (isFinite(latency) && latency >= 0) e.latencyMs = Math.round(latency)
|
|
187
|
+
if (opts.probe) {
|
|
188
|
+
e.lastProbe = at
|
|
189
|
+
e.observedNote = ok ? '主动探测通过'
|
|
190
|
+
: (opts.degraded
|
|
191
|
+
? '主动探测:链路存活但极简请求零内容(EMPTY_RESPONSE 供应商怪癖)' + (opts.note ? ':' + opts.note : '')
|
|
192
|
+
: '主动探测失败' + (opts.note ? ':' + opts.note : ''))
|
|
193
|
+
} else {
|
|
194
|
+
e.recentRuns.push({ ok: !!ok, at: at, latencyMs: isFinite(latency) && latency >= 0 ? Math.round(latency) : null })
|
|
195
|
+
if (e.recentRuns.length > 5) e.recentRuns = e.recentRuns.slice(-5)
|
|
196
|
+
var passed = e.recentRuns.filter(function (r) { return r.ok }).length
|
|
197
|
+
e.observedNote = (ok ? '最近 run 成功' : '最近 run 失败') + '(近 ' + e.recentRuns.length + ' 次 ' + passed + ' 次成功)'
|
|
198
|
+
}
|
|
199
|
+
e.note = e.observedNote
|
|
200
|
+
return syncModelHealth(sid, model)
|
|
201
|
+
}
|
|
202
|
+
function markModelCircuit(sid, model, note) {
|
|
203
|
+
var e = healthEntry(sid, model)
|
|
204
|
+
e.circuitNote = note || '连续验收失败,模型熔断中'
|
|
205
|
+
return syncModelHealth(sid, model)
|
|
206
|
+
}
|
|
207
|
+
// 冷却期判定:返回冷却截止 ts(0=不在冷却);过期项惰性清除(读路径顺手删,无需 sweeper)
|
|
208
|
+
function cooldownLive(model) {
|
|
209
|
+
var until = modelCooldown[model]
|
|
210
|
+
if (!until) return 0
|
|
211
|
+
if (Date.now() >= until) { delete modelCooldown[model]; return 0 }
|
|
212
|
+
return until
|
|
213
|
+
}
|
|
214
|
+
// 冷却嗅探登记:两条登记路径共用(①spawn extras 回退臂 e/e2 文本 ②settleWorker 失败臂 errText——
|
|
215
|
+
// activation.result 拆除兜底臂带上来的诊断文本也走②)。max 收敛:多次报错只延不缩
|
|
216
|
+
// (带重置时间的精确值与 30min/10min 缺省并存时取更晚者);截止未后延则零改动零留痕(防重复报错刷屏)。
|
|
217
|
+
// 留痕分态(task-mvnx8hnm):限流=「额度冷却至 HH:mm」,连接/网关=「连接故障短冷却 10 分钟」——
|
|
218
|
+
// 卡 messages 各记一条 kind='cooldown',排查时一眼分清「额度没了」与「连不上」。
|
|
219
|
+
// 写卡失败静默吞掉(留痕是附属动作,绝不阻断 spawn 回退/失败结算主流程)。返回冷却截止 ts,未命中 null。
|
|
220
|
+
async function sniffModelCooldown(sid, taskId, model, errText) {
|
|
221
|
+
if (!model) return null
|
|
222
|
+
var hit = parseModelCooldown(errText)
|
|
223
|
+
if (!hit) return null
|
|
224
|
+
if (hit.until <= (modelCooldown[model] || 0)) { syncModelHealth(sid, model, modelCooldown[model]); return modelCooldown[model] }
|
|
225
|
+
modelCooldown[model] = hit.until
|
|
226
|
+
var until = modelCooldown[model]
|
|
227
|
+
var he = healthEntry(sid, model)
|
|
228
|
+
he.cooldownNote = hit.kind === 'connectivity' ? ('连接/网关故障,冷却至 ' + hhmmOf(until)) : ('额度限流,冷却至 ' + hhmmOf(until))
|
|
229
|
+
syncModelHealth(sid, model, until)
|
|
230
|
+
if (hit.kind === 'connectivity') console.error('[task-board] 模型 ' + model + ' 连接/网关故障,短冷却 10 分钟至 ' + new Date(until).toISOString())
|
|
231
|
+
else console.error('[task-board] 模型 ' + model + ' 命中 RATE_LIMIT 限流,冷却至 ' + new Date(until).toISOString() + '(' + (hit.parsed ? '重置时间解析' : '缺省 30min') + ')')
|
|
232
|
+
try {
|
|
233
|
+
await mutateLocked(sid, function (d) {
|
|
234
|
+
var t = d.tasks.find(function (x) { return x.id === taskId })
|
|
235
|
+
if (!t) return null
|
|
236
|
+
if (!Array.isArray(t.messages)) t.messages = []
|
|
237
|
+
t.messages.push({ kind: 'cooldown', text: hit.kind === 'connectivity' ? ('🔌 模型 ' + model + ' 连接故障短冷却 10 分钟(至 ' + hhmmOf(until) + ')') : ('⏳ 模型 ' + model + ' 额度冷却至 ' + hhmmOf(until)), at: new Date().toISOString(), by: 'system' })
|
|
238
|
+
return { ok: true }
|
|
239
|
+
}, true) // skipKick:只是留痕,不踢 poolCycle
|
|
240
|
+
} catch (_) {}
|
|
241
|
+
return until
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ===== 模型目录获取(task-mvd3li6 目录失效闸数据源):与 rpc.mjs enumModels 同源(llm.listProviders+listModels)=====
|
|
245
|
+
// 返回 { "provider/model": true } 目录映射;null = 目录不可得 → 目录闸整体跳过(保守语义③,不误伤):
|
|
246
|
+
// ① llm 服务缺失/缺 listProviders/listModels 方法(老宿主);② listProviders 未返回数组或抛错;
|
|
247
|
+
// ③ 任一 provider 的 listModels 抛错或未返回数组(部分枚举=目录不完整,宁可跳过闸也不误杀在册模型);
|
|
248
|
+
// ④ 枚举结果为空(退化形态:网关零模型时全候选都会被误判目录外,同样保守跳过)。
|
|
249
|
+
// 每次 spawnOneShot/续跑闸现取一次(刻意不缓存:模型从宿主配置删除/改名要当轮生效,缓存会放走已删模型)。
|
|
250
|
+
async function modelDirectory() {
|
|
251
|
+
var llm = ctx && ctx.get && ctx.get('llm')
|
|
252
|
+
if (!llm || typeof llm.listProviders !== 'function' || typeof llm.listModels !== 'function') return null
|
|
253
|
+
var providers = null
|
|
254
|
+
try { providers = llm.listProviders() } catch (_) { return null }
|
|
255
|
+
if (!Array.isArray(providers)) return null
|
|
256
|
+
var out = {}
|
|
257
|
+
for (var i = 0; i < providers.length; i++) {
|
|
258
|
+
try {
|
|
259
|
+
var models = await llm.listModels(providers[i].id)
|
|
260
|
+
if (!Array.isArray(models)) return null
|
|
261
|
+
for (var j = 0; j < models.length; j++) { if (models[j] && models[j].id) out[providers[i].id + '/' + models[j].id] = true }
|
|
262
|
+
} catch (_) { return null } // 任一 provider 枚举失败 → 目录不完整 → 保守跳过(不误伤)
|
|
263
|
+
}
|
|
264
|
+
if (!Object.keys(out).length) return null
|
|
265
|
+
return out
|
|
266
|
+
}
|
|
267
|
+
// 存在闸谓词(同步;dir=modelDirectory() 结果):目录可得且模型不在目录 → true(配置已失效)。
|
|
268
|
+
// dir 为 null(目录不可得)恒 false——闸整体跳过,只留连败软闸生效。
|
|
269
|
+
function outOfDir(modelId, dir) { return !!(dir && modelId && !dir[modelId]) }
|
|
270
|
+
// ===== 目录失效留痕(task-mvd3li6):取模候选不在当前模型目录 → 卡 messages 记「配置已失效」,per 模型去重 =====
|
|
271
|
+
// 同模型重复取模只记一条(防每轮重派刷屏,与冷却嗅探「未后延零留痕」同纪律);
|
|
272
|
+
// 顺带在健康表 observedNote 落同款分态(GUI 健康点可读出「为什么被跳过」)。写卡失败静默吞掉
|
|
273
|
+
// (留痕是附属动作,绝不阻断取模主流程)。
|
|
274
|
+
async function traceStaleModel(sid, taskId, model) {
|
|
275
|
+
if (!model) return
|
|
276
|
+
console.error('[task-board] 配置已失效:' + model + '(已从模型目录移除),取模跳过该候选')
|
|
277
|
+
try {
|
|
278
|
+
var he = healthEntry(sid, model)
|
|
279
|
+
if (he.observedNote !== '配置已失效(已从模型目录移除)') { he.observedNote = '配置已失效(已从模型目录移除)'; he.note = he.observedNote }
|
|
280
|
+
} catch (_) {}
|
|
281
|
+
try {
|
|
282
|
+
await mutateLocked(sid, function (d) {
|
|
283
|
+
var t2 = d.tasks.find(function (x) { return x.id === taskId })
|
|
284
|
+
if (!t2) return null
|
|
285
|
+
if (!Array.isArray(t2.messages)) t2.messages = []
|
|
286
|
+
var text = '⚠️ 配置已失效:' + model + '(已从模型目录移除),本 run 取模跳过'
|
|
287
|
+
for (var i = t2.messages.length - 1; i >= 0; i--) {
|
|
288
|
+
if (t2.messages[i] && t2.messages[i].kind === 'stale-model' && t2.messages[i].text === text) return null // 已记过,零改动零留痕
|
|
289
|
+
}
|
|
290
|
+
t2.messages.push({ kind: 'stale-model', text: text, at: new Date().toISOString(), by: 'system' })
|
|
291
|
+
return { ok: true }
|
|
292
|
+
}, true) // skipKick:只是留痕,不踢 poolCycle
|
|
293
|
+
} catch (_) {}
|
|
294
|
+
}
|
|
295
|
+
// ===== 备用模型链健康判定(task-mvnqxlpn):不在 badModels 熔断名单且不在 modelCooldown 冷却期 =====
|
|
296
|
+
// 两张表各管各的(熔断=连续失败布尔,冷却=额度窗口时间戳),任一命中即不健康。
|
|
297
|
+
// 消费点=spawnOneShot 取模链:候选被判不健康时先扫板级 fallbackModels 找首个健康者(链本身逐项过本检查)。
|
|
298
|
+
// ===== 健康面拓宽(task-mvd3li6):在既有熔断/冷却双闸之上增读 modelHealth 聚合表 =====
|
|
299
|
+
// ① 近期连败软闸:聚合表 recentRuns(近 5 次 run 成败,settleRunRecord 每次结算喂给)≥2 且全败 →
|
|
300
|
+
// 不健康(软熔断:不落 badModels 黑名单,仅取模/续跑跳过)。一次成功即复位——recentRuns 滚动
|
|
301
|
+
// 窗口本就有归零语义(成功条目进窗「全败」即不成立),确认复用零新增簿记。原形:glm-5.2
|
|
302
|
+
// 「78s 报错一次+挂死一次」病了还被优先调度/续跑——第二次派发即被本闸拦下改走健康取模链。
|
|
303
|
+
// ② 目录失效闸:dir 可得且模型不在目录(已从宿主配置删除/改名)→ 视为不健康跳过。
|
|
304
|
+
// dir 为可选第三参:既有调用点(不带 dir)= 目录闸跳过,行为逐字不变(零回归)。
|
|
305
|
+
function healthyModel(sid, modelId, dir) {
|
|
306
|
+
if (!modelId) return false
|
|
307
|
+
var h = syncModelHealth(sid, modelId)
|
|
308
|
+
if (h.state === 'circuit') return false
|
|
309
|
+
if (h.state === 'cooldown') return false
|
|
310
|
+
// task-mvrqdw4k:degraded(🟡空响应降级存活)刻意不拦——探测管可调性,模型仍可被路由选用;
|
|
311
|
+
// 行为质量另有 recentRuns 连败软闸兜底(下方),探测放宽不放跑真坏模型。
|
|
312
|
+
if (outOfDir(modelId, dir)) return false
|
|
313
|
+
var rr = h.recentRuns
|
|
314
|
+
if (Array.isArray(rr) && rr.length >= 2) {
|
|
315
|
+
var allBad = true
|
|
316
|
+
for (var i = 0; i < rr.length; i++) { if (rr[i] && rr[i].ok) { allBad = false; break } }
|
|
317
|
+
if (allBad) return false
|
|
318
|
+
}
|
|
319
|
+
return true
|
|
320
|
+
}
|
|
321
|
+
// 扫备用链找首个健康模型(保持链序=主窗口配置的能力水位优先级);链尽/空链返回 ''。
|
|
322
|
+
// task-mvd3li6 起 async + dir + taskId:链上目录外模型跳过并留痕(traceStaleModel,per 模型去重);
|
|
323
|
+
// 目录不可得(dir 缺省/null)零留痕,行为与旧签名逐字一致(既有调用点零回归)。
|
|
324
|
+
async function pickHealthyFallback(sid, fbList, dir, taskId) {
|
|
325
|
+
if (!Array.isArray(fbList)) return ''
|
|
326
|
+
for (var fi = 0; fi < fbList.length; fi++) {
|
|
327
|
+
if (outOfDir(fbList[fi], dir)) { await traceStaleModel(sid, taskId, fbList[fi]); continue }
|
|
328
|
+
if (healthyModel(sid, fbList[fi], dir)) return fbList[fi]
|
|
329
|
+
}
|
|
330
|
+
return ''
|
|
331
|
+
}
|
|
332
|
+
// ===== 熔断连击喂给(task-mvowb2hc 洞①):模型面失败判别 + 连击计数 + 三连拉黑 =====
|
|
333
|
+
// 模型面失败 = 会话被宿主/激活机制在开工前杀掉,两条来源文本都在本文件内确定:
|
|
334
|
+
// ① activation.result 拆除兜底臂:「子会话被宿主拆除(stopReason: X,…)」(stopReason=completed
|
|
335
|
+
// 的收尾性拆除不算——那是正常完成的副产物,不是模型失败);
|
|
336
|
+
// ② activation 异常兜底臂:「子会话 activation 异常:…」(spawn 机制/provider 面失败,同属开工前)。
|
|
337
|
+
// worker 自己的失误(空输出/超时中断/文本降级失败)不算——好模型不该为 worker 的错误背锅。
|
|
338
|
+
var WORKER_MODEL_CIRCUIT_AFTER = 3 // 连续 N 次模型面失败才拉黑(与 worker 三连败阻塞同一三击口径)
|
|
339
|
+
// 探测恢复阈值(task-mvvw71nc):连续 N 次探测通过才解除熔断。2 = 探测间隔 15min 的双周期确认
|
|
340
|
+
// (单次成功可能是偶发响应——能回 1 token 但跑长任务就崩的病模型不被放跑);与熔断三击口径同族。
|
|
341
|
+
var PROBE_OK_RECOVER_AFTER = 2
|
|
342
|
+
// isHungKillFailure 与挂死常量段已拆至 lib/hung.mjs(拆分第 1 刀 task-mvt8c1yx),顶部 import 同源使用
|
|
343
|
+
// (依赖向单行道 health→hung,禁反向)。
|
|
344
|
+
function isModelKillFailure(errText) {
|
|
345
|
+
var s = String(errText || '')
|
|
346
|
+
if (isHungKillFailure(s)) return true
|
|
347
|
+
if (s.indexOf('子会话被宿主拆除(stopReason: ') === 0) return s.indexOf('子会话被宿主拆除(stopReason: completed') !== 0
|
|
348
|
+
return s.indexOf('子会话 activation 异常:') === 0
|
|
349
|
+
}
|
|
350
|
+
// 连击 +1;达 N 且尚未拉黑 → badModels 落黑名单(取模链/降档候选的既有熔断判定照常消费,零新分支)
|
|
351
|
+
// + 健康表 circuit + 系统通知(不带 taskId:与验收熔断通知同口径,不受任务回执冲刷丢弃)。
|
|
352
|
+
// why 可选(task-mvqcoz9l):挂死触发的熔断文案分态(挂死≠秒拆),缺省=既有秒拆文案(既有调用点零回归)。
|
|
353
|
+
// 返回是否本次调用触发拉黑(测试/留痕用)。
|
|
354
|
+
function feedModelFailure(sid, model, why) {
|
|
355
|
+
if (!model) return false
|
|
356
|
+
var k = modelKey(sid, model)
|
|
357
|
+
var n = (modelFailStreak[k] || 0) + 1
|
|
358
|
+
modelFailStreak[k] = n
|
|
359
|
+
if (n < WORKER_MODEL_CIRCUIT_AFTER) return false
|
|
360
|
+
if (!badModels[k]) {
|
|
361
|
+
badModels[k] = true
|
|
362
|
+
markModelCircuit(sid, model, why || ('连续 ' + n + ' 次 spawn/续跑即被宿主拆除(模型失败),已熔断'))
|
|
363
|
+
pushSysNote(sid, '模型 ' + model + ' ' + (why || ('连续 ' + n + ' 次 spawn/续跑即被宿主秒拆(模型失败),已熔断')) + '——后续取模链跳过该模型')
|
|
364
|
+
}
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
367
|
+
// 连击归零:任一可归因 run 正常完成即证明模型链路恢复(settleRunRecord completed 路径调用)
|
|
368
|
+
function resetModelFailure(sid, model) { if (model) delete modelFailStreak[modelKey(sid, model)] }
|
|
369
|
+
// 探测恢复熔断解除(task-mvvw71nc,用户 2026-10-31 拍板「乙+连击」):探测 ok 臂的恢复钩。
|
|
370
|
+
// 计数只在「已熔断」时累积(未熔断模型探测成功零副作用——不建多余计数);连续 PROBE_OK_RECOVER_AFTER 次
|
|
371
|
+
// 通过即复位:delete badModels + delete modelFailStreak + syncModelHealth 重派生(state 落回 healthy)
|
|
372
|
+
// + 系统通知(不带 taskId,与熔断通知同口径)。复位后首次 run 失败照走既有 feedModelFailure 连击链
|
|
373
|
+
// (无豁免——复位只证明可调,质量闸照旧:探测管可调性、行为闸管质量)。
|
|
374
|
+
// 返回是否本次调用解除熔断(测试/留痕用)。
|
|
375
|
+
function recoverModelOnProbeOk(sid, model) {
|
|
376
|
+
if (!model) return false
|
|
377
|
+
var k = modelKey(sid, model)
|
|
378
|
+
if (!badModels[k]) return false
|
|
379
|
+
var n = (probeOkStreak[k] || 0) + 1
|
|
380
|
+
probeOkStreak[k] = n
|
|
381
|
+
if (n < PROBE_OK_RECOVER_AFTER) return false
|
|
382
|
+
delete badModels[k]
|
|
383
|
+
delete probeOkStreak[k]
|
|
384
|
+
delete modelFailStreak[k]
|
|
385
|
+
syncModelHealth(sid, model)
|
|
386
|
+
pushSysNote(sid, '模型 ' + model + ' 连续 ' + n + ' 次探测通过,熔断已解除,恢复调度')
|
|
387
|
+
return true
|
|
388
|
+
}
|
|
389
|
+
// 非 ok 探测结果(失败/超时/degraded)→ 该模型恢复计数清零(连续 2 次必须真的连续)
|
|
390
|
+
function resetProbeOkStreak(sid, model) { if (model) delete probeOkStreak[modelKey(sid, model)] }
|
|
391
|
+
// ===== 主动探测编排(拆分第 4 刀随迁):probeOnce 单次请求 → probeModel 单模型三态 → =====
|
|
392
|
+
// probeConfiguredModels 配置集合探测 → maybeProbeModels 15s 心跳间隔闸 → disposeProbe 统一卸载入口。
|
|
393
|
+
// 「绝不阻塞 poolCycle」红线:探测经 maybeProbeModels 后台异步跑(不 await 进派发主链);
|
|
394
|
+
// probeNextAt 间隔闸与 probeModel 三态消费(true=🟢存活 / 'degraded'=🟡降级存活 / false=失败)语义零变化。
|
|
395
|
+
// 单次探测请求:返回 { status:'ok' } | { status:'empty', text, code } | { status:'fail', text }
|
|
396
|
+
// | { status:'timeout', text }(task-mvsv8zym:超时单列形态,不再与真错误混记 fail——
|
|
397
|
+
// 超时可能只是慢模型+我方预算约束,由上层按首次尝试判定落定或保留既有观测)。
|
|
398
|
+
// attemptMs = 单次尝试独立超时(每尝试各拿满全额预算,非共享递减;task-mvsv8zym)。
|
|
399
|
+
// 每次尝试独立 AbortController/计时器;probeAbort 指向当前在飞尝试(dispose 时统一 abort)。
|
|
400
|
+
async function probeOnce(llm, model, maxTokens, attemptMs) {
|
|
401
|
+
var ac = new AbortController(), timedOut = false, disposeTimer = null
|
|
402
|
+
var attempt = Math.max(1, Number(attemptMs) || 0)
|
|
403
|
+
var slash = model.indexOf('/')
|
|
404
|
+
try {
|
|
405
|
+
if (ctx.timer && typeof ctx.timer.timeout === 'function') disposeTimer = ctx.timer.timeout(function () { timedOut = true; ac.abort() }, attempt)
|
|
406
|
+
else { var nativeTimer = setTimeout(function () { timedOut = true; ac.abort() }, attempt); disposeTimer = function () { clearTimeout(nativeTimer) } }
|
|
407
|
+
probeAbort[model] = ac
|
|
408
|
+
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 })
|
|
409
|
+
var finished = false, failure = '', failureCode = ''
|
|
410
|
+
for await (var chunk of stream) {
|
|
411
|
+
if (!chunk || chunk.type !== 'finish') continue
|
|
412
|
+
finished = true
|
|
413
|
+
var reason = chunk.reason || {}
|
|
414
|
+
if (reason.kind === 'error' || reason.kind === 'aborted') {
|
|
415
|
+
failure = String((reason.failure && reason.failure.message) || reason.kind || '模型探测失败')
|
|
416
|
+
failureCode = String((reason.failure && reason.failure.code) || '')
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (timedOut) return { status: 'timeout', text: 'probe timeout ' + Math.round(attempt) + 'ms' }
|
|
420
|
+
if (!finished) return { status: 'fail', text: '模型探测流未返回 finish' }
|
|
421
|
+
if (failure) return isEmptyResponse(failureCode, failure) ? { status: 'empty', text: failure, code: failureCode } : { status: 'fail', text: failure }
|
|
422
|
+
return { status: 'ok' }
|
|
423
|
+
} catch (e) {
|
|
424
|
+
// 抛错形态(provider 直接 throw LlmError):code 可能带 EMPTY_RESPONSE,文本兜底同一谓词
|
|
425
|
+
if (timedOut) return { status: 'timeout', text: 'probe timeout ' + Math.round(attempt) + 'ms' }
|
|
426
|
+
var text = String(e && e.message || e)
|
|
427
|
+
return isEmptyResponse(e && e.code, text) ? { status: 'empty', text: text, code: String(e && e.code || '') } : { status: 'fail', text: text }
|
|
428
|
+
} finally {
|
|
429
|
+
try { if (typeof disposeTimer === 'function') disposeTimer() } catch (_) {}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
async function probeModel(sid, model) {
|
|
433
|
+
if (probeDisposed) return null
|
|
434
|
+
if (probeInFlight[model]) return probeInFlight[model]
|
|
435
|
+
var llm = ctx.get && ctx.get('llm')
|
|
436
|
+
if (!llm || typeof llm.stream !== 'function') return null
|
|
437
|
+
var slash = model.indexOf('/')
|
|
438
|
+
if (slash <= 0 || slash === model.length - 1) return null
|
|
439
|
+
var started = Date.now()
|
|
440
|
+
var promise = (async function () {
|
|
441
|
+
try {
|
|
442
|
+
var outcome = await probeOnce(llm, model, PROBE_MAX_TOKENS, PROBE_ATTEMPT_TIMEOUT_MS)
|
|
443
|
+
if (outcome.status === 'timeout') {
|
|
444
|
+
// ③ 首探即超时(task-mvsv8zym):无重试(⑬e 契约保留),且不覆盖既有观测——只更新 note
|
|
445
|
+
// (判定保留):超时可能只是慢模型+我方预算约束,已 healthy/degraded 条目不倒退成 unknown。
|
|
446
|
+
observeModel(sid, model, false, { probe: true, preserveObserved: true, note: String(outcome.text || '').slice(0, 300) })
|
|
447
|
+
if (!probeDisposed) resetProbeOkStreak(sid, model)
|
|
448
|
+
return false
|
|
449
|
+
}
|
|
450
|
+
if (outcome.status === 'empty') {
|
|
451
|
+
// 空响应 = 唯一触发重试的形态(真错误/超时/无 finish 一次定性,不重试)
|
|
452
|
+
if (probeDisposed) {
|
|
453
|
+
// 探测已停(dispose 中断):无重试机会,仍按错误码分治落定(停机是我方调度约束,非模型健康信号)
|
|
454
|
+
var budgetText = '空响应且探测已停(不重试,零内容):' + outcome.text
|
|
455
|
+
outcome = classifyEmptyResponse(outcome.code, outcome.text) === 'degraded'
|
|
456
|
+
? { status: 'degraded', text: budgetText }
|
|
457
|
+
: { status: 'fail', text: budgetText }
|
|
458
|
+
} else {
|
|
459
|
+
var first = outcome
|
|
460
|
+
outcome = await probeOnce(llm, model, PROBE_MAX_TOKENS_RETRY, PROBE_ATTEMPT_TIMEOUT_MS)
|
|
461
|
+
// 重试仍空 = 按错误码分治(task-mvrqdw4k 用户拍板):纯空响应=🟡degraded 存活(供应商怪癖,链路通)
|
|
462
|
+
// ——不喂冷却、路由仍可选用;额度/凭据/连接信号=真错,维持失败臂(quota 文本优先级高于空响应判定,
|
|
463
|
+
// ⑬b/⑬d 契约保留)。文案标明重试口径便于排查与嗅探归因。
|
|
464
|
+
if (outcome.status === 'empty') {
|
|
465
|
+
var emptyText = 'maxTokens=' + PROBE_MAX_TOKENS_RETRY + ' 重试仍空(EMPTY_RESPONSE):' + outcome.text
|
|
466
|
+
outcome = classifyEmptyResponse(outcome.code, outcome.text) === 'degraded'
|
|
467
|
+
? { status: 'degraded', text: emptyText }
|
|
468
|
+
: { status: 'fail', text: emptyText }
|
|
469
|
+
} else if (outcome.status === 'timeout') {
|
|
470
|
+
// ② 重试超时≠失败(task-mvsv8zym):每尝试独立 20s 预算仍耗尽=慢模型,沿用首次尝试判定落定
|
|
471
|
+
// ——与上文「空响应且探测已停」分支同原则(我方预算/停机约束非模型健康信号,
|
|
472
|
+
// 本分支与其对称补齐,探测段内相邻两处不再互钉行号):
|
|
473
|
+
// 首探纯空响应→🟡degraded;首探文本带额度/凭据/连接信号→fail(嗅探接管)。
|
|
474
|
+
var firstVerdict = classifyEmptyResponse(first.code, first.text)
|
|
475
|
+
outcome = firstVerdict === 'degraded'
|
|
476
|
+
? { status: 'degraded', text: '重试预算耗尽(慢模型),沿用首探判定:' + String(first.text || '') }
|
|
477
|
+
: { status: 'fail', text: '重试预算耗尽(慢模型),沿用首探判定:' + String(first.text || '') }
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (outcome.status === 'ok') {
|
|
482
|
+
observeModel(sid, model, true, { probe: true, latencyMs: Date.now() - started })
|
|
483
|
+
// task-mvvw71nc:探测成功 = 证「可调用」,对已熔断模型累计恢复连击(连续 2 次即解除熔断,
|
|
484
|
+
// 红态翻绿);未熔断模型零副作用。dispose 中断后的迟到结果不写任何表。
|
|
485
|
+
if (!probeDisposed) recoverModelOnProbeOk(sid, model)
|
|
486
|
+
return true
|
|
487
|
+
}
|
|
488
|
+
if (outcome.status === 'degraded') {
|
|
489
|
+
// 🟡 降级存活臂(task-mvrqdw4k):记存活不记失败——observeModel 落 degraded 派生态;
|
|
490
|
+
// 不调 sniffModelCooldown(冷却表零登记);recentRuns 不动(探测本就不进 recentRuns,观察臂已隔开)。
|
|
491
|
+
observeModel(sid, model, false, { probe: true, degraded: true, latencyMs: Date.now() - started, note: String(outcome.text || '').slice(0, 300) })
|
|
492
|
+
if (!probeDisposed) resetProbeOkStreak(sid, model)
|
|
493
|
+
return 'degraded'
|
|
494
|
+
}
|
|
495
|
+
var text = String(outcome.text || '模型探测失败').slice(0, 300)
|
|
496
|
+
observeModel(sid, model, false, { probe: true, latencyMs: Date.now() - started, note: text })
|
|
497
|
+
if (!probeDisposed) { resetProbeOkStreak(sid, model); try { await sniffModelCooldown(sid, '', model, text) } catch (_) {} }
|
|
498
|
+
return false
|
|
499
|
+
} catch (e) {
|
|
500
|
+
// 兜底臂(probeOnce 自吞错,理论不可达):语义与旧实现逐字一致
|
|
501
|
+
var failText = String(e && e.message || e).slice(0, 300)
|
|
502
|
+
observeModel(sid, model, false, { probe: true, latencyMs: Date.now() - started, note: failText })
|
|
503
|
+
if (!probeDisposed) { resetProbeOkStreak(sid, model); try { await sniffModelCooldown(sid, '', model, failText) } catch (_) {} }
|
|
504
|
+
return false
|
|
505
|
+
} finally {
|
|
506
|
+
delete probeInFlight[model]
|
|
507
|
+
delete probeAbort[model]
|
|
508
|
+
}
|
|
509
|
+
})()
|
|
510
|
+
probeInFlight[model] = promise
|
|
511
|
+
return promise
|
|
512
|
+
}
|
|
513
|
+
async function probeConfiguredModels(sid, d) {
|
|
514
|
+
var c = core.cfg(d), models = core.configuredModelIds(d)
|
|
515
|
+
for (var i = 0; i < models.length; i++) syncModelHealth(sid, models[i])
|
|
516
|
+
if (!c.probeEnabled) return { disabled: true, models: models, probed: 0, degraded: 0 }
|
|
517
|
+
var llm = ctx.get && ctx.get('llm')
|
|
518
|
+
if (!llm || typeof llm.stream !== 'function') return { disabled: false, unavailable: true, models: models, probed: 0, degraded: 0 }
|
|
519
|
+
var probed = 0, healthy = 0, degraded = 0
|
|
520
|
+
for (var m = 0; m < models.length; m++) {
|
|
521
|
+
if (probeDisposed) break
|
|
522
|
+
var before = probeInFlight[models[m]] ? false : true
|
|
523
|
+
var outcome = await probeModel(sid, models[m])
|
|
524
|
+
if (outcome === null) continue
|
|
525
|
+
if (before) probed++
|
|
526
|
+
// task-mvrqdw4k:probeModel 三态消费——true=🟢存活、'degraded'=🟡降级存活(供应商怪癖)、false=失败
|
|
527
|
+
if (outcome === true) healthy++
|
|
528
|
+
else if (outcome === 'degraded') degraded++
|
|
529
|
+
}
|
|
530
|
+
return { disabled: false, models: models, probed: probed, healthy: healthy, degraded: degraded }
|
|
531
|
+
}
|
|
532
|
+
// 15s 调度心跳驱动间隔闸:配置变化立即重排,探测在后台跑,不 await 进派发主链。
|
|
533
|
+
function maybeProbeModels(sid, d) {
|
|
534
|
+
var c = core.cfg(d), now = Date.now()
|
|
535
|
+
if (!c.probeEnabled) { probeNextAt[sid] = 0; return }
|
|
536
|
+
var llm = ctx.get && ctx.get('llm')
|
|
537
|
+
if (!llm || typeof llm.stream !== 'function') return
|
|
538
|
+
var planKey = String(c.probeIntervalMin)
|
|
539
|
+
if (probeNextAt[sid] && probeNextAt[sid].plan === planKey && now < probeNextAt[sid].at) return
|
|
540
|
+
if (!probeNextAt[sid] || probeNextAt[sid].plan !== planKey) probeNextAt[sid] = { at: 0, plan: planKey }
|
|
541
|
+
probeNextAt[sid] = { at: now + c.probeIntervalMin * 60000, plan: planKey }
|
|
542
|
+
probeConfiguredModels(sid, d).catch(function (e) { console.error('[task-board] 模型健康探测失败(下周期重试):', String(e)) })
|
|
543
|
+
}
|
|
544
|
+
// 插件卸载统一入口(拆分第 4 刀):落卸载闸(probeDisposed)+ abort 在飞尝试 + 清 probeNextAt 间隔闸表。
|
|
545
|
+
// 注册仍由 dispatch.mjs 的卸载 effect 执行(回调只调本方法)——三步操作与旧卸载 effect 逐字一致;
|
|
546
|
+
// dispose 后 probeModel 不再启动新探测、在飞尝试被 abort、迟到结果不再写健康表。
|
|
547
|
+
function disposeProbe() {
|
|
548
|
+
probeDisposed = true
|
|
549
|
+
Object.keys(probeAbort).forEach(function (model) { try { probeAbort[model].abort() } catch (_) {} })
|
|
550
|
+
probeNextAt = {}
|
|
551
|
+
}
|
|
552
|
+
return { healthBucket: healthBucket, healthEntry: healthEntry, syncModelHealth: syncModelHealth, observeModel: observeModel, markModelCircuit: markModelCircuit, cooldownLive: cooldownLive, sniffModelCooldown: sniffModelCooldown, modelDirectory: modelDirectory, outOfDir: outOfDir, traceStaleModel: traceStaleModel, healthyModel: healthyModel, pickHealthyFallback: pickHealthyFallback, isModelKillFailure: isModelKillFailure, feedModelFailure: feedModelFailure, resetModelFailure: resetModelFailure, probeConfiguredModels: probeConfiguredModels, maybeProbeModels: maybeProbeModels, disposeProbe: disposeProbe }
|
|
553
|
+
}
|