@arcaneorion/dsh-model-channel-manager 0.3.8 → 0.3.13

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
@@ -95,7 +95,12 @@ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
95
95
  - **降级**:storageDomain 缺席的 profile 退化为纯内存(不持久化流水),不拒绝启动。
96
96
  - **client 兼容**:旧 host(无 digest 字段)自动回落原始 records 路径,升级窗口不断供。
97
97
  - **已知近似**:30m/24h 视图按「最近活跃渠道」过滤,数值仍是 7 天累计(UI 已标注);
98
- 精确分窗口需 host 出多份 digest,后续增强。
98
+ 精确分窗口需 host 出多份 digest,后续增强。健康页默认 7d,避免近 30m 冷窗让用户
99
+ 误以为历史数据丢失。
100
+ - **混部 nonce 兼容**:测试/测速 nonce 使用随机安全整数(host schema 为 number),
101
+ 避免 UUID client 遇到未重启旧 host 时被旧版 `typeof nonce === 'number'` 静默忽略;
102
+ 新 host 同时兼容旧数字与字符串 nonce。使用 `crypto.getRandomValues`,fallback 为
103
+ 时间戳×1000 + 同毫秒计数。
99
104
 
100
105
  ## 响应信封(重要)
101
106
 
@@ -121,11 +126,37 @@ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
121
126
 
122
127
  - 模型行「⚡测试」→ 弹窗输入自定义问题 + maxTokens → 发送
123
128
  - prompt 存 localStorage(`mcm_test_prompt`,pi 同款,全局共用)
124
- - host 用 `llm.stream({provider, model, messages, maxTokens})` 真实调用(与正式对话同链路);60s 超时
129
+ - host 用 `llm.stream({provider, model, messages, maxTokens})` 真实调用(与正式对话同链路);45s 总时限(0.3.13 由 60s 下调,给终态写入留余量)
125
130
  - 结果:`status:'ok'`(ttftMs/latencyMs/text)或 `status:'error'`(code/error)
126
131
  - 模型行内显示 ⏳→✓/✗ 状态标签(hover 见详情)
127
132
 
128
- > 注意:此通道依赖 host 半新代码。**旧 host(未重启)无 testRequest 处理器**,测试会一直「请求中」——client 现在约 66s 后超时报 `POLL_TIMEOUT` 并提示 host 未处理(不再无限轮询)。
133
+ > 注意:此通道依赖 host 半新代码。**旧 host(未重启)无 testRequest 处理器**,测试会一直「请求中」——client 现在约 66s 后超时,并按现场区分提示:`POLL_TIMEOUT_RUNNING`(host 已收到、仍在执行:渠道慢或上游挂起)或 `POLL_TIMEOUT`(host 未写入结果:多为旧 host 未重启或写入失败)。
134
+ >
135
+ > 0.3.12 起终态判定同时接受 `finishedAt`:即使条目的 `status` 被并发写坏成 `running`,只要带 `finishedAt`/`code`/`error` 就按终态显示真实上游错误,不再误报「host 未处理」。
136
+
137
+ ## 任务通道完整性(0.3.12 重构:去重与写入语义)
138
+
139
+ ## 任务通道完整性(0.3.12 重构:去重与写入语义)
140
+
141
+ 0.3.11 线上症状「等待测试结果超时:host 可能未处理该请求」的两个真实成因,均在 0.3.12 修掉:
142
+
143
+ - **去重不许做数值推断**:`handleTestRequest` / `reloadFromConfig`(测速)改用纯函数
144
+ `shouldConsumeNonce()`(导出,可在测试里直接断言判定表)——严格等值 + 已消费集合 +
145
+ 已结算值 + 已有终态结果,四者任一命中即不消费。旧实现 `last = Math.max(lastTestHandledNonce,
146
+ claimedNonce)` 对随机 53-bit nonce 有 ≈50% 失效率(「上一轮 > 本轮」即失效),
147
+ 于是同一请求在每次 `loader/volatile-update`(含宿主自己每 5s 的 digest 回写)都被重新消费:
148
+ 重复上游请求(真实计费)+ 多次执行结果写进同一条目(线上物证:同 nonce 同时带
149
+ `code TIMEOUT / 60000ms` 与 `ok:true / text`,单次执行不可能)。
150
+ - **health 子树一律叶写**:`writeHealth` 由「读整树 → 合并 → 整树写回」改为对 patch 命中的
151
+ 顶层键逐个 `set`(path-ops),单键结果用 `writeHealthLeaf(['testResults', nonce], value)`;
152
+ client 侧 shim 同样不再 `describe` 快照 + 整树回写,直接下推 path-ops。整树回写的 payload 是
153
+ 「调用时刻的快照」,晚于并发的终态落盘时会把 `status` 改回 `running`,而 `update` 的深合并
154
+ 保留后写入的 `finishedAt/code/error` → 僵尸条目 → 客户端永远等不到终态。
155
+ - client 终态判定加 `finishedAt`(写入后不可抹掉),并把 `ok` 归一成 `status` 显示真实错误。
156
+ - 降级 nonce 路径改用安全整数随机数(旧 `Date.now()*1000+counter ≈ 1.8e18` 超出
157
+ `MAX_SAFE_INTEGER`,同毫秒可撞值;strict consume-once 下撞值 = 测试被静默丢弃)。
158
+
159
+ 回归:`tests/task-dedup.test.cjs`(判定表用真实导出,不复制实现)、`tests/testresult-fallback.test.cjs`(叶写语义 + 事故复现:终态后并发叶写不得改回 running)。
129
160
 
130
161
  ## 拉取上游(模型选择)
131
162
 
@@ -200,6 +231,38 @@ react 经 `require('react')`;样式用 `ctx.effect` 自管理;`dsh.client: {
200
231
  - 30m/24h 健康视图是近似口径(按最近活跃过滤,数值为 7 天累计,UI 已标注);精确分窗口
201
232
  需 host 出多份 digest
202
233
 
234
+ ### 0.3.13:2026-10-01 审计剩余项(已修)
235
+
236
+ 同一份只读审计(13 项)里,0.3.12 修了最要命的两条(去重 guard、整树回写),0.3.13 清掉其余:
237
+
238
+ - **R3 remount/dispose 打断在飞测试**:`timed()` 守卫不再只在 dispose 时 `clearTimeout`——
239
+ 那样 promise 永不 settle,`await Promise.race([inner.next(), guard.promise])` 直接挂死,
240
+ 在飞测试既不结束也不写终态。现在 dispose 会主动 reject `ABORTED`,调用方走正常失败路径。
241
+ 另外**启动时清扫**:新进程里任何 `status: running` 且无 `finishedAt` 的条目都是上次遗留的,
242
+ 补写 `ABORTED` 终态;`running + finishedAt` 的僵尸条目按 `ok` 归一状态位
243
+ (纯函数 `selectStaleTestEntries` 导出,回归见 `tests/runtime-hardening.test.cjs`)。
244
+ - **R2 volatile 提交静默失败(放大器)**:请求存在但既未认领也未结算时,
245
+ ① `handleTestRequest` 显式打日志(每个 nonce 一次,不再静默忽略);
246
+ ② 新增 5s 兜底轮询 `sweepPendingTestRequest`,复用同一个消费判定,主动重试一次并留日志;
247
+ ③ `reloadFromConfig` 检测「health 子树运行时从有到无」并大声告警——这是 0.3.11
248
+ schema 兜底事故的形态,以前完全无声。
249
+ - **#7 预算错配**:host 测试总时限 60s → 45s(client 轮询上限 ≈66s,旧值只留 6s 余量);
250
+ client 放弃时还会比对 `lastTestHandledNonce`,区分第三种情况
251
+ `POLL_TIMEOUT_SETTLED`(host 已结算但结果条目未写入)。
252
+ - **#6 notReady 退避**:释放认领后设 5s 退避窗口(测试/测速各自),期间任何
253
+ volatile-update 都不再消费——旧实现释放后会被立刻再消费,并发起多个 45s 上游请求。
254
+ - **#13 迁移直写**:`migrateLegacyConfig` 改走 `bus.writeConfig`(`outsideTransaction` 包裹),
255
+ 不再在 HMR 事务内直写(旧写法会抛 "cannot be nested" 并被空 catch 吞掉,只剩哨兵)。
256
+ - **#11 digest 遮蔽**:`digest: []` 且 settings 仍有 `records` 时(domain 打开成功但迁移失败、
257
+ 或 host 刚重启尚未投影)回落到 records 路径,页面显示旧流水而不是全 0。
258
+ - **#10 health-store**:事件去重键由 `ts|provider|model` 扩成含 ok/code/延迟/token 的指纹
259
+ (同毫秒同渠道的两笔真实请求不再被当重复合并);`putSpeedRows` 与 `migrateFrom` 的测速写入
260
+ 并入同一条写链(不与事件追加交错);`close()` 先排干写链再关 domain(不丢排队中的最后一笔)。
261
+
262
+ **仍然未修(有意保留)**:health 子树写入不带 `expectedRevision`。叶写后危害已大幅降低
263
+ (不同叶子互不覆盖),同一叶子的并发写本身是「后写者胜」的语义,加 revision 需要调用方
264
+ 持有并维护 revision,收益不抵复杂度;配置域(groups/providerOrder)仍然带 revision。
265
+
203
266
  ## 引擎超时与生命周期(0.3.3 重构:审计 F01/F02/F15 已修)
204
267
 
205
268
  - **per-attempt AbortController**:每次候选尝试独立 signal,用户取消转发(`relayAbort`,
@@ -243,3 +306,16 @@ react 经 `require('react')`;样式用 `ctx.effect` 自管理;`dsh.client: {
243
306
  23. **整字段落盘 + 内存态被配置快照覆盖**(0.3.1 已根治):`settings.update` 是整字段替换;旧实现 `reloadFromConfig()` 每次 volatile-update 都用配置快照整体覆盖 `state.records`,而健康 flush 有 2s 防抖 → **刚记下的一笔在落盘前就被内存覆盖**(症状:面板“测试”成功不入账,失败反被全局拦截器的 catch 记上)。当时的修法是 `pendingRecords` 缓冲补账;0.3.1 起权威数据搬入 storageDomain(见「健康数据存储」),settings 只存 digest 小投影,此竞态从数据模型层消除。
244
307
  24. **nonce 落盘时机与启动竞态**:`lastTestHandledNonce` / `lastHandledNonce` 必须在**得出结果之后**写(提前写会让“未就绪”的重试被自己的持久值挡掉);启动瞬间凭据服务尚未就绪时测试/测速会以 `MISSING_CREDENTIAL` 失败(凭据其实已在 `.credentials.yaml` 里),应识别为「还没就绪」→ 释放认领 + 5s 延时重试(上限 24 次),**不要**写成渠道故障;测速还必须在整组候选都因未就绪失败时**不落盘、不冷却**,否则一次启动重放就把所有渠道误判成故障。
245
308
  25. **domain 写入的并发丢失(0.3.1 review 挽救)**:`KvTable.put` 是整 record 覆盖,`get→filter→put` 的读-改-写在 put 的 IO 延迟窗口内并发调用会互相覆盖(后写盖先写,先记的账丢失)——恰好复刻了要消灭的丢账问题。**并发追加必须走原子链**:`update(key, fn)` 的 fn 在写链队列槽位看到当前值;桶不存在时 update 报 `missing-key`,先 put 初始化。另外在 promise 链里 `ctx.effect` 注册 disposer 前必须先验 fiber 活性——对 inactive fiber 注册会抛 `INACTIVE_EFFECT`,若被外层 catch 吞掉则 domain 永不 close,facility 名字被占 → HMR 重载后 `already-open` 静默降级。回归:`tests/health-domain-sync.test.cjs`。
309
+ 26. **`ctx.get(name)` 是宽松读,不代表可以访问服务(0.3.10 线上事故)**:Cordis 的服务属性访问经 Proxy 校验 inject——`ctx.get('storageDomain')` 直接查 store 返回裸服务(不校验 state/inject),但拿着这个未声明的 ctx 做 `ctx.storageDomain.open(...)` 会抛 `cannot get property "storageDomain" without inject`;若被 promise 链的 catch 吞掉,表现为「插件启动正常、domain 永远不打开、健康流水静默降级为内存」。**可选服务必须用响应式 inject**:`ctx.inject(['storageDomain'], (dctx) => { ... dctx.storageDomain ... })`——回调只在服务可用(state=2)时触发,缺席时插件照常工作;同时把插件主启动(`reloadFromConfig` + `boot`)放在 inject **之外**先跑,避免服务缺席时路由不注册。回归:`tests/domain-wiring.test.cjs`。
310
+ 27. **`settings.update` 的深合并清不掉旧键(0.3.10 修)**:`update` 走 `mergeLayers`,`{records: {}}` 覆盖已有对象是**深合并**——旧键原样保留。0.3.8 的迁移「清空 settings 旧流水」实际没删,`records` 一直留在 profile 里,每次 `describe`(client 5s 轮询全量命名空间)都要带着它。要真删除必须走 path-ops:`settings.mutate(ns, [{op:'unset', path:['health','records']}, ...])`(`isVolatilePath` 对 volatile 子树的后代返回 true,允许操作)。回归:`tests/domain-wiring.test.cjs`。
311
+ 28. **schema 类型漂移会静默清空整个 volatile 子树(0.3.11 线上事故,自己埋的)**:0.3.2 把 client nonce 改成 UUID 字符串、host 消费代码也兼容了字符串,**但没同步改 schema**——`lastTestHandledNonce: z.number()`。字符串一落盘,`HEALTH_SCHEMA` 解析时子字段抛错;而它是 `.loose(true)`,宽松兜底把**整个 health 子树换成默认空对象**(实测 `healthOf()` 返回 `{}`,不报错、不告警)。后果:`records`/`digest` 在运行时全部消失 → 存量迁移遍历空对象(什么都不导入)→ 清理逻辑以为「无残留」(旧键删不掉)→ 表现为「健康页没有历史数据」且旧 `records` 永久占据 profile。三条教训:① **写侧放宽类型时,schema 必须同步放宽**(用 `z.union([z.number(), z.string()])` 兼容历史值);② **`.loose(true)` 的兜底是静默的**,volatile 子树里一个字段失配 = 整树数据不可见;③ **迁移标记不可信**——`migrateFrom` 改为「桶已存在时合并去重」而非整桶跳过,且「有存量必须先导入再清理」,这样即使 marker 被误写也能把旧账救回来。回归:`tests/health-schema-tolerance.test.cjs`。
312
+
313
+ 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`)。
314
+ 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 直接复刻事故时序)。
315
+
316
+ 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`。
317
+ 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`。
318
+
319
+ ## 0.3.11 修复后的自愈路径(无需手工清库)
320
+
321
+ 重启后: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.8",
3
+ "version": "0.3.13",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -56,7 +56,9 @@
56
56
  "patch": "./cordis.patch.yml"
57
57
  },
58
58
  "client": {
59
- "inject": ["slots"],
59
+ "inject": [
60
+ "slots"
61
+ ],
60
62
  "platform": "web"
61
63
  }
62
64
  }
package/src/client.js CHANGED
@@ -11,6 +11,24 @@ window.__ModuleLoader__.load({
11
11
  const { createElement: el, useState, useEffect, useRef, useSyncExternalStore } = require('react')
12
12
  let apiRef = null
13
13
  const savedKeys = {}
14
+ // 使用随机安全整数 nonce:host 的 lastTestHandledNonce/lastHandledNonce 是 number。
15
+ // UUID 字符串虽被新 host 接受,但旧 host(滚动升级/未重启窗口)只校验 number,
16
+ // 会把请求静默忽略,client 最终提示「host 未处理」。随机 53-bit 整数同时避免
17
+ // Date.now()%1e9 同毫秒碰撞,并保持与旧 host 的 wire schema 兼容。
18
+ let nonceFallbackCounter = 0
19
+ const createNonce = () => {
20
+ if (typeof crypto !== 'undefined' && crypto && typeof crypto.getRandomValues === 'function') {
21
+ const words = new Uint32Array(2)
22
+ crypto.getRandomValues(words)
23
+ const n = (words[0] & 0x1fffff) * 0x100000000 + words[1]
24
+ return n === 0 ? 1 : n
25
+ }
26
+ // 降级路径:旧写法 Date.now()*1000+counter ≈ 1.8e18 > MAX_SAFE_INTEGER,
27
+ // 同毫秒内可能撞值(且 strict consume-once 去重下撞值 = 测试被静默丢弃)。
28
+ nonceFallbackCounter = (nonceFallbackCounter + 1) % 1000
29
+ const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
30
+ return n === 0 ? nonceFallbackCounter + 1 : n
31
+ }
14
32
 
15
33
  // dsh 0.2:客户端远程面由 connection.api 改为 ctx.remote;参数改为位置参数,结果统一为
16
34
  // RemoteResult。本适配层把两者还原成 0.1 的调用形状(对象入参 + {result:{ok,value}})。
@@ -19,11 +37,6 @@ window.__ModuleLoader__.load({
19
37
  const NS_MCM = 'model-channel-manager'
20
38
  const makeLegacyApi = (remote) => {
21
39
  const wrap = (res) => ({ result: res || { ok: false } });
22
- const readMcm = async () => {
23
- const res = await remote.settings.describe()
24
- if (!res || res.ok !== true) return null
25
- return ((res.value && res.value.namespaces) || []).find((n) => n && n.ns === NS_MCM) || null
26
- }
27
40
  return {
28
41
  settings: {
29
42
  describe: async () => {
@@ -45,45 +58,31 @@ window.__ModuleLoader__.load({
45
58
  const patch = (args && args.patch) || {}
46
59
  const expected = (args && args.expectedRevision !== undefined) ? args.expectedRevision : undefined
47
60
  if (ns === 'model-channels') return wrap(await remote.settings.update(NS_MCM, patch, expected))
48
- const row = await readMcm()
49
- const health = (row && row.value && row.value.health) || {}
50
- return wrap(await remote.settings.update(NS_MCM, { health: Object.assign({}, health, patch) }, expected))
61
+ // 0.3.12:health 子树改成「叶写」(path-ops),不再 describe 快照 + 整树回写。
62
+ // 旧写法在 read→write 的两次 RPC 之间有窗口:宿主刚写入的终态会被旧快照覆盖
63
+ // (线上事故:testResults[nonce].status 被改回 running,finishedAt/code/error 却保留,
64
+ // 客户端永远等不到终态 → 66s 后误报「host 未处理」)。
65
+ const ops = Object.entries(patch).map(([k, v]) => ({ op: 'set', path: ['health', k], value: v }))
66
+ if (ops.length === 0) return { result: { ok: true, value: null } }
67
+ return wrap(await remote.settings.mutate(NS_MCM, ops, expected))
51
68
  },
52
- // 旧签名:mutate({ ns, ops })——0.2 只对真实行命名空间提供 path-ops;
53
- // 本插件自身的健康子树用合并写实现等价语义。
69
+ // 旧签名:mutate({ ns, ops })——health 子树现在也走真实 path-ops(不再本地模拟整树写)
54
70
  mutate: async (args) => {
55
71
  const ns = (args && args.ns) || NS_MCM
56
72
  const ops = (args && args.ops) || []
57
73
  const expected = (args && args.expectedRevision !== undefined) ? args.expectedRevision : undefined
58
- if (ns !== 'model-channels' && ns !== 'model-channel-health' && ns !== NS_MCM) {
59
- return wrap(await remote.settings.mutate(ns, ops, expected))
60
- }
61
- const row = await readMcm()
62
- const health = Object.assign({}, (row && row.value && row.value.health) || {})
63
- const top = {
64
- groups: (row && row.value && row.value.groups) || [],
65
- providerOrder: (row && row.value && row.value.providerOrder) || [],
66
- }
74
+ if (ns === NS_MCM) return wrap(await remote.settings.mutate(NS_MCM, ops, expected))
75
+ const prefix = (ns === 'model-channels') ? [] : ['health']
76
+ const mapped = []
67
77
  for (const op of ops) {
68
78
  const path = (op && op.path) || []
69
- const root = (ns === 'model-channels') ? (path[0] === 'groups' ? top : (path[0] === 'providerOrder' ? top : health)) : health
70
79
  if (path.length === 0) continue
71
- const key = path[0]
72
- const rest = path.slice(1)
73
- if (rest.length === 0) {
74
- if (op.op === 'set') root[key] = op.value
75
- else delete root[key]
76
- } else if (rest.length === 1) {
77
- const child = Object.assign({}, root[key] || {})
78
- if (op.op === 'set') child[rest[0]] = op.value
79
- else delete child[rest[0]]
80
- root[key] = child
81
- }
82
- }
83
- if (ns === 'model-channels') {
84
- return wrap(await remote.settings.update(NS_MCM, { groups: top.groups, providerOrder: top.providerOrder }, expected))
80
+ // legacy 混合命名空间:groups/providerOrder 属于行顶层,其余视为 health 子键
81
+ const p = (ns === 'model-channels' && (path[0] === 'groups' || path[0] === 'providerOrder')) ? path : [...prefix, ...path]
82
+ mapped.push({ op: (op && op.op) || 'set', path: p, value: op && op.value })
85
83
  }
86
- return wrap(await remote.settings.update(NS_MCM, { health }, expected))
84
+ if (mapped.length === 0) return { result: { ok: true, value: null } }
85
+ return wrap(await remote.settings.mutate(NS_MCM, mapped, expected))
87
86
  },
88
87
  },
89
88
  credentials: {
@@ -567,18 +566,30 @@ window.__ModuleLoader__.load({
567
566
  const pollTest = (nonce, provider, model, attempt = 0) => {
568
567
  if (!apiRef) return
569
568
  const key = provider + '::' + model
570
- // host 侧测试超时 60s,约 55 次 × 1.2s ≈ 66s 后放弃,避免结果被覆盖时无限轮询
571
- const giveUp = () => setTestStates((s) => Object.assign({}, s, { [key]: { status: 'error', code: 'POLL_TIMEOUT', error: '等待测试结果超时:host 可能未处理该请求(host 半更新后需重启 DSH)' } }))
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 未重启到新版,或结果写入失败)' } }))
572
576
  apiRef.settings.describe({}).then((resp) => {
573
577
  const r = resp && resp.result ? resp.result : resp
574
578
  const d = r && r.value !== undefined ? r.value : r
575
579
  const ns = ((d && d.namespaces) || []).find((n) => n && n.ns === 'model-channel-health')
576
580
  const tr = (ns && ns.value && ns.value.testResults) || {}
577
581
  const e = tr[nonce]
578
- if (e && (e.status === 'ok' || e.status === 'error')) {
579
- setTestStates((s) => Object.assign({}, s, { [key]: e }))
582
+ // 终态判定不能只看 status:整树快照回写曾把 status 改回 running 而保留
583
+ // finishedAt/code/error(0.3.11 事故)。finishedAt 写入后不会被抹掉,把它也当终态,
584
+ // 并按 ok 归一 status —— 这样「僵尸条目」也能显示真实的上游错误而不是假超时。
585
+ const terminal = !!(e && (e.status === 'ok' || e.status === 'error' || e.finishedAt))
586
+ if (terminal) {
587
+ const norm = (e.status === 'ok' || e.status === 'error') ? e : Object.assign({}, e, { status: e.ok === true ? 'ok' : 'error' })
588
+ setTestStates((s) => Object.assign({}, s, { [key]: norm }))
580
589
  } else if (attempt >= 55) {
581
- giveUp()
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))
582
593
  } else {
583
594
  setTimeout(() => pollTest(nonce, provider, model, attempt + 1), 1200)
584
595
  }
@@ -594,7 +605,7 @@ window.__ModuleLoader__.load({
594
605
  setTestStates((s) => Object.assign({}, s, { [key]: { status: 'running' } }))
595
606
  // F03:Date.now()%1e9 同毫秒可碰撞(碰撞 = 两次测试互相认领/覆盖结果),
596
607
  // 换 UUID;host 侧 testRequest.nonce 消费方是 !== 比较,字符串/数字都行
597
- const nonce = (crypto.randomUUID && crypto.randomUUID()) || (String(Date.now()) + '-' + Math.random().toString(36).slice(2, 10))
608
+ const nonce = createNonce()
598
609
  const prompt = localStorage.getItem('mcm_test_prompt') || '用一句话介绍你自己'
599
610
  const maxTokens = Number(localStorage.getItem('mcm_test_max_tokens')) || 256
600
611
  apiRef.settings.update({
@@ -976,7 +987,7 @@ window.__ModuleLoader__.load({
976
987
  const speedtest = (gid) => {
977
988
  if (!apiRef) return
978
989
  // F03/F19 同 fireTest:UUID 防碰撞 + 信封检查(ok:false 不再当「已触发」)
979
- const nonce = (crypto.randomUUID && crypto.randomUUID()) || (String(Date.now()) + '-' + Math.random().toString(36).slice(2, 10))
990
+ const nonce = createNonce()
980
991
  setSpeedState((s) => Object.assign({}, s, { [gid]: 'running' }))
981
992
  apiRef.settings.update({ ns: 'model-channel-health', patch: { speedRequest: { group: gid, nonce } } }).then((resp) => {
982
993
  const r = resp && resp.result ? resp.result : resp
@@ -1076,7 +1087,9 @@ window.__ModuleLoader__.load({
1076
1087
  function HealthPanel(props) {
1077
1088
  const health = props.health
1078
1089
  const providers = props._providers || {}
1079
- const [windowMode, setWindowMode] = useState('30m') // '30m' | '24h' | '7d'
1090
+ // 默认 7d:digest 是窗口聚合,小窗口在长时间无请求时会合法显示 0,
1091
+ // 用户容易误认为「数据丢了」。先默认展示历史,再可切 30m/24h 看实时/近期。
1092
+ const [windowMode, setWindowMode] = useState('7d') // '30m' | '24h' | '7d'
1080
1093
 
1081
1094
  if (!health) return el('div', { className: 'mcm-empty' }, '健康统计数据准备中…')
1082
1095
 
@@ -1088,7 +1101,12 @@ window.__ModuleLoader__.load({
1088
1101
  // digest 是 7 天全窗口聚合;30m/24h 视图按 lastTs 近似(lastTs 在窗口内才计入),
1089
1102
  // 精细窗口统计属后续增强(需要 host 按窗口出多份 digest)。
1090
1103
  // 旧 host 兼容:无 digest 字段时走原始 records 路径(升级窗口不断供)。
1091
- 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
1092
1110
  // 按 provider 分组
1093
1111
  const byProvider = new Map()
1094
1112
 
@@ -1124,6 +1142,11 @@ window.__ModuleLoader__.load({
1124
1142
  let totalTokIn = 0
1125
1143
  let totalTokOut = 0
1126
1144
  let totalTokCache = 0
1145
+ // allEvents 必须在两个分支前声明:旧 host 兼容路径(digest 缺失)会在
1146
+ // else 块里赋值它(供顶部指标卡用);若在此处之后才 let 声明,
1147
+ // 赋值撞未声明变量 → ReferenceError 白屏(host 重启首次加载、digest
1148
+ // 尚未生成时正是此路径)。这是 0.3.1 引入双路径时的遗漏。
1149
+ let allEvents = []
1127
1150
 
1128
1151
  if (digestRows !== null) {
1129
1152
  // ---------- digest 投影路径 ----------
@@ -1231,8 +1254,7 @@ window.__ModuleLoader__.load({
1231
1254
  }
1232
1255
 
1233
1256
  // 顶部指标卡:digest 路径用聚合值求和,旧路径用原始事件计数
1234
- // (allEvents 提升到函数级:旧 host 回落路径的 else 块在此作用域外定义它)
1235
- let allEvents = []
1257
+ // (allEvents 已在上方分支前声明)
1236
1258
  let totalRequests = 0
1237
1259
  let totalSuccess = 0
1238
1260
  let ttftWeightedSum = 0
@@ -67,6 +67,18 @@ const healthDomainSpec = defineDomain({
67
67
  },
68
68
  });
69
69
 
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('|');
81
+
70
82
  /**
71
83
  * HealthStore:封装 domain 读写 + 保留策略 + 聚合。
72
84
  * 非 Service(宿主插件自有生命周期),由 apply() 创建并 effect 持有 close。
@@ -95,6 +107,9 @@ export class HealthStore {
95
107
  /** effect 用:domain 关闭(写入队列排干后释放)。 */
96
108
  async close() {
97
109
  if (this.ready === null) return;
110
+ // 0.3.13(审计 #10):先排干类内写链再关 domain——旧实现直接 close,
111
+ // 排队中的 append/迁移写入可能在 domain 关闭后才执行(丢最后一笔)。
112
+ await this.eventChain.catch(() => {});
98
113
  const domain = await this.ready.catch(() => null);
99
114
  this.ready = null;
100
115
  this.events = null;
@@ -102,14 +117,21 @@ export class HealthStore {
102
117
  if (domain) await domain.close().catch(() => {});
103
118
  }
104
119
 
120
+ /** 把一次事件表写入排入类内写链:并发调用逐个重读当前值,不交错、不互相覆盖。
121
+ * 返回本次写入的真实结果(失败会 reject,调用方可记录/重试);链本身用吞错版本续接。
122
+ */
123
+ enqueueEvent(run) {
124
+ const result = this.eventChain.then(run, run);
125
+ this.eventChain = result.catch(() => { });
126
+ return result;
127
+ }
128
+
105
129
  /** 追加一条事件到渠道桶,同时执行窗口过期 + 条数截断。
106
- * 并发安全:桶初始化(put)与追加(update)都排队到类内写链,逐个重读当前值,
107
- * 并发 appendEvent 不交错、不互相覆盖(等价 KvTable.update 的原子语义;
108
- * 桶不存在时 KvTable.update 报 missing-key,故初始化先行)。
130
+ * 桶不存在时先 put 初始化(KvTable.update 对缺失键报 missing-key)。
109
131
  */
110
132
  appendEvent(provider, rec) {
111
133
  if (this.events === null) return Promise.resolve();
112
- this.eventChain = this.eventChain.then(async () => {
134
+ return this.enqueueEvent(async () => {
113
135
  const cutoff = Date.now() - WINDOW_MS;
114
136
  const cur = this.events.get(provider);
115
137
  if (cur === undefined) {
@@ -119,14 +141,17 @@ export class HealthStore {
119
141
  await this.events.update(provider, (c) => ({
120
142
  events: [...c.events.filter((e) => e.ts >= cutoff), rec].slice(-PER_KEY_LIMIT),
121
143
  }));
122
- }).catch(() => { }); // 链不断:失败吞掉(调用方已有 warn),后续追加继续
123
- return this.eventChain;
144
+ });
124
145
  }
125
146
 
126
- /** 整组测速结果替换写入。 */
147
+ /** 整组测速结果替换写入。0.3.13(审计 #10):也排入同一条写链,
148
+ * 避免与迁移/追加写入交错(快照语义下后写者胜,交错会写出半新半旧的桶)。 */
127
149
  async putSpeedRows(groupId, rows) {
128
150
  if (this.speed === null) return;
129
- 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
+ });
130
155
  }
131
156
 
132
157
  /** 读全部事件桶(迁移/聚合用)。 */
@@ -146,23 +171,43 @@ export class HealthStore {
146
171
  }
147
172
 
148
173
  /**
149
- * 存量迁移:settings health 子树的 records/speedResults 一次性搬入 domain。
150
- * 迁移以「桶里有数据即跳过」防重放;完成后由调用方写迁移标记。
151
- * 并发安全:逐桶判断改走原子链(迁移进行中 recordHealth 并发创建的新桶不会被旧数据覆盖)。
174
+ * 存量迁移:把 settings health 子树的 records/speedResults 并入 domain。
175
+ *
176
+ * 与早期实现的关键差别:**桶已存在时合并去重,而不是整桶跳过**。
177
+ * 原因是 0.3.11 修掉的 schema 缺陷(nonce 类型漂移 → health 整树被宽松兜底
178
+ * 清空)期间,迁移标记可能已被误写、而 settings 里的旧流水从未真正导入;
179
+ * 此时若沿用「桶已存在即跳过」再清理 settings,旧账会被直接删除。
180
+ * 合并保证不丢数据,且幂等(同 ts+provider+model 视为同一笔)。
181
+ * 测速结果是「最新快照」语义,仍以 domain 已有行为准。
152
182
  */
153
183
  async migrateFrom(records, speedResults) {
154
184
  if (this.events === null) return false;
155
185
  const cutoff = Date.now() - WINDOW_MS;
156
186
  for (const [provider, list] of Object.entries(records || {})) {
157
- if (!Array.isArray(list) || list.length === 0) continue;
158
- if (this.events.get(provider) !== undefined) continue; // 已有桶:domain 是权威,不回搬
159
- const events = list.filter((e) => e && e.ts >= cutoff).slice(-PER_KEY_LIMIT);
160
- if (events.length > 0) await this.events.put(provider, { events });
187
+ const incoming = (Array.isArray(list) ? list : [])
188
+ .filter((e) => e && Number.isFinite(e.ts) && e.ts >= cutoff);
189
+ if (incoming.length === 0) continue;
190
+ await this.enqueueEvent(async () => {
191
+ const existing = (this.events.get(provider) || {}).events || [];
192
+ const seen = new Set(existing.map(eventKey));
193
+ const merged = existing.slice();
194
+ for (const e of incoming) {
195
+ const k = eventKey(e);
196
+ if (seen.has(k)) continue;
197
+ seen.add(k);
198
+ merged.push(e);
199
+ }
200
+ merged.sort((a, b) => (a.ts || 0) - (b.ts || 0));
201
+ await this.events.put(provider, { events: merged.slice(-PER_KEY_LIMIT) });
202
+ });
161
203
  }
162
204
  for (const [groupId, rows] of Object.entries(speedResults || {})) {
163
205
  if (!Array.isArray(rows) || rows.length === 0) continue;
164
- if (this.speed.get(groupId) !== undefined) continue;
165
- 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
+ });
166
211
  }
167
212
  return true;
168
213
  }
package/src/index.js CHANGED
@@ -18,10 +18,14 @@ const HEALTH_SCHEMA = z.object({
18
18
  speedResults: z.dict(z.array(z.any())).default({}),
19
19
  runtime: z.dict(z.any()).default({}),
20
20
  speedRequest: z.any(),
21
- lastHandledNonce: z.number().default(0),
21
+ // nonce 必须容忍 number 与 string 两种历史形态:0.3.2 的 client 写过 UUID 字符串,
22
+ // 而 schema 当时声明 z.number() → 字符串一旦落盘,**整个 health 子树校验失败**,
23
+ // 被 .loose(true) 兜底替换成默认值:records/digest 在运行时全部消失、迁移空转。
24
+ // (0.3.11 线上事故根因;回归见 tests/health-schema-tolerance.test.cjs)
25
+ lastHandledNonce: z.union([z.number(), z.string()]).default(0),
22
26
  testRequest: z.any(),
23
27
  testResults: z.dict(z.any()).default({}),
24
- lastTestHandledNonce: z.number().default(0),
28
+ lastTestHandledNonce: z.union([z.number(), z.string()]).default(0),
25
29
  legacyMigrated: z.any(),
26
30
  healthMigrated: z.any(),
27
31
  // 小投影:host 聚合好的健康摘要(client 渲染用),digestAt 为生成时刻
@@ -34,6 +38,48 @@ export const Config = z.object({
34
38
  effortMemory: z.dict(z.string()).default({}).volatile(),
35
39
  health: HEALTH_SCHEMA.volatile(),
36
40
  }).loose(true);
41
+ // ---------- 任务请求的 consume-once 判定(纯函数,便于回归测试) ----------
42
+ // nonce 规范化为字符串键:number(现行 client 的随机 53-bit 整数)与 string(0.3.2–0.3.8 的 UUID)
43
+ // 都能比较;非法形态返回 null。
44
+ export const nonceKey = (v) => (typeof v === 'number' || typeof v === 'string') ? String(v) : null;
45
+ // schema 默认值 0 表示「从未处理过」,不能当一个真实 nonce 用
46
+ export const settledNonceKey = (v) => {
47
+ const k = nonceKey(v);
48
+ return (k === null || k === '0') ? null : k;
49
+ };
50
+ /**
51
+ * 是否应当消费这个 nonce(唯一入口,测试与测速共用)。
52
+ * 关键点:只用「严格等值 + 已消费集合」,不做任何数值大小推断——
53
+ * 旧实现用 Math.max(…) 比较数值大小来判等,而 nonce 是随机 53-bit,
54
+ * 「上一轮 > 本轮」时(≈50%)guard 失效,同一请求会在每次 volatile-update
55
+ * 被重新消费(重复上游请求 + 结果条目互相污染)。
56
+ * hasTerminal 为真(finishedAt / status ok|error)时也视为已消费:结果已落地,
57
+ * 重放只会得到重复计费与覆盖。
58
+ */
59
+ export function shouldConsumeNonce({ consumed, key, claimed, settled, hasTerminal }) {
60
+ if (key === null || key === undefined) return false;
61
+ if (hasTerminal) return false;
62
+ if (key === claimed || key === settled) return false;
63
+ return !consumed.has(key);
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
+ }
37
83
  export function apply(ctx, config) {
38
84
  // 本行在 profile 中的 entry id 就是 settings 命名空间;缺失时回落到包名。
39
85
  const SELF_NS = (ctx.fiber && ctx.fiber.entry && ctx.fiber.entry.options && ctx.fiber.entry.options.id) || 'model-channel-manager';
@@ -81,14 +127,25 @@ export function apply(ctx, config) {
81
127
  return g;
82
128
  };
83
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),调用方走正常失败路径。
84
135
  const timed = (ms, makeError) => {
85
136
  let rejectFn;
137
+ let settled = false;
138
+ let timer = null;
86
139
  const promise = new Promise((_, reject) => { rejectFn = reject; });
87
- const dispose = ctx.effect(() => {
88
- const timer = setTimeout(() => rejectFn(makeError()), ms);
89
- 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
+ };
90
147
  }, 'model-channel timeout guard');
91
- return { promise, dispose };
148
+ return { promise, dispose: () => { if (timer !== null) clearTimeout(timer); settled = true; } };
92
149
  };
93
150
  // ---------- 配置归一化(与动态原型逐行一致) ----------
94
151
  function normalizeConfig(raw) {
@@ -866,9 +923,11 @@ export function apply(ctx, config) {
866
923
  // ---------- 单模型真实请求测试(client 经 settings 总线下发,走 DSH 真实 llm.stream 链路) ----------
867
924
  async function runModelTest(provider, model, prompt, maxTokens) {
868
925
  const llm = ctx.llm;
869
- // F15(与 measureCandidate 同一模式):总时限 60s(旧行为每 chunk 重置 guard,
926
+ // F15(与 measureCandidate 同一模式):总时限 45s(旧行为每 chunk 重置 guard,
870
927
  // 持续输出的请求永不超时而 client 已放弃)+ 超时 abort 上游 + 统一 finally 关闭。
871
- 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;
872
931
  const controller = new AbortController();
873
932
  const deadline = Date.now() + MODEL_TEST_TOTAL_MS;
874
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 });
@@ -944,16 +1003,89 @@ export function apply(ctx, config) {
944
1003
  await closeInner();
945
1004
  }
946
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
+ };
947
1055
  function handleTestRequest(next) {
948
1056
  const req = next.testRequest;
949
- // nonce 统一为字符串 UUID(0.3.2 起 client 换 randomUUID,防同毫秒碰撞);
950
- // 旧数字 nonce(Date.now()%1e9)仍被接受:typeof 兼容两种,比较用 !==。
1057
+ // nonce 接受 number(现行 client 的随机 53-bit 整数)与 string(0.3.2–0.3.8 的 UUID)
1058
+ // 两种历史形态;去重一律按字符串键严格比较,不再做数值大小推断。
951
1059
  const nonceOf = (v) => (typeof v === 'number' || typeof v === 'string') ? v : null;
952
1060
  const reqNonce = nonceOf(req && req.nonce);
953
- const lastNum = typeof next.lastTestHandledNonce === 'number' ? next.lastTestHandledNonce : null;
954
- const last = lastNum !== null ? Math.max(lastNum, typeof claimedTestNonce === 'number' ? claimedTestNonce : 0) : (claimedTestNonce || null);
955
- if (!req || typeof req.provider !== 'string' || typeof req.model !== 'string' || reqNonce === null || reqNonce === last)
1061
+ const reqKey = nonceKey(reqNonce);
1062
+ const claimedKey = nonceKey(claimedTestNonce);
1063
+ const settled = settledNonceKey(next.lastTestHandledNonce);
1064
+ const entry = reqKey !== null ? (next.testResults || {})[reqKey] : null;
1065
+ // 终态判定用 finishedAt(写入后不会被覆盖),不只信 status:
1066
+ // 整树快照回写曾把 status 改回 running 却保留 finishedAt/code/error(0.3.11 事故)。
1067
+ const hasTerminal = !!(entry && (entry.finishedAt || entry.status === 'ok' || entry.status === 'error'));
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
+ }
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
+ }
956
1086
  return;
1087
+ }
1088
+ markConsumed(consumedTestNonces, reqKey);
957
1089
  claimedTestNonce = reqNonce;
958
1090
  // nonce 的落盘推迟到本次测试得出结论之后:只有「真跑过」才算 handled。
959
1091
  // 启动早于凭据服务就绪时会以 MISSING_CREDENTIAL 失败,那要释放认领重试;
@@ -963,23 +1095,28 @@ export function apply(ctx, config) {
963
1095
  bus.writeHealth({ lastTestHandledNonce: reqNonce }).catch((e) => console.error('[model-channel-manager] lastTestHandledNonce 写入失败:', e));
964
1096
  return setResult(entry);
965
1097
  };
966
- // 0.2 的配置写入是「整字段落盘」,不再有 path-ops;结果集按当前值合并后整段写回。
1098
+ // 0.3.12:结果按「单键叶写」落盘(path-ops),不再把整个 testResults 读出来整段写回。
1099
+ // 整段写回的 payload 是调用时刻的快照,落盘晚于并发的终态写时会把该 nonce 的
1100
+ // status 改回 running(深合并保留 finishedAt/code/error)——线上事故的直接成因。
1101
+ // 叶写只动这一个 nonce 的条目,物理上不可能覆盖别人的、也不可能自我回退。
967
1102
  const setResult = async (entry) => {
968
1103
  const key = String(reqNonce);
969
1104
  const value = Object.assign({}, entry, { nonce: reqNonce, provider: req.provider, model: req.model });
970
- const cur = healthOf().testResults || {};
971
1105
  if (bus !== null)
972
- await bus.writeHealth({ testResults: Object.assign({}, cur, { [key]: value }) }).catch((e) => console.error('[model-channel-manager] testResults 写入失败:', e));
1106
+ await bus.writeHealthLeaf(['testResults', key], value).catch((e) => console.error('[model-channel-manager] testResults 写入失败:', e));
973
1107
  };
974
- // 修剪低频执行:只在条目数超限时砍到 50(不在每次写入时整包重写)
1108
+ // 修剪低频执行:只在条目数超限时砍到 50。用 unset 叶删(update 的深合并删不掉键)
975
1109
  const maybePrune = () => {
976
1110
  const all = healthOf().testResults || {};
977
1111
  const entries = Object.entries(all);
978
1112
  if (entries.length <= 50) return;
979
- const kept = {};
980
- for (const [k, v] of entries.sort((a, b) => ((b[1] && b[1].finishedAt) || 0) - ((a[1] && a[1].finishedAt) || 0)).slice(0, 50)) kept[k] = v;
981
- if (bus !== null)
982
- bus.writeHealth({ testResults: kept }).catch((e) => console.error('[model-channel-manager] testResults 修剪失败:', e));
1113
+ const removed = entries
1114
+ .sort((a, b) => ((b[1] && b[1].finishedAt) || 0) - ((a[1] && a[1].finishedAt) || 0))
1115
+ .slice(50)
1116
+ .map(([k]) => k);
1117
+ if (removed.length === 0 || bus === null) return;
1118
+ bus.writeHealthOps(removed.map((k) => ({ op: 'unset', path: ['health', 'testResults', k] })))
1119
+ .catch((e) => console.error('[model-channel-manager] testResults 修剪失败:', e));
983
1120
  };
984
1121
  const done = async (r) => {
985
1122
  try {
@@ -996,6 +1133,9 @@ export function apply(ctx, config) {
996
1133
  if (notReady(e)) {
997
1134
  // 凭据服务尚未就绪(典型是启动瞬间的重放):不写成假 error,释放认领并延后重试
998
1135
  claimedTestNonce = null;
1136
+ releaseConsumed(consumedTestNonces, reqKey); // 释放后才能靠重放再消费
1137
+ releaseConsumed(warnedTestNonces, reqKey);
1138
+ testBackoffUntil = Date.now() + 5000; // 退避窗口:期间任何 volatile-update 都不再消费
999
1139
  scheduleReplay('test', () => reloadFromConfig(false));
1000
1140
  return;
1001
1141
  }
@@ -1096,21 +1236,41 @@ export function apply(ctx, config) {
1096
1236
  flushDigest();
1097
1237
  }, 'model-channel health digest');
1098
1238
  // settings 存量 records/speedResults → domain 一次性迁移(桶已存在即跳过,domain 权威)。
1239
+ // 清空旧键必须走 path-ops:update 是深合并,`records: {}` 清不掉已有键——
1240
+ // 0.3.8 用合并写「清空」实际没删,旧流水一直留在 profile 里(每 5s describe 都要带着它)。
1099
1241
  const migrateHealthToDomain = async () => {
1100
1242
  if (healthStore === null || bus === null) return;
1101
1243
  const health = healthOf();
1102
- if (health.healthMigrated === true) return;
1244
+ const alreadyMigrated = health.healthMigrated === true;
1245
+ const hasRecords = health.records !== undefined && Object.keys(health.records || {}).length > 0;
1246
+ const hasSpeed = health.speedResults !== undefined && Object.keys(health.speedResults || {}).length > 0;
1247
+ if (!hasRecords && !hasSpeed) {
1248
+ // 无存量:只补哨兵(幂等)
1249
+ if (!alreadyMigrated) {
1250
+ await bus.writeHealthOps([{ op: 'set', path: ['health', 'healthMigrated'], value: true }])
1251
+ .catch((e) => console.warn('[model-channel-manager] health marker write failed:', e && e.message));
1252
+ }
1253
+ return;
1254
+ }
1255
+ // 有存量就必须导入(合并幂等)——不能只看 marker:schema 缺陷期间 marker 可能已被
1256
+ // 误写而数据从未导入(0.3.11 线上事故),照 marker 跳过再清理 = 直接删掉旧账。
1103
1257
  let ok = false;
1104
1258
  try {
1105
1259
  ok = await healthStore.migrateFrom(health.records || {}, health.speedResults || {});
1106
1260
  }
1107
- catch (_e) {
1108
- ok = false; // 下次启动再试
1261
+ catch (e) {
1262
+ console.warn('[model-channel-manager] legacy health import failed (will retry next boot):', e && e.message);
1263
+ return;
1109
1264
  }
1110
- if (!ok) return; // store 已关闭(卸载竞态):不写标记,避免标记与数据永久不一致
1111
- // 同一次合并写:迁移标记 + 清空 settings 里的旧存量(否则 writeHealth 的
1112
- // Object.assign 会把 records/speedResults 永远带下去,profile 膨胀问题未真正解决)
1113
- bus.writeHealth({ healthMigrated: true, records: {}, speedResults: {} }).catch(() => { });
1265
+ if (!ok) return; // store 已关闭(卸载竞态):不清理,避免标记与数据永久不一致
1266
+ // 导入成功才真删除旧键(path-ops;update 的深合并清不掉)+ 落哨兵
1267
+ const ops = [];
1268
+ if (hasRecords) ops.push({ op: 'unset', path: ['health', 'records'] });
1269
+ if (hasSpeed) ops.push({ op: 'unset', path: ['health', 'speedResults'] });
1270
+ if (!alreadyMigrated) ops.push({ op: 'set', path: ['health', 'healthMigrated'], value: true });
1271
+ if (ops.length === 0) return;
1272
+ await bus.writeHealthOps(ops).catch((e) => console.warn('[model-channel-manager] legacy health cleanup failed:', e && e.message));
1273
+ console.log('[model-channel-manager] legacy health imported into domain and cleared from settings');
1114
1274
  };
1115
1275
 
1116
1276
  // ---------- settings 总线接入(响应式:settings 服务异步初始化,apply 时查询太早) ----------
@@ -1118,8 +1278,34 @@ export function apply(ctx, config) {
1118
1278
  let runtimeRestored = false;
1119
1279
  // 非持久去重:回写要等当前 HMR 事务结束才落盘,这中间 volatile-update 可能带着同一个 nonce 再来,
1120
1280
  // 内存里先认领,避免同一请求被重复执行(并顺带消掉重复的回写风暴)。
1281
+ //
1282
+ // 0.3.12:改为严格 consume-once(字符串键集合),不再依赖 nonce 的数值单调性。
1283
+ // 旧实现 last = Math.max(上一轮结算值, 本次认领值),而两个 nonce 都是随机 53-bit:
1284
+ // 「上一轮 > 本轮」时判等直接失效(≈50%),于是测试在每次 loader/volatile-update
1285
+ // (包括宿主自己每 5s 的 digest 回写)都被重新消费——重复上游请求(真实计费)
1286
+ // 且结果条目互相污染。线上物证:同 nonce 一条记录同时带
1287
+ // `code TIMEOUT / 60000ms` 与 `ok:true / text`(单次执行不可能)。
1288
+ // 回归见 tests/task-dedup.test.cjs。
1121
1289
  let claimedTestNonce = null; // string UUID 或旧数字;null = 未认领
1122
1290
  let claimedSpeedNonce = null; // string UUID 或旧数字;null = 未认领
1291
+ let lastHealthKeyCount = -1; // health 子树键数(用于探测「被兜底清空」,审计 R2/#4)
1292
+ const consumedTestNonces = new Set(); // 已消费过的 nonce 字符串键(含本次)
1293
+ const consumedSpeedNonces = new Set();
1294
+ const warnedTestNonces = new Set(); // 每个 nonce 只提醒一次(防日志刷屏)
1295
+ const pendingSweepAttempted = new Set(); // 兜底重试每个 nonce 只做一次
1296
+ const CONSUMED_NONCE_CAP = 256; // 上限:只用于防重复消费,不需要无限历史
1297
+ // notReady(凭据服务未就绪)释放认领后的退避窗口:期间不再消费,等 scheduleReplay 到点。
1298
+ // 否则释放后任何一次 volatile-update 都会立刻再消费,并发起多个 60s 上游请求(审计 #6)。
1299
+ let testBackoffUntil = 0;
1300
+ let speedBackoffUntil = 0;
1301
+ const markConsumed = (set, key) => {
1302
+ set.add(key);
1303
+ if (set.size > CONSUMED_NONCE_CAP) {
1304
+ const oldest = set.values().next().value;
1305
+ set.delete(oldest);
1306
+ }
1307
+ };
1308
+ const releaseConsumed = (set, key) => { if (key !== null) set.delete(key); };
1123
1309
  // 启动早于凭据服务就绪:此时重放测试/测速会以 MISSING_CREDENTIAL 失败。
1124
1310
  // 这类错误是「环境还没准备好」而不是「渠道故障」,必须释放认领、延后重试,不能写成假 error。
1125
1311
  const notReady = (e) => {
@@ -1143,6 +1329,13 @@ export function apply(ctx, config) {
1143
1329
  const cfg = cfgOf();
1144
1330
  state.config = clone(cfg) || { groups: [] };
1145
1331
  const health = healthOf();
1332
+ // 意外清空告警(审计 R2/#4):health 子树本来有内容,运行时却变成空对象 =
1333
+ // schema 校验失败被 .loose(true) 兜底(0.3.11 事故形态),必须大声报出来。
1334
+ const healthKeys = Object.keys(health || {}).length;
1335
+ if (healthKeys === 0 && lastHealthKeyCount > 0) {
1336
+ console.warn('[model-channel-manager] health 子树在运行时变空(上一轮有 ' + lastHealthKeyCount + ' 个键)——极可能是 schema 校验失败被 .loose(true) 静默兜底,请检查最近一次写入的字段类型');
1337
+ }
1338
+ lastHealthKeyCount = healthKeys;
1146
1339
  state.records = healthStore !== null
1147
1340
  ? clone(healthStore.allEventBuckets())
1148
1341
  : clone(health.records || {});
@@ -1163,23 +1356,28 @@ export function apply(ctx, config) {
1163
1356
  runtime.delete(id);
1164
1357
  rewireRoutes();
1165
1358
  const req = health.speedRequest;
1166
- // nonce 兼容字符串 UUID / 旧数字(与 handleTestRequest 同一策略)
1167
- const speedNonce = (typeof (req && req.nonce) === 'number' || typeof (req && req.nonce) === 'string') ? req.nonce : null;
1168
- const lastNum = typeof health.lastHandledNonce === 'number' ? health.lastHandledNonce : null;
1169
- const last = lastNum !== null ? Math.max(lastNum, typeof claimedSpeedNonce === 'number' ? claimedSpeedNonce : 0) : (claimedSpeedNonce || null);
1170
- if (req && typeof req.group === 'string' && speedNonce !== null && speedNonce !== last) {
1171
- claimedSpeedNonce = speedNonce;
1359
+ // 与 handleTestRequest 同一策略:nonce 兼容字符串/数字,去重按字符串键严格等值
1360
+ const speedKey = nonceKey(req && req.nonce);
1361
+ const speedClaimed = nonceKey(claimedSpeedNonce);
1362
+ const speedSettled = settledNonceKey(health.lastHandledNonce);
1363
+ if (req && typeof req.group === 'string' && Date.now() < speedBackoffUntil) return;
1364
+ if (req && typeof req.group === 'string'
1365
+ && shouldConsumeNonce({ consumed: consumedSpeedNonces, key: speedKey, claimed: speedClaimed, settled: speedSettled, hasTerminal: false })) {
1366
+ markConsumed(consumedSpeedNonces, speedKey);
1367
+ claimedSpeedNonce = req.nonce;
1172
1368
  const cfgRow = pullConfig().groups.find((g) => g.id === req.group);
1173
1369
  // nonce 落盘推迟到本次测速得出结论之后(同 handleTestRequest 的理由):
1174
1370
  // 提前写会把「没就绪」的重试用自己的持久值挡掉。
1175
1371
  const settleSpeed = () => {
1176
1372
  if (bus !== null)
1177
- bus.writeHealth({ lastHandledNonce: speedNonce }).catch(() => { });
1373
+ bus.writeHealth({ lastHandledNonce: req.nonce }).catch(() => { });
1178
1374
  };
1179
1375
  if (cfgRow)
1180
1376
  runSpeedTest(cfgRow).then((r) => {
1181
1377
  if (r && r.deferred) {
1182
1378
  claimedSpeedNonce = null;
1379
+ releaseConsumed(consumedSpeedNonces, speedKey);
1380
+ speedBackoffUntil = Date.now() + 5000; // 退避:别让 volatile-update 立刻再消费
1183
1381
  scheduleReplay('speed', () => reloadFromConfig(false));
1184
1382
  return;
1185
1383
  }
@@ -1188,6 +1386,8 @@ export function apply(ctx, config) {
1188
1386
  }).catch((e) => {
1189
1387
  if (notReady(e)) {
1190
1388
  claimedSpeedNonce = null;
1389
+ releaseConsumed(consumedSpeedNonces, speedKey);
1390
+ speedBackoffUntil = Date.now() + 5000; // 退避:同上
1191
1391
  scheduleReplay('speed', () => reloadFromConfig(false));
1192
1392
  return;
1193
1393
  }
@@ -1218,70 +1418,86 @@ export function apply(ctx, config) {
1218
1418
  // 后写的直接覆盖先写的(实测 lastTestHandledNonce 与 records 就这样丢过)。
1219
1419
  // 所以写入串行化:排队后逐个重新读当前值再合并,不让两次写入互相覆写。
1220
1420
  let healthWrites = Promise.resolve();
1221
- const writeHealth = (patch) => {
1222
- const run = async () => {
1223
- const cur = healthOf();
1224
- const next = Object.assign({}, cur, patch);
1225
- return outsideTransaction(() => settings.update(SELF_NS, { health: next }));
1226
- };
1421
+ const enqueueHealthWrite = (run) => {
1227
1422
  const queued = healthWrites.then(run, run);
1228
1423
  healthWrites = queued.catch(() => { });
1229
1424
  return queued;
1230
1425
  };
1426
+ // 0.2 的 health 子树写入曾用「整树读-改-写」(cur = healthOf(); update({health:{...cur, patch}}))。
1427
+ // 只要内存视图落后于已提交的行(volatile 提交与事件之间有窗口),旧快照就会把新值写回去:
1428
+ // 线上物证是终态写入后 status 又被改回 running(同一 nonce 同时有 finishedAt/error 与 running)。
1429
+ // 0.3.12 起一律「叶写」:patch 里出现的每个顶层键单独 set,不再回灌未见过的值。
1430
+ // 需要只动某个子键时用 writeHealthLeaf(如 testResults.<nonce>)。
1431
+ const writeHealth = (patch) => enqueueHealthWrite(async () => {
1432
+ const ops = Object.keys(patch || {}).map((k) => ({ op: 'set', path: ['health', k], value: patch[k] }));
1433
+ if (ops.length === 0) return;
1434
+ return outsideTransaction(() => settings.mutate(SELF_NS, ops, undefined));
1435
+ });
1436
+ // path-ops 写入:update 是深合并,空对象清不掉旧键;需要真删除时走 mutate。
1437
+ const writeHealthOps = (ops) => enqueueHealthWrite(async () => outsideTransaction(() => settings.mutate(SELF_NS, ops, undefined)));
1438
+ // 单叶写入:把 value 精确写到 health.<path...>,不读、不带任何快照
1439
+ const writeHealthLeaf = (path, value) => enqueueHealthWrite(async () => outsideTransaction(() => settings.mutate(SELF_NS, [{ op: 'set', path: ['health', ...path], value }], undefined)));
1440
+ // 行级配置写入(groups 等):也必须切出 HMR 事务——migrateLegacyConfig 可能在
1441
+ // loader 回调链里被调用,事务内直写会抛 "HMR transactions cannot be nested"
1442
+ // 并被空 catch 吞掉,只剩哨兵、旧配置永久不迁(审计 #13)。
1443
+ const writeConfig = (patch) => enqueueHealthWrite(async () => outsideTransaction(() => settings.update(SELF_NS, patch, undefined)));
1231
1444
  bus = {
1232
1445
  settings,
1233
1446
  writeHealth,
1447
+ writeHealthOps,
1448
+ writeHealthLeaf,
1449
+ writeConfig,
1234
1450
  cfgScope: { get: () => cfgOf() },
1235
1451
  healthScope: { get: () => healthOf() },
1236
1452
  };
1237
- // healthStore 可用(dsh-base 的 storage 栈在场)时:开 domain → 迁移存量 →
1238
- // 用 domain 权威数据重建内存镜像 → boot;否则同步走旧路径。
1239
- // ctx.inject 回调不是 async 函数,domain 初始化用 promise 链表达。
1240
- // 卸载竞态防护:then 回调先验 fiber 活性,inactive 时回滚 healthStore 并跳过
1241
- // effect 注册(对 inactive fiber 注册 effect 会抛 INACTIVE_EFFECT,若被吞掉则
1242
- // domain 永不 close,facility 名字被占 → HMR 重载后 already-open 静默降级)。
1243
- // boot 也挪进链尾:domain 路径下 reloadFromConfig(true) 异步排队,若 boot 先跑,
1244
- // state.speedResults 尚为空 → onFirstUse 组每次启动都重测;且 state.config 为空
1245
- // 时 rewireRoutes 推迟,启动早期虚拟路由短暂不存在。
1246
- const domainFacility = ctx.get('storageDomain');
1453
+ // 卸载竞态防护:异步链回到插件时先确认 fiber 仍存活(uid 被清除即已卸载)。
1247
1454
  const fiberUid = ctx.fiber && ctx.fiber.uid;
1248
- const fiberAlive = () => ctx.fiber !== undefined && ctx.fiber.uid === fiberUid && ctx.fiber.state !== 4 /* disposed */;
1249
- if (domainFacility !== undefined) {
1250
- const store = new HealthStore(ctx);
1455
+ const fiberAlive = () => ctx.fiber !== undefined && ctx.fiber.uid !== undefined && ctx.fiber.uid === fiberUid;
1456
+ // 启动不等待 domain:先用现有 settings 数据把路由与健康页带起来(旧 records 尚在
1457
+ // settings 时也能立即显示);domain 就绪后再以它为权威重载。
1458
+ // 清扫必须在首次消费 testRequest 之前:新进程里已存在的 running 条目都是上次遗留的。
1459
+ sweepStaleTestResults();
1460
+ reloadFromConfig(true);
1461
+ ctx.on('loader/volatile-update', () => {
1462
+ reloadFromConfig(false);
1463
+ console.log('[model-channel-manager] config hot-reloaded, routes:', pullConfig().groups.map((g) => g.id).join(', ') || '(none)');
1464
+ });
1465
+ boot();
1466
+ scheduleDigest(); // 首屏投影:让健康页不用等下一次真实请求
1467
+ // 未消费请求的兜底轮询(审计 R2):每 5s 看一眼 testRequest 是否被静默丢弃
1468
+ ctx.effect(() => {
1469
+ const timer = setInterval(() => { try { sweepPendingTestRequest(); } catch (_e) { } }, 5000);
1470
+ return () => clearInterval(timer);
1471
+ }, 'model-channel pending-request sweep');
1472
+ // storageDomain 是可选服务,必须经响应式 inject 取得「已声明依赖」的上下文:
1473
+ // 直接 ctx.get('storageDomain') 只能拿到未注入的裸服务,随后任何属性访问都会被
1474
+ // Cordis 代理拒绝(实测 "cannot get property storageDomain without inject",
1475
+ // domain 永远打不开、健康流水静默降级为内存)。inject 回调在服务可用时触发;
1476
+ // profile 没有 storage 栈时它不触发,插件保持内存模式,路由与面板不受影响。
1477
+ ctx.inject(['storageDomain'], (dctx) => {
1478
+ const store = new HealthStore(dctx);
1251
1479
  Promise.resolve()
1252
1480
  .then(() => store.open())
1253
1481
  .then(() => {
1254
- if (!fiberAlive()) {
1255
- // 已卸载:回滚赋值,由 closeAll 兜底回收 domain
1256
- void store.close().catch(() => { });
1257
- return;
1258
- }
1259
- healthStore = store;
1260
- ctx.effect(() => () => { void store.close(); }, 'model-channel health domain close');
1261
- })
1482
+ if (!fiberAlive()) {
1483
+ void store.close().catch(() => { }); // 已卸载:由 facility closeAll 兜底回收
1484
+ return;
1485
+ }
1486
+ healthStore = store;
1487
+ ctx.effect(() => () => { void store.close(); }, 'model-channel health domain close');
1488
+ console.log('[model-channel-manager] health domain ready: model_channel_health');
1489
+ })
1262
1490
  .catch((e) => {
1263
- console.warn('[model-channel-manager] storageDomain open failed, health records stay in-memory only:', e && e.message);
1264
- })
1491
+ console.warn('[model-channel-manager] storageDomain open failed, health records stay in-memory only:', e && e.message);
1492
+ })
1265
1493
  .then(() => (healthStore !== null ? migrateHealthToDomain().catch(() => { }) : null))
1266
1494
  .then(() => {
1267
- if (fiberAlive())
1268
- reloadFromConfig(true);
1269
- })
1270
- .then(() => {
1271
- if (fiberAlive())
1272
- boot();
1273
- })
1274
- .catch(() => { }); // 卸载竞态下 reload/boot 内部可能 throw,链尾兜底防 unhandled rejection
1275
- }
1276
- else {
1277
- reloadFromConfig(true);
1278
- boot();
1279
- }
1280
- ctx.on('loader/volatile-update', () => {
1281
- reloadFromConfig(false);
1282
- console.log('[model-channel-manager] config hot-reloaded, routes:', pullConfig().groups.map((g) => g.id).join(', ') || '(none)');
1495
+ if (!fiberAlive()) return;
1496
+ reloadFromConfig(true); // domain 权威数据覆盖内存镜像
1497
+ scheduleDigest(); // 立刻把投影刷新到 settings(健康页首屏可读)
1498
+ })
1499
+ .catch(() => { }); // 卸载竞态下 reload 内部可能 throw,链尾兜底防 unhandled rejection
1283
1500
  });
1284
- // boot 已在 domain ready 链尾(或降级分支)调用,此处不再调
1285
1501
  });
1286
1502
  // ---------- 全局 LLM 请求健康拦截 (涵盖所有非虚拟路由的真实渠道模型调用) ----------
1287
1503
  ctx.on('llm/stream', async function* (options, next) {
@@ -1391,7 +1607,7 @@ export function apply(ctx, config) {
1391
1607
  return;
1392
1608
  }
1393
1609
  if (migrated.groups.length > 0) {
1394
- await bus.settings.update(SELF_NS, { groups: migrated.groups });
1610
+ await bus.writeConfig({ groups: migrated.groups });
1395
1611
  state.config = { groups: migrated.groups };
1396
1612
  console.log('[model-channel-manager] migrated legacy config from workspace .channel-manager/config.json');
1397
1613
  }