@arcaneorion/dsh-model-channel-manager 0.3.12 → 0.3.14
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 +64 -19
- package/package.json +1 -1
- package/src/client.js +86 -27
- package/src/health-store.js +25 -6
- package/src/index.js +149 -10
package/README.md
CHANGED
|
@@ -126,7 +126,7 @@ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
|
|
|
126
126
|
|
|
127
127
|
- 模型行「⚡测试」→ 弹窗输入自定义问题 + maxTokens → 发送
|
|
128
128
|
- prompt 存 localStorage(`mcm_test_prompt`,pi 同款,全局共用)
|
|
129
|
-
- host 用 `llm.stream({provider, model, messages, maxTokens})` 真实调用(与正式对话同链路);60s
|
|
129
|
+
- host 用 `llm.stream({provider, model, messages, maxTokens})` 真实调用(与正式对话同链路);45s 总时限(0.3.13 由 60s 下调,给终态写入留余量)
|
|
130
130
|
- 结果:`status:'ok'`(ttftMs/latencyMs/text)或 `status:'error'`(code/error)
|
|
131
131
|
- 模型行内显示 ⏳→✓/✗ 状态标签(hover 见详情)
|
|
132
132
|
|
|
@@ -187,6 +187,33 @@ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
|
|
|
187
187
|
|
|
188
188
|
host 侧兜底:`rewireRoutes` 发现配置组数 > 实际路由数时 `console.warn` 列出被丢弃的组 ID(此前是静默丢弃——H08:3 组保存、路由只有 1 条,界面仍显示「已保存」)。允许清空全部组(空列表合法)。
|
|
189
189
|
|
|
190
|
+
## 保存时的 revision 冲突(0.3.14:自噪声识别 + 自动重试)
|
|
191
|
+
|
|
192
|
+
**症状**:保存后提示「部分保存:提供商配置 已生效;轮询组配置 失败——轮询组配置已被其他页面修改(版本冲突),请点「刷新」后重试」,且要点多次才成功。
|
|
193
|
+
|
|
194
|
+
**根因**(dsh-settings 源码事实):`describe()` 里
|
|
195
|
+
|
|
196
|
+
```js
|
|
197
|
+
raw = JSON.stringify([fiber.uid, schema.toJSON(), entry.options.config ?? {}])
|
|
198
|
+
revision = previous.revision + Number(previous.raw !== raw)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**revision 就是「该行 raw config 的 JSON 指纹」**。而本插件的 settings 行同时承载健康投影——宿主每 5s 的 digest 叶写、`runtime`、`testResults` 都会改动该行 raw config,revision 因此不断前进。client 加载时记下的 revision 在几秒内必然过期,保存被 `settings/conflict` 拒绝:**提示里的「其他页面」其实是插件自己写的健康数据**。providers 走的是另一个行(`llm-pi-ai`),没有这种自噪声,所以它总是先成功——这正是「部分保存」的原因。
|
|
202
|
+
|
|
203
|
+
**修法**:
|
|
204
|
+
|
|
205
|
+
- client:写入前**重新读取 revision**(消除过期);仍冲突时比较远端值与我们加载时的基线
|
|
206
|
+
(`groups`+`providerOrder` / `providers`,用键序无关的规范 JSON 比较)——
|
|
207
|
+
**远端没变 = 自噪声 → 用新 revision 重试(最多 3 次);远端真的变了 → 才报冲突**。
|
|
208
|
+
既消除误报,也保留真正的多页面冲突保护(审计 F05 的语义)。
|
|
209
|
+
- host:`digest` 内容未变时不写(60s 心跳保底),从源头减少 revision 噪声。
|
|
210
|
+
|
|
211
|
+
回归:`tests/revision-conflict.test.cjs`。
|
|
212
|
+
|
|
213
|
+
> 真正的根治是把健康/任务通道搬出 settings 行(审计建议的 Remote + storageDomain 路线)——
|
|
214
|
+
> 那时本行 revision 只随用户配置变化,连重试都不需要。当前修法在不改通信架构的前提下
|
|
215
|
+
> 同时消除了症状与误报。
|
|
216
|
+
|
|
190
217
|
## 密钥写入(凭据引用虚拟化)
|
|
191
218
|
|
|
192
219
|
- 面板主视图只出现「API Key」输入框:**粘贴或输入后失焦即自动写入** DSH 凭据存储(`~/.dsh/.credentials.yaml`,0600,write-only 读不回),无手动按钮;清空输入框不会删除已存 key。上游 llm-pi-ai 的供应商 profile 只有 `apiKeyEnv` 一个密钥字段(凭据引用名),不存在内联 key 的选项——secrets 不进 settings.yaml、不随 `settings.describe` 下发,是有意的安全设计。
|
|
@@ -231,24 +258,37 @@ react 经 `require('react')`;样式用 `ctx.effect` 自管理;`dsh.client: {
|
|
|
231
258
|
- 30m/24h 健康视图是近似口径(按最近活跃过滤,数值为 7 天累计,UI 已标注);精确分窗口
|
|
232
259
|
需 host 出多份 digest
|
|
233
260
|
|
|
234
|
-
### 0.3.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
- **
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
-
|
|
250
|
-
|
|
251
|
-
`
|
|
261
|
+
### 0.3.13:2026-10-01 审计剩余项(已修)
|
|
262
|
+
|
|
263
|
+
同一份只读审计(13 项)里,0.3.12 修了最要命的两条(去重 guard、整树回写),0.3.13 清掉其余:
|
|
264
|
+
|
|
265
|
+
- **R3 remount/dispose 打断在飞测试**:`timed()` 守卫不再只在 dispose 时 `clearTimeout`——
|
|
266
|
+
那样 promise 永不 settle,`await Promise.race([inner.next(), guard.promise])` 直接挂死,
|
|
267
|
+
在飞测试既不结束也不写终态。现在 dispose 会主动 reject `ABORTED`,调用方走正常失败路径。
|
|
268
|
+
另外**启动时清扫**:新进程里任何 `status: running` 且无 `finishedAt` 的条目都是上次遗留的,
|
|
269
|
+
补写 `ABORTED` 终态;`running + finishedAt` 的僵尸条目按 `ok` 归一状态位
|
|
270
|
+
(纯函数 `selectStaleTestEntries` 导出,回归见 `tests/runtime-hardening.test.cjs`)。
|
|
271
|
+
- **R2 volatile 提交静默失败(放大器)**:请求存在但既未认领也未结算时,
|
|
272
|
+
① `handleTestRequest` 显式打日志(每个 nonce 一次,不再静默忽略);
|
|
273
|
+
② 新增 5s 兜底轮询 `sweepPendingTestRequest`,复用同一个消费判定,主动重试一次并留日志;
|
|
274
|
+
③ `reloadFromConfig` 检测「health 子树运行时从有到无」并大声告警——这是 0.3.11
|
|
275
|
+
schema 兜底事故的形态,以前完全无声。
|
|
276
|
+
- **#7 预算错配**:host 测试总时限 60s → 45s(client 轮询上限 ≈66s,旧值只留 6s 余量);
|
|
277
|
+
client 放弃时还会比对 `lastTestHandledNonce`,区分第三种情况
|
|
278
|
+
`POLL_TIMEOUT_SETTLED`(host 已结算但结果条目未写入)。
|
|
279
|
+
- **#6 notReady 退避**:释放认领后设 5s 退避窗口(测试/测速各自),期间任何
|
|
280
|
+
volatile-update 都不再消费——旧实现释放后会被立刻再消费,并发起多个 45s 上游请求。
|
|
281
|
+
- **#13 迁移直写**:`migrateLegacyConfig` 改走 `bus.writeConfig`(`outsideTransaction` 包裹),
|
|
282
|
+
不再在 HMR 事务内直写(旧写法会抛 "cannot be nested" 并被空 catch 吞掉,只剩哨兵)。
|
|
283
|
+
- **#11 digest 遮蔽**:`digest: []` 且 settings 仍有 `records` 时(domain 打开成功但迁移失败、
|
|
284
|
+
或 host 刚重启尚未投影)回落到 records 路径,页面显示旧流水而不是全 0。
|
|
285
|
+
- **#10 health-store**:事件去重键由 `ts|provider|model` 扩成含 ok/code/延迟/token 的指纹
|
|
286
|
+
(同毫秒同渠道的两笔真实请求不再被当重复合并);`putSpeedRows` 与 `migrateFrom` 的测速写入
|
|
287
|
+
并入同一条写链(不与事件追加交错);`close()` 先排干写链再关 domain(不丢排队中的最后一笔)。
|
|
288
|
+
|
|
289
|
+
**仍然未修(有意保留)**:health 子树写入不带 `expectedRevision`。叶写后危害已大幅降低
|
|
290
|
+
(不同叶子互不覆盖),同一叶子的并发写本身是「后写者胜」的语义,加 revision 需要调用方
|
|
291
|
+
持有并维护 revision,收益不抵复杂度;配置域(groups/providerOrder)仍然带 revision。
|
|
252
292
|
|
|
253
293
|
## 引擎超时与生命周期(0.3.3 重构:审计 F01/F02/F15 已修)
|
|
254
294
|
|
|
@@ -300,6 +340,11 @@ react 经 `require('react')`;样式用 `ctx.effect` 自管理;`dsh.client: {
|
|
|
300
340
|
29. **去重不能依赖 nonce 的数值单调性(0.3.12)**:`last = Math.max(lastTestHandledNonce, claimedNonce)` 看似「取最新」,实则假设了 nonce 单调递增——而 0.3.2 起 nonce 是随机 53-bit,**「上一轮 > 本轮」时(≈50%)判等失效**,同一 testRequest 在每次 `loader/volatile-update`(包括宿主自己每 5s 的 digest 回写触发的那次)都被重新消费。症状是两级:① 上游被真实重复调用(计费);② 多次执行的结果写进同一条目(线上物证:同 nonce 一条记录同时带 `code TIMEOUT / 60000ms` 与 `ok:true / text/ttftMs`,单次执行不可能)。**规则:去重只做严格等值 + 已消费集合 + 已结算值 + 已有终态,任何「大小推断」都是错的**。释放认领(notReady 重放)时必须同时释放已消费集合,否则重放被自己的去重挡住。回归:`tests/task-dedup.test.cjs`(判定表直接调用生产导出 `shouldConsumeNonce`)。
|
|
301
341
|
30. **整树读-改-写会把并发终态「回灌」成旧值(0.3.12 线上事故)**:health 子树的每次写入都曾是 `cur = healthOf(); update({health:{...cur, patch}})`——payload 是调用时刻的**整树快照**。只要它晚于并发的终态写落盘,`status` 就被改回 `running`;而 `settings.update` 是深合并(只覆盖出现的键、不删键),后写入的 `finishedAt/code/error` 反而被保留 → 条目变成「running + 终态字段」的僵尸,客户端只认 `status==='ok'|'error'` 就永远等不到终态,66s 后误报「host 可能未处理该请求」。**规则:settings 里的共享子树一律叶写**(patch 命中的键逐个 `set`;单键结果用 `writeHealthLeaf(['testResults', nonce], value)`;修剪用 `unset` 而非整字典覆盖),client 侧同样不得「describe 快照 + 整树回写」。另:客户端终态判定要接受 `finishedAt`——状态位可能被写坏,但已经发生的终态字段不会消失。回归:`tests/testresult-fallback.test.cjs`(F4 直接复刻事故时序)。
|
|
302
342
|
|
|
343
|
+
31. **超时守卫 dispose 时必须 settle promise(0.3.13,审计 R3)**:`timed()` 的 guard 若在 fiber dispose 时只 `clearTimeout`,promise 永不 reject——`await Promise.race([inner.next(), guard.promise])` 就永久挂住,在飞测试既不结束也不写终态,客户端只能等到 66s 假超时(表现与「host 未处理」一模一样)。**规则:任何挂在 `ctx.effect` 上的定时器,dispose 时不仅要清定时器,还要让等待它的 promise settle**(这里 reject `ABORTED`),否则卸载路径会留下永久悬挂的 await。配套:新进程启动时清扫上次遗留的 `running` 条目(补 `ABORTED` 终态)——进程内在飞任务都有 fiber 生命周期,重启后见到的 running 一定是遗留的。回归:`tests/runtime-hardening.test.cjs`。
|
|
344
|
+
32. **静默失败要有出口(0.3.13,审计 R2)**:loader 的 `_commitVolatile` 在 `resolveConfig` 抛错时只 `logger.warn` 后返回 true——document 已落盘、fiber 引用不更新、**不发 `loader/volatile-update`**。而本插件的任务通道只在 volatile-update 里消费请求,于是请求被彻底忽略、客户端 66s 超时,宿主侧却「什么都没发生」。同理 `.loose(true)` 的 schema 兜底会把整棵 volatile 子树换成空对象而不报错(踩坑 28)。**规则:凡是「静默丢弃用户动作」的路径都要有可见出口**——① 未消费的请求打日志(每 nonce 一次);② 周期性兜底重试(5s,复用同一消费判定,每 nonce 一次);③ 关键子树从有到无时告警。回归:`tests/runtime-hardening.test.cjs`。
|
|
345
|
+
|
|
346
|
+
33. **settings 的 revision 是「raw config 的 JSON 指纹」,会被插件自己的健康写入推高(0.3.14)**:`describe()` 里 `revision += raw !== previous.raw`,`raw = JSON.stringify([fiber.uid, schema.toJSON(), entry.options.config])`。本插件的行同时承载健康投影(digest 每 5s、runtime、testResults),所以 client 手里的 revision 几秒内必然过期,保存被 `settings/conflict` 拒绝,提示却是「已被其他页面修改」——**其实「其他页面」就是插件自己**;providers 在另一个行(llm-pi-ai)没有自噪声,于是表现为「部分保存:提供商成功、轮询组失败,点多次才成功」。两条教训:① **带 revision 的写入必须「写入前重读」**,加载时记下的 revision 只在「该行没有其他写入者」时才有效;② 冲突要**分类**——比较远端值与加载基线(键序无关的规范 JSON),远端没变就是自噪声(重试),真的变了才是冲突(报错)。根治方向是把高频数据搬出该行(Remote/storageDomain)。回归:`tests/revision-conflict.test.cjs`。
|
|
347
|
+
|
|
303
348
|
## 0.3.11 修复后的自愈路径(无需手工清库)
|
|
304
349
|
|
|
305
350
|
重启后:schema 接受历史字符串 nonce → `records` 重新可见 → `migrateFrom` 把旧流水**合并去重**进 domain(`my-opencode-go` 桶已有新事件,按 `ts|provider|model` 去重后并入)→ path-ops 真删除 settings 里的 `records` → digest 重算包含历史。日志会打印 `legacy health imported into domain and cleared from settings`。
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -566,11 +566,13 @@ window.__ModuleLoader__.load({
|
|
|
566
566
|
const pollTest = (nonce, provider, model, attempt = 0) => {
|
|
567
567
|
if (!apiRef) return
|
|
568
568
|
const key = provider + '::' + model
|
|
569
|
-
// host 侧测试超时
|
|
570
|
-
//
|
|
571
|
-
const giveUp = (e) => setTestStates((s) => Object.assign({}, s, { [key]:
|
|
572
|
-
? { status: 'error', code: '
|
|
573
|
-
:
|
|
569
|
+
// host 侧测试超时 45s(0.3.13 从 60s 下调,给终态写入留出余量),
|
|
570
|
+
// 约 55 次 × 1.2s ≈ 66s 后放弃。放弃时按现场分三种提示,不再一律甩锅「未处理」。
|
|
571
|
+
const giveUp = (e, settledHere) => setTestStates((s) => Object.assign({}, s, { [key]: settledHere
|
|
572
|
+
? { status: 'error', code: 'POLL_TIMEOUT_SETTLED', error: 'host 已完成本次测试但结果条目未能写入(写入失败或被覆盖):请重试,若反复出现请查看宿主日志' }
|
|
573
|
+
: (e && e.status === 'running')
|
|
574
|
+
? { status: 'error', code: 'POLL_TIMEOUT_RUNNING', error: '测试仍在执行(约 66 秒未返回结果):host 已收到请求,可能是渠道慢或上游挂起;稍后可在健康统计里核对' }
|
|
575
|
+
: { status: 'error', code: 'POLL_TIMEOUT', error: '等待测试结果超时:host 未写入结果(可能 host 未重启到新版,或结果写入失败)' } }))
|
|
574
576
|
apiRef.settings.describe({}).then((resp) => {
|
|
575
577
|
const r = resp && resp.result ? resp.result : resp
|
|
576
578
|
const d = r && r.value !== undefined ? r.value : r
|
|
@@ -585,7 +587,9 @@ window.__ModuleLoader__.load({
|
|
|
585
587
|
const norm = (e.status === 'ok' || e.status === 'error') ? e : Object.assign({}, e, { status: e.ok === true ? 'ok' : 'error' })
|
|
586
588
|
setTestStates((s) => Object.assign({}, s, { [key]: norm }))
|
|
587
589
|
} else if (attempt >= 55) {
|
|
588
|
-
|
|
590
|
+
// host 已结算(lastTestHandledNonce === 自己的 nonce)但结果条目不在 → 写入失败
|
|
591
|
+
const settled = ns && ns.value ? ns.value.lastTestHandledNonce : undefined
|
|
592
|
+
giveUp(e, settled !== undefined && settled !== null && String(settled) === String(nonce))
|
|
589
593
|
} else {
|
|
590
594
|
setTimeout(() => pollTest(nonce, provider, model, attempt + 1), 1200)
|
|
591
595
|
}
|
|
@@ -1097,7 +1101,12 @@ window.__ModuleLoader__.load({
|
|
|
1097
1101
|
// digest 是 7 天全窗口聚合;30m/24h 视图按 lastTs 近似(lastTs 在窗口内才计入),
|
|
1098
1102
|
// 精细窗口统计属后续增强(需要 host 按窗口出多份 digest)。
|
|
1099
1103
|
// 旧 host 兼容:无 digest 字段时走原始 records 路径(升级窗口不断供)。
|
|
1100
|
-
|
|
1104
|
+
// #11(审计):digest 是空数组时不要遮蔽 settings 里仍存在的 records——
|
|
1105
|
+
// 典型场景是 domain 打开成功但迁移失败(host 保留旧流水不清理)或 host 刚重启
|
|
1106
|
+
// 尚未投影。此时回落到 records 路径,页面显示旧流水而不是全 0。
|
|
1107
|
+
const rawRecords = health.records || {}
|
|
1108
|
+
const hasRawRecords = Object.keys(rawRecords).some((k) => Array.isArray(rawRecords[k]) && rawRecords[k].length > 0)
|
|
1109
|
+
const digestRows = (Array.isArray(health.digest) && (health.digest.length > 0 || !hasRawRecords)) ? health.digest : null
|
|
1101
1110
|
// 按 provider 分组
|
|
1102
1111
|
const byProvider = new Map()
|
|
1103
1112
|
|
|
@@ -1426,6 +1435,46 @@ window.__ModuleLoader__.load({
|
|
|
1426
1435
|
if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'request failed')
|
|
1427
1436
|
return r && r.value !== undefined ? r.value : r
|
|
1428
1437
|
}
|
|
1438
|
+
// 冲突判定:宿主以 settings/conflict 拒绝(dsh-settings 的 SettingsConflictError)
|
|
1439
|
+
const isConflict = (err) => !!(err && (err.code === 'settings/conflict' || /conflict/i.test(String(err.message || ''))))
|
|
1440
|
+
// 规范 JSON(对象键排序)——用于「远端是否真的改过我们关心的字段」的比较,
|
|
1441
|
+
// 不受键序影响(providers/groups 的对象键序在落盘与投影之间可能不同)
|
|
1442
|
+
const canonicalJson = (v) => {
|
|
1443
|
+
if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']'
|
|
1444
|
+
if (v && typeof v === 'object') return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonicalJson(v[k])).join(',') + '}'
|
|
1445
|
+
return JSON.stringify(v === undefined ? null : v)
|
|
1446
|
+
}
|
|
1447
|
+
const sameJson = (a, b) => canonicalJson(a) === canonicalJson(b)
|
|
1448
|
+
const readNsDesc = async (ns) => {
|
|
1449
|
+
const resp = await apiRef.settings.describe({})
|
|
1450
|
+
const d = unwrap(resp)
|
|
1451
|
+
return ((d && d.namespaces) || []).find((n) => n && n.ns === ns) || null
|
|
1452
|
+
}
|
|
1453
|
+
// 带 revision 的保存 + 「自噪声冲突」自动重试(0.3.14):
|
|
1454
|
+
//
|
|
1455
|
+
// 为什么必须这样:本插件的 settings 行(model-channel-manager)同时承载健康投影,
|
|
1456
|
+
// 宿主每 5s 的 digest 叶写都会改动该行的 raw config;而 describe() 的 revision
|
|
1457
|
+
// 正是「raw config 的 JSON 指纹」(dsh-settings:revision += raw !== previous.raw)。
|
|
1458
|
+
// 于是 client 手里那份 revision 在几秒内必然过期,保存被 settings/conflict 拒绝——
|
|
1459
|
+
// 提示里的「其他页面修改」其实是插件自己写的健康数据。
|
|
1460
|
+
//
|
|
1461
|
+
// 策略:① 每次写入前取最新 revision(消除过期);② 仍冲突时比较远端值与我们加载时
|
|
1462
|
+
// 的基线:没变 = 纯 revision 噪声 → 用新 revision 重试;真的变了 = 真冲突 → 报错。
|
|
1463
|
+
const saveWithRevisionRetry = async ({ ns, baseline, remoteOf, write, conflictMessage, attempts = 3 }) => {
|
|
1464
|
+
let lastError = null
|
|
1465
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
1466
|
+
const desc = await readNsDesc(ns).catch(() => null)
|
|
1467
|
+
const rev = desc && typeof desc.revision === 'number' ? desc.revision : undefined
|
|
1468
|
+
const r = await write(rev)
|
|
1469
|
+
if (!(r && r.ok === false)) return r
|
|
1470
|
+
lastError = r.error
|
|
1471
|
+
if (!isConflict(r.error)) throw new Error((r.error && (r.error.message || r.error)) || (ns + ' save failed'))
|
|
1472
|
+
const now = await readNsDesc(ns).catch(() => null)
|
|
1473
|
+
if (!sameJson(remoteOf(now), baseline)) throw new Error(conflictMessage)
|
|
1474
|
+
// 远端未变:属于插件自身健康写入造成的 revision 噪声 → 取新 revision 重试
|
|
1475
|
+
}
|
|
1476
|
+
throw new Error(conflictMessage.replace(/,请点「刷新」后重试/, '') + '(已自动重试 ' + attempts + ' 次仍未成功)' + (lastError ? ':' + String(lastError.message || lastError) : ''))
|
|
1477
|
+
}
|
|
1429
1478
|
|
|
1430
1479
|
// 按持久化的 providerOrder 重排 providers 的键序:settings-file 的 patchNode
|
|
1431
1480
|
// 对 map 键序盲(拖拽纯重排在文件层是零 diff),顺序由 model-channels ns 里的
|
|
@@ -1645,33 +1694,43 @@ window.__ModuleLoader__.load({
|
|
|
1645
1694
|
for (const k of allKeys) ops.push({ op: 'unset', path: ['providers', k] })
|
|
1646
1695
|
for (const k of keys) ops.push({ op: 'set', path: ['providers', k], value: cleanProviders[k] })
|
|
1647
1696
|
if (ops.length === 0) return Promise.resolve()
|
|
1648
|
-
// F05:带 revision
|
|
1649
|
-
//
|
|
1650
|
-
return
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1697
|
+
// F05 + 0.3.14:带 revision 保存;冲突若只是宿主侧 revision 噪声(远端 providers
|
|
1698
|
+
// 与我们加载时一致)则自动重试,真的被别处改过才提示「刷新后重试」。
|
|
1699
|
+
return saveWithRevisionRetry({
|
|
1700
|
+
ns: 'llm-pi-ai',
|
|
1701
|
+
baseline: (state && state.providers) || {},
|
|
1702
|
+
remoteOf: (desc) => (desc && desc.value && desc.value.providers) || {},
|
|
1703
|
+
write: (rev) => apiRef.settings.mutate({ ns: 'llm-pi-ai', ops, expectedRevision: rev }).then((resp) => {
|
|
1704
|
+
const r = resp && resp.result ? resp.result : resp
|
|
1705
|
+
return r
|
|
1706
|
+
}),
|
|
1707
|
+
conflictMessage: 'llm-pi-ai 配置已被其他页面修改(版本冲突),请点「刷新」后重试',
|
|
1657
1708
|
})
|
|
1658
1709
|
})() : Promise.resolve()
|
|
1659
1710
|
// 顺序持久化:patchNode 对 map 键序盲,拖拽顺序写进 providerOrder 数组(同一次 update 落盘);
|
|
1660
1711
|
// 即使 channelsDraft 为空(无轮询组),只要 draft 非空也要写——这是排序的唯二持久化时机
|
|
1661
1712
|
const orderPatch = draft ? { providerOrder: Object.keys(cleanProviders) } : {}
|
|
1662
|
-
const
|
|
1663
|
-
ns: 'model-channels',
|
|
1664
|
-
expectedRevision: (state && state.nsRevisions && state.nsRevisions.channels) || undefined,
|
|
1713
|
+
const channelsPatch = Object.assign({
|
|
1665
1714
|
// 命名单一身份:写入时统一 virtualModel.name = 组 id——旧的独立呈现名
|
|
1666
1715
|
// (如遗留的 group-1)在下一次保存时自动归一,无需迁移
|
|
1667
|
-
|
|
1668
|
-
})
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1716
|
+
groups: (channelsDraft || []).map((g) => Object.assign({}, g, { virtualModel: Object.assign({}, g.virtualModel, { name: g.id }) }))
|
|
1717
|
+
}, orderPatch)
|
|
1718
|
+
// 0.3.14:本行同时承载健康投影,宿主每 5s 的 digest 叶写会不断推高它的 revision
|
|
1719
|
+
// (revision = raw config 的 JSON 指纹)——旧实现用加载时的旧 revision,几乎必然
|
|
1720
|
+
// 撞 settings/conflict,于是出现「部分保存:轮询组配置失败……点击多次才能成功」。
|
|
1721
|
+
// 现在每次写入前取最新 revision;冲突时只有远端 groups/providerOrder 真的变了才报冲突。
|
|
1722
|
+
const p2 = saveWithRevisionRetry({
|
|
1723
|
+
ns: 'model-channels',
|
|
1724
|
+
baseline: { groups: channels || [], providerOrder: (state && state.providerOrder) || [] },
|
|
1725
|
+
remoteOf: (desc) => ({
|
|
1726
|
+
groups: (desc && desc.value && desc.value.groups) || [],
|
|
1727
|
+
providerOrder: (desc && desc.value && desc.value.providerOrder) || [],
|
|
1728
|
+
}),
|
|
1729
|
+
write: (rev) => apiRef.settings.update({ ns: 'model-channels', expectedRevision: rev, patch: channelsPatch }).then((resp) => {
|
|
1730
|
+
const r = resp && resp.result ? resp.result : resp
|
|
1731
|
+
return r
|
|
1732
|
+
}),
|
|
1733
|
+
conflictMessage: '轮询组配置已被其他页面修改(版本冲突),请点「刷新」后重试',
|
|
1675
1734
|
})
|
|
1676
1735
|
// F06(审计 C10,0.3.8):两域用 allSettled 而非 all——半成功不再「整单失败」
|
|
1677
1736
|
// 掩盖已提交的那半。各自报告,失败域给出明确指引;部分成功时提示里列明
|
package/src/health-store.js
CHANGED
|
@@ -67,8 +67,17 @@ const healthDomainSpec = defineDomain({
|
|
|
67
67
|
},
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
70
|
+
/**
|
|
71
|
+
* 事件去重键:同一笔在 settings 与 domain 之间合并时不重复计。
|
|
72
|
+
* 0.3.13(审计 #10):旧键只有 `ts|provider|model`,同一毫秒同渠道的两笔真实请求
|
|
73
|
+
* 会被当成同一笔合并掉。把判定性字段一起纳入指纹——同毫秒 + 同渠道 + 同结果形态
|
|
74
|
+
* 的两笔在语义上本就不可区分;真正不同的两笔(成功率/延迟/token 不同)不会再被吞。
|
|
75
|
+
*/
|
|
76
|
+
const eventKey = (e) => [
|
|
77
|
+
e.ts, e.provider, e.model, e.ok ? 1 : 0, e.code || '',
|
|
78
|
+
e.ttftMs ?? '', e.latencyMs ?? '',
|
|
79
|
+
e.inputTokens ?? '', e.outputTokens ?? '', e.cacheReadTokens ?? '', e.cacheWriteTokens ?? '',
|
|
80
|
+
].join('|');
|
|
72
81
|
|
|
73
82
|
/**
|
|
74
83
|
* HealthStore:封装 domain 读写 + 保留策略 + 聚合。
|
|
@@ -98,6 +107,9 @@ export class HealthStore {
|
|
|
98
107
|
/** effect 用:domain 关闭(写入队列排干后释放)。 */
|
|
99
108
|
async close() {
|
|
100
109
|
if (this.ready === null) return;
|
|
110
|
+
// 0.3.13(审计 #10):先排干类内写链再关 domain——旧实现直接 close,
|
|
111
|
+
// 排队中的 append/迁移写入可能在 domain 关闭后才执行(丢最后一笔)。
|
|
112
|
+
await this.eventChain.catch(() => {});
|
|
101
113
|
const domain = await this.ready.catch(() => null);
|
|
102
114
|
this.ready = null;
|
|
103
115
|
this.events = null;
|
|
@@ -132,10 +144,14 @@ export class HealthStore {
|
|
|
132
144
|
});
|
|
133
145
|
}
|
|
134
146
|
|
|
135
|
-
/** 整组测速结果替换写入。
|
|
147
|
+
/** 整组测速结果替换写入。0.3.13(审计 #10):也排入同一条写链,
|
|
148
|
+
* 避免与迁移/追加写入交错(快照语义下后写者胜,交错会写出半新半旧的桶)。 */
|
|
136
149
|
async putSpeedRows(groupId, rows) {
|
|
137
150
|
if (this.speed === null) return;
|
|
138
|
-
await this.
|
|
151
|
+
await this.enqueueEvent(async () => {
|
|
152
|
+
if (this.speed === null) return;
|
|
153
|
+
await this.speed.put(groupId, { rows });
|
|
154
|
+
});
|
|
139
155
|
}
|
|
140
156
|
|
|
141
157
|
/** 读全部事件桶(迁移/聚合用)。 */
|
|
@@ -187,8 +203,11 @@ export class HealthStore {
|
|
|
187
203
|
}
|
|
188
204
|
for (const [groupId, rows] of Object.entries(speedResults || {})) {
|
|
189
205
|
if (!Array.isArray(rows) || rows.length === 0) continue;
|
|
190
|
-
|
|
191
|
-
|
|
206
|
+
await this.enqueueEvent(async () => {
|
|
207
|
+
if (this.speed === null) return;
|
|
208
|
+
if (this.speed.get(groupId) !== undefined) return; // domain 已有该组结果:以 domain 为准
|
|
209
|
+
await this.speed.put(groupId, { rows });
|
|
210
|
+
});
|
|
192
211
|
}
|
|
193
212
|
return true;
|
|
194
213
|
}
|
package/src/index.js
CHANGED
|
@@ -62,6 +62,24 @@ export function shouldConsumeNonce({ consumed, key, claimed, settled, hasTermina
|
|
|
62
62
|
if (key === claimed || key === settled) return false;
|
|
63
63
|
return !consumed.has(key);
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* 启动清扫的分类(纯函数):新进程里已存在的测试条目分为两类——
|
|
67
|
+
* aborted : status=running 且没有 finishedAt = 上次进程遗留的在飞任务(重载打断),
|
|
68
|
+
* 必须补写 ABORTED 终态,否则客户端只会等到 66s 假超时(审计 R3);
|
|
69
|
+
* repaired : status=running 但有 finishedAt(终态字段已在)= status 被并发整树回写
|
|
70
|
+
* 改坏的僵尸条目,按 ok 归一状态位(审计 #2 的线上物证形态)。
|
|
71
|
+
*/
|
|
72
|
+
export function selectStaleTestEntries(testResults) {
|
|
73
|
+
const aborted = [];
|
|
74
|
+
const repaired = [];
|
|
75
|
+
for (const [key, e] of Object.entries(testResults || {})) {
|
|
76
|
+
if (!e || typeof e !== 'object') continue;
|
|
77
|
+
if (e.status !== 'running') continue;
|
|
78
|
+
if (!e.finishedAt) aborted.push(key);
|
|
79
|
+
else if (e.ok === true || e.code || e.error) repaired.push(key);
|
|
80
|
+
}
|
|
81
|
+
return { aborted, repaired };
|
|
82
|
+
}
|
|
65
83
|
export function apply(ctx, config) {
|
|
66
84
|
// 本行在 profile 中的 entry id 就是 settings 命名空间;缺失时回落到包名。
|
|
67
85
|
const SELF_NS = (ctx.fiber && ctx.fiber.entry && ctx.fiber.entry.options && ctx.fiber.entry.options.id) || 'model-channel-manager';
|
|
@@ -109,14 +127,25 @@ export function apply(ctx, config) {
|
|
|
109
127
|
return g;
|
|
110
128
|
};
|
|
111
129
|
// ---------- 可取消超时(避免 Promise.race 留下孤儿定时器) ----------
|
|
130
|
+
// 0.3.13(审计 R3):guard 不能只在 dispose 时 clearTimeout。
|
|
131
|
+
// dispose(= fiber 卸载/重载)时若只清定时器,promise 永不 settle,
|
|
132
|
+
// `await Promise.race([inner.next(), guard.promise])` 就永远挂住:
|
|
133
|
+
// 在飞测试既不结束也不写终态,客户端 66s 后得到假超时。
|
|
134
|
+
// 现在 dispose 会主动 reject(ABORTED),调用方走正常失败路径。
|
|
112
135
|
const timed = (ms, makeError) => {
|
|
113
136
|
let rejectFn;
|
|
137
|
+
let settled = false;
|
|
138
|
+
let timer = null;
|
|
114
139
|
const promise = new Promise((_, reject) => { rejectFn = reject; });
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
140
|
+
const fire = (err) => { if (settled) return; settled = true; rejectFn(err); };
|
|
141
|
+
ctx.effect(() => {
|
|
142
|
+
timer = setTimeout(() => fire(makeError()), ms);
|
|
143
|
+
return () => {
|
|
144
|
+
if (timer !== null) clearTimeout(timer);
|
|
145
|
+
fire({ code: 'ABORTED', message: 'plugin reloaded while this attempt was in flight' });
|
|
146
|
+
};
|
|
118
147
|
}, 'model-channel timeout guard');
|
|
119
|
-
return { promise, dispose };
|
|
148
|
+
return { promise, dispose: () => { if (timer !== null) clearTimeout(timer); settled = true; } };
|
|
120
149
|
};
|
|
121
150
|
// ---------- 配置归一化(与动态原型逐行一致) ----------
|
|
122
151
|
function normalizeConfig(raw) {
|
|
@@ -894,9 +923,11 @@ export function apply(ctx, config) {
|
|
|
894
923
|
// ---------- 单模型真实请求测试(client 经 settings 总线下发,走 DSH 真实 llm.stream 链路) ----------
|
|
895
924
|
async function runModelTest(provider, model, prompt, maxTokens) {
|
|
896
925
|
const llm = ctx.llm;
|
|
897
|
-
// F15(与 measureCandidate 同一模式):总时限
|
|
926
|
+
// F15(与 measureCandidate 同一模式):总时限 45s(旧行为每 chunk 重置 guard,
|
|
898
927
|
// 持续输出的请求永不超时而 client 已放弃)+ 超时 abort 上游 + 统一 finally 关闭。
|
|
899
|
-
|
|
928
|
+
// 0.3.13:60s → 45s。client 轮询上限约 66s,60s 的 host 预算只留 6s 余量给
|
|
929
|
+
// 「终态写入 + 下一次 describe」,跑满就会撞上假超时(审计 #7)。
|
|
930
|
+
const MODEL_TEST_TOTAL_MS = 45000;
|
|
900
931
|
const controller = new AbortController();
|
|
901
932
|
const deadline = Date.now() + MODEL_TEST_TOTAL_MS;
|
|
902
933
|
const inner = llm.stream({ provider, model, messages: [{ role: 'user', content: [{ type: 'text', text: prompt || '你好' }] }], maxTokens: maxTokens || 512, sessionId: 'mcm-modeltest-' + Date.now(), signal: controller.signal });
|
|
@@ -972,6 +1003,55 @@ export function apply(ctx, config) {
|
|
|
972
1003
|
await closeInner();
|
|
973
1004
|
}
|
|
974
1005
|
}
|
|
1006
|
+
// 启动清扫(审计 R3):新进程里任何「status running 但没有 finishedAt」的测试条目
|
|
1007
|
+
// 必然是上一次进程遗留的(进程内在飞任务都有 fiber 生命周期,重载会打断它们,
|
|
1008
|
+
// 旧闭包的收尾写入往往以 "Configuration entry is no longer available" 失败)。
|
|
1009
|
+
// 补写 ABORTED 终态,客户端就不必等到 66s 假超时。
|
|
1010
|
+
// 同时修复「running + finishedAt」的僵尸条目(status 被并发整树回写改坏),
|
|
1011
|
+
// 按 ok 归一状态位。
|
|
1012
|
+
const sweepStaleTestResults = () => {
|
|
1013
|
+
if (bus === null) return;
|
|
1014
|
+
const all = healthOf().testResults || {};
|
|
1015
|
+
const { aborted, repaired } = selectStaleTestEntries(all);
|
|
1016
|
+
for (const key of aborted) {
|
|
1017
|
+
bus.writeHealthLeaf(['testResults', key], Object.assign({}, all[key], {
|
|
1018
|
+
status: 'error', code: 'ABORTED',
|
|
1019
|
+
error: 'host 在测试执行期间重启/重载,结果未写入;请重试',
|
|
1020
|
+
finishedAt: Date.now(),
|
|
1021
|
+
})).catch((err) => console.warn('[model-channel-manager] stale test result sweep failed:', err && err.message));
|
|
1022
|
+
}
|
|
1023
|
+
for (const key of repaired) {
|
|
1024
|
+
bus.writeHealthLeaf(['testResults', key], Object.assign({}, all[key], { status: all[key].ok === true ? 'ok' : 'error' }))
|
|
1025
|
+
.catch((err) => console.warn('[model-channel-manager] zombie test result repair failed:', err && err.message));
|
|
1026
|
+
}
|
|
1027
|
+
if (aborted.length > 0 || repaired.length > 0)
|
|
1028
|
+
console.log('[model-channel-manager] test results swept after restart: aborted =', aborted.length, ', repaired =', repaired.length);
|
|
1029
|
+
};
|
|
1030
|
+
// 兜底重试(审计 R2):testRequest 存在、却既没被认领也没结算 —— 典型是 loader 的
|
|
1031
|
+
// volatile 提交被静默拒绝(document 落盘、fiber 引用不更新、不发 volatile-update),
|
|
1032
|
+
// 请求会被彻底忽略。这里每个 nonce 主动重试一次并留下日志,不再无声失败。
|
|
1033
|
+
const sweepPendingTestRequest = () => {
|
|
1034
|
+
if (bus === null) return;
|
|
1035
|
+
const health = healthOf();
|
|
1036
|
+
const req = health.testRequest;
|
|
1037
|
+
const key = nonceKey(req && req.nonce);
|
|
1038
|
+
if (key === null) return;
|
|
1039
|
+
const entry = (health.testResults || {})[key];
|
|
1040
|
+
const hasTerminal = !!(entry && (entry.finishedAt || entry.status === 'ok' || entry.status === 'error'));
|
|
1041
|
+
// 复用与消费路径同一个(已被测试覆盖的)判定:返回 true = 这个请求还没被消费
|
|
1042
|
+
const needsSweep = shouldConsumeNonce({
|
|
1043
|
+
consumed: consumedTestNonces,
|
|
1044
|
+
key,
|
|
1045
|
+
claimed: nonceKey(claimedTestNonce),
|
|
1046
|
+
settled: settledNonceKey(health.lastTestHandledNonce),
|
|
1047
|
+
hasTerminal,
|
|
1048
|
+
});
|
|
1049
|
+
if (!needsSweep || pendingSweepAttempted.has(key)) return;
|
|
1050
|
+
markConsumed(pendingSweepAttempted, key);
|
|
1051
|
+
console.warn('[model-channel-manager] testRequest nonce', key,
|
|
1052
|
+
'既未结算也未消费——疑似 volatile 提交被静默拒绝,主动重试一次(若仍无结果,请看宿主 stdout 的 loader 警告)');
|
|
1053
|
+
reloadFromConfig(false);
|
|
1054
|
+
};
|
|
975
1055
|
function handleTestRequest(next) {
|
|
976
1056
|
const req = next.testRequest;
|
|
977
1057
|
// nonce 接受 number(现行 client 的随机 53-bit 整数)与 string(0.3.2–0.3.8 的 UUID)
|
|
@@ -985,9 +1065,26 @@ export function apply(ctx, config) {
|
|
|
985
1065
|
// 终态判定用 finishedAt(写入后不会被覆盖),不只信 status:
|
|
986
1066
|
// 整树快照回写曾把 status 改回 running 却保留 finishedAt/code/error(0.3.11 事故)。
|
|
987
1067
|
const hasTerminal = !!(entry && (entry.finishedAt || entry.status === 'ok' || entry.status === 'error'));
|
|
988
|
-
if (!req || typeof req.provider !== 'string' || typeof req.model !== 'string'
|
|
989
|
-
|
|
1068
|
+
if (!req || typeof req.provider !== 'string' || typeof req.model !== 'string' || reqKey === null) {
|
|
1069
|
+
// 字段非法:显式记一次,别再静默吞掉(审计 R2)
|
|
1070
|
+
if (req && reqKey !== null && !warnedTestNonces.has(reqKey)) {
|
|
1071
|
+
markConsumed(warnedTestNonces, reqKey);
|
|
1072
|
+
console.warn('[model-channel-manager] testRequest 字段非法,已忽略(需要 provider/model/nonce):', JSON.stringify(req).slice(0, 200));
|
|
1073
|
+
}
|
|
990
1074
|
return;
|
|
1075
|
+
}
|
|
1076
|
+
if (Date.now() < testBackoffUntil) return; // 退避窗口内不消费(等重放)
|
|
1077
|
+
if (!shouldConsumeNonce({ consumed: consumedTestNonces, key: reqKey, claimed: claimedKey, settled, hasTerminal })) {
|
|
1078
|
+
// 未消费的显式解释:请求存在但既没被认领、也没终态 → 多半是 volatile 提交被
|
|
1079
|
+
// loader 静默拒绝(document 已落盘、fiber 引用不更新、不发 volatile-update,
|
|
1080
|
+
// 审计 R2),此时请求会被彻底忽略且没有任何可见报错。
|
|
1081
|
+
if (!hasTerminal && reqKey !== claimedKey && !warnedTestNonces.has(reqKey)) {
|
|
1082
|
+
markConsumed(warnedTestNonces, reqKey);
|
|
1083
|
+
console.warn('[model-channel-manager] testRequest 未被消费: nonce', reqKey,
|
|
1084
|
+
'(已结算或已消费命中)——若本次测试始终没有结果,检查宿主 stdout 是否有 loader 的 "volatile config update failed"');
|
|
1085
|
+
}
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
991
1088
|
markConsumed(consumedTestNonces, reqKey);
|
|
992
1089
|
claimedTestNonce = reqNonce;
|
|
993
1090
|
// nonce 的落盘推迟到本次测试得出结论之后:只有「真跑过」才算 handled。
|
|
@@ -1037,6 +1134,8 @@ export function apply(ctx, config) {
|
|
|
1037
1134
|
// 凭据服务尚未就绪(典型是启动瞬间的重放):不写成假 error,释放认领并延后重试
|
|
1038
1135
|
claimedTestNonce = null;
|
|
1039
1136
|
releaseConsumed(consumedTestNonces, reqKey); // 释放后才能靠重放再消费
|
|
1137
|
+
releaseConsumed(warnedTestNonces, reqKey);
|
|
1138
|
+
testBackoffUntil = Date.now() + 5000; // 退避窗口:期间任何 volatile-update 都不再消费
|
|
1040
1139
|
scheduleReplay('test', () => reloadFromConfig(false));
|
|
1041
1140
|
return;
|
|
1042
1141
|
}
|
|
@@ -1078,6 +1177,8 @@ export function apply(ctx, config) {
|
|
|
1078
1177
|
// digest:写进 settings health 子树的小投影(client 健康页渲染用)。
|
|
1079
1178
|
// 聚合上移 host,client 不再拉原始流水全量 describe——settings 里不再出现 records。
|
|
1080
1179
|
let digestTimer = null;
|
|
1180
|
+
let lastDigestJson = null; // 上次落盘的 digest 内容(相同则跳过,减少 revision 噪声)
|
|
1181
|
+
let lastDigestWriteAt = 0;
|
|
1081
1182
|
const DIGEST_INTERVAL = 5000;
|
|
1082
1183
|
const buildDigest = () => {
|
|
1083
1184
|
// 与旧 client HealthPanel 聚合口径一致:total/success/ttft/latency/token(计费口径
|
|
@@ -1121,7 +1222,16 @@ export function apply(ctx, config) {
|
|
|
1121
1222
|
const flushDigest = () => {
|
|
1122
1223
|
digestTimer = null;
|
|
1123
1224
|
if (bus !== null) {
|
|
1124
|
-
|
|
1225
|
+
const digest = buildDigest();
|
|
1226
|
+
// 0.3.14:内容没变就不写。settings 行的 revision 是 raw config 的 JSON 指纹
|
|
1227
|
+
// (dsh-settings describe:revision += raw !== previous.raw),每一次无意义的写
|
|
1228
|
+
// 都会让 client 手里的 revision 过期,表现为保存时的 settings/conflict。
|
|
1229
|
+
// 60s 心跳保证 digestAt 不会永远停住。
|
|
1230
|
+
const json = JSON.stringify(digest);
|
|
1231
|
+
if (json === lastDigestJson && Date.now() - lastDigestWriteAt < 60000) return;
|
|
1232
|
+
lastDigestJson = json;
|
|
1233
|
+
lastDigestWriteAt = Date.now();
|
|
1234
|
+
bus.writeHealth({ digest, digestAt: Date.now() }).catch(() => { });
|
|
1125
1235
|
}
|
|
1126
1236
|
};
|
|
1127
1237
|
const scheduleDigest = () => {
|
|
@@ -1189,9 +1299,16 @@ export function apply(ctx, config) {
|
|
|
1189
1299
|
// 回归见 tests/task-dedup.test.cjs。
|
|
1190
1300
|
let claimedTestNonce = null; // string UUID 或旧数字;null = 未认领
|
|
1191
1301
|
let claimedSpeedNonce = null; // string UUID 或旧数字;null = 未认领
|
|
1302
|
+
let lastHealthKeyCount = -1; // health 子树键数(用于探测「被兜底清空」,审计 R2/#4)
|
|
1192
1303
|
const consumedTestNonces = new Set(); // 已消费过的 nonce 字符串键(含本次)
|
|
1193
1304
|
const consumedSpeedNonces = new Set();
|
|
1305
|
+
const warnedTestNonces = new Set(); // 每个 nonce 只提醒一次(防日志刷屏)
|
|
1306
|
+
const pendingSweepAttempted = new Set(); // 兜底重试每个 nonce 只做一次
|
|
1194
1307
|
const CONSUMED_NONCE_CAP = 256; // 上限:只用于防重复消费,不需要无限历史
|
|
1308
|
+
// notReady(凭据服务未就绪)释放认领后的退避窗口:期间不再消费,等 scheduleReplay 到点。
|
|
1309
|
+
// 否则释放后任何一次 volatile-update 都会立刻再消费,并发起多个 60s 上游请求(审计 #6)。
|
|
1310
|
+
let testBackoffUntil = 0;
|
|
1311
|
+
let speedBackoffUntil = 0;
|
|
1195
1312
|
const markConsumed = (set, key) => {
|
|
1196
1313
|
set.add(key);
|
|
1197
1314
|
if (set.size > CONSUMED_NONCE_CAP) {
|
|
@@ -1223,6 +1340,13 @@ export function apply(ctx, config) {
|
|
|
1223
1340
|
const cfg = cfgOf();
|
|
1224
1341
|
state.config = clone(cfg) || { groups: [] };
|
|
1225
1342
|
const health = healthOf();
|
|
1343
|
+
// 意外清空告警(审计 R2/#4):health 子树本来有内容,运行时却变成空对象 =
|
|
1344
|
+
// schema 校验失败被 .loose(true) 兜底(0.3.11 事故形态),必须大声报出来。
|
|
1345
|
+
const healthKeys = Object.keys(health || {}).length;
|
|
1346
|
+
if (healthKeys === 0 && lastHealthKeyCount > 0) {
|
|
1347
|
+
console.warn('[model-channel-manager] health 子树在运行时变空(上一轮有 ' + lastHealthKeyCount + ' 个键)——极可能是 schema 校验失败被 .loose(true) 静默兜底,请检查最近一次写入的字段类型');
|
|
1348
|
+
}
|
|
1349
|
+
lastHealthKeyCount = healthKeys;
|
|
1226
1350
|
state.records = healthStore !== null
|
|
1227
1351
|
? clone(healthStore.allEventBuckets())
|
|
1228
1352
|
: clone(health.records || {});
|
|
@@ -1247,6 +1371,7 @@ export function apply(ctx, config) {
|
|
|
1247
1371
|
const speedKey = nonceKey(req && req.nonce);
|
|
1248
1372
|
const speedClaimed = nonceKey(claimedSpeedNonce);
|
|
1249
1373
|
const speedSettled = settledNonceKey(health.lastHandledNonce);
|
|
1374
|
+
if (req && typeof req.group === 'string' && Date.now() < speedBackoffUntil) return;
|
|
1250
1375
|
if (req && typeof req.group === 'string'
|
|
1251
1376
|
&& shouldConsumeNonce({ consumed: consumedSpeedNonces, key: speedKey, claimed: speedClaimed, settled: speedSettled, hasTerminal: false })) {
|
|
1252
1377
|
markConsumed(consumedSpeedNonces, speedKey);
|
|
@@ -1263,6 +1388,7 @@ export function apply(ctx, config) {
|
|
|
1263
1388
|
if (r && r.deferred) {
|
|
1264
1389
|
claimedSpeedNonce = null;
|
|
1265
1390
|
releaseConsumed(consumedSpeedNonces, speedKey);
|
|
1391
|
+
speedBackoffUntil = Date.now() + 5000; // 退避:别让 volatile-update 立刻再消费
|
|
1266
1392
|
scheduleReplay('speed', () => reloadFromConfig(false));
|
|
1267
1393
|
return;
|
|
1268
1394
|
}
|
|
@@ -1272,6 +1398,7 @@ export function apply(ctx, config) {
|
|
|
1272
1398
|
if (notReady(e)) {
|
|
1273
1399
|
claimedSpeedNonce = null;
|
|
1274
1400
|
releaseConsumed(consumedSpeedNonces, speedKey);
|
|
1401
|
+
speedBackoffUntil = Date.now() + 5000; // 退避:同上
|
|
1275
1402
|
scheduleReplay('speed', () => reloadFromConfig(false));
|
|
1276
1403
|
return;
|
|
1277
1404
|
}
|
|
@@ -1321,11 +1448,16 @@ export function apply(ctx, config) {
|
|
|
1321
1448
|
const writeHealthOps = (ops) => enqueueHealthWrite(async () => outsideTransaction(() => settings.mutate(SELF_NS, ops, undefined)));
|
|
1322
1449
|
// 单叶写入:把 value 精确写到 health.<path...>,不读、不带任何快照
|
|
1323
1450
|
const writeHealthLeaf = (path, value) => enqueueHealthWrite(async () => outsideTransaction(() => settings.mutate(SELF_NS, [{ op: 'set', path: ['health', ...path], value }], undefined)));
|
|
1451
|
+
// 行级配置写入(groups 等):也必须切出 HMR 事务——migrateLegacyConfig 可能在
|
|
1452
|
+
// loader 回调链里被调用,事务内直写会抛 "HMR transactions cannot be nested"
|
|
1453
|
+
// 并被空 catch 吞掉,只剩哨兵、旧配置永久不迁(审计 #13)。
|
|
1454
|
+
const writeConfig = (patch) => enqueueHealthWrite(async () => outsideTransaction(() => settings.update(SELF_NS, patch, undefined)));
|
|
1324
1455
|
bus = {
|
|
1325
1456
|
settings,
|
|
1326
1457
|
writeHealth,
|
|
1327
1458
|
writeHealthOps,
|
|
1328
1459
|
writeHealthLeaf,
|
|
1460
|
+
writeConfig,
|
|
1329
1461
|
cfgScope: { get: () => cfgOf() },
|
|
1330
1462
|
healthScope: { get: () => healthOf() },
|
|
1331
1463
|
};
|
|
@@ -1334,6 +1466,8 @@ export function apply(ctx, config) {
|
|
|
1334
1466
|
const fiberAlive = () => ctx.fiber !== undefined && ctx.fiber.uid !== undefined && ctx.fiber.uid === fiberUid;
|
|
1335
1467
|
// 启动不等待 domain:先用现有 settings 数据把路由与健康页带起来(旧 records 尚在
|
|
1336
1468
|
// settings 时也能立即显示);domain 就绪后再以它为权威重载。
|
|
1469
|
+
// 清扫必须在首次消费 testRequest 之前:新进程里已存在的 running 条目都是上次遗留的。
|
|
1470
|
+
sweepStaleTestResults();
|
|
1337
1471
|
reloadFromConfig(true);
|
|
1338
1472
|
ctx.on('loader/volatile-update', () => {
|
|
1339
1473
|
reloadFromConfig(false);
|
|
@@ -1341,6 +1475,11 @@ export function apply(ctx, config) {
|
|
|
1341
1475
|
});
|
|
1342
1476
|
boot();
|
|
1343
1477
|
scheduleDigest(); // 首屏投影:让健康页不用等下一次真实请求
|
|
1478
|
+
// 未消费请求的兜底轮询(审计 R2):每 5s 看一眼 testRequest 是否被静默丢弃
|
|
1479
|
+
ctx.effect(() => {
|
|
1480
|
+
const timer = setInterval(() => { try { sweepPendingTestRequest(); } catch (_e) { } }, 5000);
|
|
1481
|
+
return () => clearInterval(timer);
|
|
1482
|
+
}, 'model-channel pending-request sweep');
|
|
1344
1483
|
// storageDomain 是可选服务,必须经响应式 inject 取得「已声明依赖」的上下文:
|
|
1345
1484
|
// 直接 ctx.get('storageDomain') 只能拿到未注入的裸服务,随后任何属性访问都会被
|
|
1346
1485
|
// Cordis 代理拒绝(实测 "cannot get property storageDomain without inject",
|
|
@@ -1479,7 +1618,7 @@ export function apply(ctx, config) {
|
|
|
1479
1618
|
return;
|
|
1480
1619
|
}
|
|
1481
1620
|
if (migrated.groups.length > 0) {
|
|
1482
|
-
await bus.
|
|
1621
|
+
await bus.writeConfig({ groups: migrated.groups });
|
|
1483
1622
|
state.config = { groups: migrated.groups };
|
|
1484
1623
|
console.log('[model-channel-manager] migrated legacy config from workspace .channel-manager/config.json');
|
|
1485
1624
|
}
|