@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 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.12 审计已确认、尚未修(按严重度)
235
-
236
- 均为 2026-10-01 只读审计结论(13 项),0.3.12 先修了其中最要命的两条(去重 guard、整树回写):
237
-
238
- - **volatile 提交静默失败仍是放大器**:loader 的 `_commitVolatile` 在 `resolveConfig` 抛错时
239
- 只 `logger.warn` 后返回——document 已落盘、fiber 引用不更新、**不发 volatile-update**,
240
- 而宿主只在 volatile-update 里消费请求 → 请求被彻底忽略且无任何可见报错。当前 payload
241
- 校验通过,属潜在复发路径。建议:宿主加「未结算 nonce」兜底轮询 + 未消费显式日志。
242
- - **remount/dispose 会打断在飞的测试**:超时守卫挂在 `ctx.effect` 上,dispose 只 `clearTimeout`,
243
- promise 永不 reject → `runModelTest` 挂死 → 终态永不写入(旧闭包的排队写入只进
244
- `console.error`)。建议:守卫不挂 fiber effect,dispose 时补写 ABORTED 终态。
245
- - **60s(host)/66s(client)预算错配**:跑满 60s 时终态写入稍晚于 client giveUp。建议 host 收到
246
- 45s 或 client 放弃前比较 `lastTestHandledNonce` 再补一轮。
247
- - **health 写不带 `expectedRevision`**(配置域已带):叶写后危害大幅降低(不同键互不覆盖),
248
- 但同一叶子仍无冲突检测。
249
- - 低危:`digest: []` 空数组会遮蔽 settings 里的旧 `records`;`migrateLegacyConfig` 直写未走
250
- `outsideTransaction`(在事务内会抛 "cannot be nested" 并被空 catch 吞掉);health-store 的
251
- `eventKey = ts|provider|model` 会把同毫秒同渠道两笔当重复合并。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcaneorion/dsh-model-channel-manager",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
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 侧测试超时 60s,约 55 次 × 1.2s ≈ 66s 后放弃,避免结果被覆盖时无限轮询。
570
- // 0.3.12:放弃时区分「host 没写结果」与「host 还在跑」,不再一律甩锅「未处理/需重启」。
571
- const giveUp = (e) => setTestStates((s) => Object.assign({}, s, { [key]: (e && e.status === 'running')
572
- ? { status: 'error', code: 'POLL_TIMEOUT_RUNNING', error: '测试仍在执行(约 66 秒未返回结果):host 已收到请求,可能是渠道慢或上游挂起;稍后可在健康统计里核对' }
573
- : { status: 'error', code: 'POLL_TIMEOUT', error: '等待测试结果超时:host 未写入结果(可能 host 未重启到新版,或结果写入失败)' } }))
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
- giveUp(e)
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
- const digestRows = Array.isArray(health.digest) ? health.digest : null
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 保存——llm-pi-ai 是独立 settings 行,冲突由宿主以
1649
- // settings/conflict 拒绝;提示里给「刷新后重试」指引而不是静默覆盖
1650
- return apiRef.settings.mutate({ ns: 'llm-pi-ai', ops, expectedRevision: (state && state.nsRevisions && state.nsRevisions.piAi) || undefined }).then((resp) => {
1651
- const r = resp && resp.result ? resp.result : resp
1652
- if (r && r.ok === false) {
1653
- const conflict = r.error && (r.error.code === 'settings/conflict' || /conflict/i.test(String(r.error.message || '')))
1654
- throw new Error(conflict ? 'llm-pi-ai 配置已被其他页面修改(版本冲突),请点「刷新」后重试' : ((r.error && (r.error.message || r.error)) || 'llm-pi-ai save failed'))
1655
- }
1656
- return r
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 p2 = apiRef.settings.update({
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
- patch: Object.assign({ groups: (channelsDraft || []).map((g) => Object.assign({}, g, { virtualModel: Object.assign({}, g.virtualModel, { name: g.id }) })) }, orderPatch)
1668
- }).then((resp) => {
1669
- const r = resp && resp.result ? resp.result : resp
1670
- if (r && r.ok === false) {
1671
- const conflict = r.error && (r.error.code === 'settings/conflict' || /conflict/i.test(String(r.error.message || '')))
1672
- throw new Error(conflict ? '轮询组配置已被其他页面修改(版本冲突),请点「刷新」后重试' : ((r.error && (r.error.message || r.error)) || 'model-channels save failed'))
1673
- }
1674
- return r
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
  // 掩盖已提交的那半。各自报告,失败域给出明确指引;部分成功时提示里列明
@@ -67,8 +67,17 @@ const healthDomainSpec = defineDomain({
67
67
  },
68
68
  });
69
69
 
70
- /** 事件去重键:同一笔在 settings 与 domain 之间合并时不重复计。 */
71
- const eventKey = (e) => `${e.ts}|${e.provider}|${e.model}`;
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.speed.put(groupId, { rows });
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
- if (this.speed.get(groupId) !== undefined) continue;
191
- await this.speed.put(groupId, { rows });
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 dispose = ctx.effect(() => {
116
- const timer = setTimeout(() => rejectFn(makeError()), ms);
117
- return () => clearTimeout(timer);
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 同一模式):总时限 60s(旧行为每 chunk 重置 guard,
926
+ // F15(与 measureCandidate 同一模式):总时限 45s(旧行为每 chunk 重置 guard,
898
927
  // 持续输出的请求永不超时而 client 已放弃)+ 超时 abort 上游 + 统一 finally 关闭。
899
- const MODEL_TEST_TOTAL_MS = 60000;
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
- || !shouldConsumeNonce({ consumed: consumedTestNonces, key: reqKey, claimed: claimedKey, settled, hasTerminal }))
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
- bus.writeHealth({ digest: buildDigest(), digestAt: Date.now() }).catch(() => { });
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.settings.update(SELF_NS, { groups: migrated.groups });
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
  }