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

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
 
@@ -125,7 +130,33 @@ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
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,25 @@ react 经 `require('react')`;样式用 `ctx.effect` 自管理;`dsh.client: {
200
231
  - 30m/24h 健康视图是近似口径(按最近活跃过滤,数值为 7 天累计,UI 已标注);精确分窗口
201
232
  需 host 出多份 digest
202
233
 
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` 会把同毫秒同渠道两笔当重复合并。
252
+
203
253
  ## 引擎超时与生命周期(0.3.3 重构:审计 F01/F02/F15 已修)
204
254
 
205
255
  - **per-attempt AbortController**:每次候选尝试独立 signal,用户取消转发(`relayAbort`,
@@ -243,3 +293,13 @@ react 经 `require('react')`;样式用 `ctx.effect` 自管理;`dsh.client: {
243
293
  23. **整字段落盘 + 内存态被配置快照覆盖**(0.3.1 已根治):`settings.update` 是整字段替换;旧实现 `reloadFromConfig()` 每次 volatile-update 都用配置快照整体覆盖 `state.records`,而健康 flush 有 2s 防抖 → **刚记下的一笔在落盘前就被内存覆盖**(症状:面板“测试”成功不入账,失败反被全局拦截器的 catch 记上)。当时的修法是 `pendingRecords` 缓冲补账;0.3.1 起权威数据搬入 storageDomain(见「健康数据存储」),settings 只存 digest 小投影,此竞态从数据模型层消除。
244
294
  24. **nonce 落盘时机与启动竞态**:`lastTestHandledNonce` / `lastHandledNonce` 必须在**得出结果之后**写(提前写会让“未就绪”的重试被自己的持久值挡掉);启动瞬间凭据服务尚未就绪时测试/测速会以 `MISSING_CREDENTIAL` 失败(凭据其实已在 `.credentials.yaml` 里),应识别为「还没就绪」→ 释放认领 + 5s 延时重试(上限 24 次),**不要**写成渠道故障;测速还必须在整组候选都因未就绪失败时**不落盘、不冷却**,否则一次启动重放就把所有渠道误判成故障。
245
295
  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`。
296
+ 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`。
297
+ 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`。
298
+ 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`。
299
+
300
+ 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
+ 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
+
303
+ ## 0.3.11 修复后的自愈路径(无需手工清库)
304
+
305
+ 重启后: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.12",
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,26 @@ 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 侧测试超时 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 未重启到新版,或结果写入失败)' } }))
572
574
  apiRef.settings.describe({}).then((resp) => {
573
575
  const r = resp && resp.result ? resp.result : resp
574
576
  const d = r && r.value !== undefined ? r.value : r
575
577
  const ns = ((d && d.namespaces) || []).find((n) => n && n.ns === 'model-channel-health')
576
578
  const tr = (ns && ns.value && ns.value.testResults) || {}
577
579
  const e = tr[nonce]
578
- if (e && (e.status === 'ok' || e.status === 'error')) {
579
- setTestStates((s) => Object.assign({}, s, { [key]: e }))
580
+ // 终态判定不能只看 status:整树快照回写曾把 status 改回 running 而保留
581
+ // finishedAt/code/error(0.3.11 事故)。finishedAt 写入后不会被抹掉,把它也当终态,
582
+ // 并按 ok 归一 status —— 这样「僵尸条目」也能显示真实的上游错误而不是假超时。
583
+ const terminal = !!(e && (e.status === 'ok' || e.status === 'error' || e.finishedAt))
584
+ if (terminal) {
585
+ const norm = (e.status === 'ok' || e.status === 'error') ? e : Object.assign({}, e, { status: e.ok === true ? 'ok' : 'error' })
586
+ setTestStates((s) => Object.assign({}, s, { [key]: norm }))
580
587
  } else if (attempt >= 55) {
581
- giveUp()
588
+ giveUp(e)
582
589
  } else {
583
590
  setTimeout(() => pollTest(nonce, provider, model, attempt + 1), 1200)
584
591
  }
@@ -594,7 +601,7 @@ window.__ModuleLoader__.load({
594
601
  setTestStates((s) => Object.assign({}, s, { [key]: { status: 'running' } }))
595
602
  // F03:Date.now()%1e9 同毫秒可碰撞(碰撞 = 两次测试互相认领/覆盖结果),
596
603
  // 换 UUID;host 侧 testRequest.nonce 消费方是 !== 比较,字符串/数字都行
597
- const nonce = (crypto.randomUUID && crypto.randomUUID()) || (String(Date.now()) + '-' + Math.random().toString(36).slice(2, 10))
604
+ const nonce = createNonce()
598
605
  const prompt = localStorage.getItem('mcm_test_prompt') || '用一句话介绍你自己'
599
606
  const maxTokens = Number(localStorage.getItem('mcm_test_max_tokens')) || 256
600
607
  apiRef.settings.update({
@@ -976,7 +983,7 @@ window.__ModuleLoader__.load({
976
983
  const speedtest = (gid) => {
977
984
  if (!apiRef) return
978
985
  // F03/F19 同 fireTest:UUID 防碰撞 + 信封检查(ok:false 不再当「已触发」)
979
- const nonce = (crypto.randomUUID && crypto.randomUUID()) || (String(Date.now()) + '-' + Math.random().toString(36).slice(2, 10))
986
+ const nonce = createNonce()
980
987
  setSpeedState((s) => Object.assign({}, s, { [gid]: 'running' }))
981
988
  apiRef.settings.update({ ns: 'model-channel-health', patch: { speedRequest: { group: gid, nonce } } }).then((resp) => {
982
989
  const r = resp && resp.result ? resp.result : resp
@@ -1076,7 +1083,9 @@ window.__ModuleLoader__.load({
1076
1083
  function HealthPanel(props) {
1077
1084
  const health = props.health
1078
1085
  const providers = props._providers || {}
1079
- const [windowMode, setWindowMode] = useState('30m') // '30m' | '24h' | '7d'
1086
+ // 默认 7d:digest 是窗口聚合,小窗口在长时间无请求时会合法显示 0,
1087
+ // 用户容易误认为「数据丢了」。先默认展示历史,再可切 30m/24h 看实时/近期。
1088
+ const [windowMode, setWindowMode] = useState('7d') // '30m' | '24h' | '7d'
1080
1089
 
1081
1090
  if (!health) return el('div', { className: 'mcm-empty' }, '健康统计数据准备中…')
1082
1091
 
@@ -1124,6 +1133,11 @@ window.__ModuleLoader__.load({
1124
1133
  let totalTokIn = 0
1125
1134
  let totalTokOut = 0
1126
1135
  let totalTokCache = 0
1136
+ // allEvents 必须在两个分支前声明:旧 host 兼容路径(digest 缺失)会在
1137
+ // else 块里赋值它(供顶部指标卡用);若在此处之后才 let 声明,
1138
+ // 赋值撞未声明变量 → ReferenceError 白屏(host 重启首次加载、digest
1139
+ // 尚未生成时正是此路径)。这是 0.3.1 引入双路径时的遗漏。
1140
+ let allEvents = []
1127
1141
 
1128
1142
  if (digestRows !== null) {
1129
1143
  // ---------- digest 投影路径 ----------
@@ -1231,8 +1245,7 @@ window.__ModuleLoader__.load({
1231
1245
  }
1232
1246
 
1233
1247
  // 顶部指标卡:digest 路径用聚合值求和,旧路径用原始事件计数
1234
- // (allEvents 提升到函数级:旧 host 回落路径的 else 块在此作用域外定义它)
1235
- let allEvents = []
1248
+ // (allEvents 已在上方分支前声明)
1236
1249
  let totalRequests = 0
1237
1250
  let totalSuccess = 0
1238
1251
  let ttftWeightedSum = 0
@@ -67,6 +67,9 @@ const healthDomainSpec = defineDomain({
67
67
  },
68
68
  });
69
69
 
70
+ /** 事件去重键:同一笔在 settings 与 domain 之间合并时不重复计。 */
71
+ const eventKey = (e) => `${e.ts}|${e.provider}|${e.model}`;
72
+
70
73
  /**
71
74
  * HealthStore:封装 domain 读写 + 保留策略 + 聚合。
72
75
  * 非 Service(宿主插件自有生命周期),由 apply() 创建并 effect 持有 close。
@@ -102,14 +105,21 @@ export class HealthStore {
102
105
  if (domain) await domain.close().catch(() => {});
103
106
  }
104
107
 
108
+ /** 把一次事件表写入排入类内写链:并发调用逐个重读当前值,不交错、不互相覆盖。
109
+ * 返回本次写入的真实结果(失败会 reject,调用方可记录/重试);链本身用吞错版本续接。
110
+ */
111
+ enqueueEvent(run) {
112
+ const result = this.eventChain.then(run, run);
113
+ this.eventChain = result.catch(() => { });
114
+ return result;
115
+ }
116
+
105
117
  /** 追加一条事件到渠道桶,同时执行窗口过期 + 条数截断。
106
- * 并发安全:桶初始化(put)与追加(update)都排队到类内写链,逐个重读当前值,
107
- * 并发 appendEvent 不交错、不互相覆盖(等价 KvTable.update 的原子语义;
108
- * 桶不存在时 KvTable.update 报 missing-key,故初始化先行)。
118
+ * 桶不存在时先 put 初始化(KvTable.update 对缺失键报 missing-key)。
109
119
  */
110
120
  appendEvent(provider, rec) {
111
121
  if (this.events === null) return Promise.resolve();
112
- this.eventChain = this.eventChain.then(async () => {
122
+ return this.enqueueEvent(async () => {
113
123
  const cutoff = Date.now() - WINDOW_MS;
114
124
  const cur = this.events.get(provider);
115
125
  if (cur === undefined) {
@@ -119,8 +129,7 @@ export class HealthStore {
119
129
  await this.events.update(provider, (c) => ({
120
130
  events: [...c.events.filter((e) => e.ts >= cutoff), rec].slice(-PER_KEY_LIMIT),
121
131
  }));
122
- }).catch(() => { }); // 链不断:失败吞掉(调用方已有 warn),后续追加继续
123
- return this.eventChain;
132
+ });
124
133
  }
125
134
 
126
135
  /** 整组测速结果替换写入。 */
@@ -146,18 +155,35 @@ export class HealthStore {
146
155
  }
147
156
 
148
157
  /**
149
- * 存量迁移:settings health 子树的 records/speedResults 一次性搬入 domain。
150
- * 迁移以「桶里有数据即跳过」防重放;完成后由调用方写迁移标记。
151
- * 并发安全:逐桶判断改走原子链(迁移进行中 recordHealth 并发创建的新桶不会被旧数据覆盖)。
158
+ * 存量迁移:把 settings health 子树的 records/speedResults 并入 domain。
159
+ *
160
+ * 与早期实现的关键差别:**桶已存在时合并去重,而不是整桶跳过**。
161
+ * 原因是 0.3.11 修掉的 schema 缺陷(nonce 类型漂移 → health 整树被宽松兜底
162
+ * 清空)期间,迁移标记可能已被误写、而 settings 里的旧流水从未真正导入;
163
+ * 此时若沿用「桶已存在即跳过」再清理 settings,旧账会被直接删除。
164
+ * 合并保证不丢数据,且幂等(同 ts+provider+model 视为同一笔)。
165
+ * 测速结果是「最新快照」语义,仍以 domain 已有行为准。
152
166
  */
153
167
  async migrateFrom(records, speedResults) {
154
168
  if (this.events === null) return false;
155
169
  const cutoff = Date.now() - WINDOW_MS;
156
170
  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 });
171
+ const incoming = (Array.isArray(list) ? list : [])
172
+ .filter((e) => e && Number.isFinite(e.ts) && e.ts >= cutoff);
173
+ if (incoming.length === 0) continue;
174
+ await this.enqueueEvent(async () => {
175
+ const existing = (this.events.get(provider) || {}).events || [];
176
+ const seen = new Set(existing.map(eventKey));
177
+ const merged = existing.slice();
178
+ for (const e of incoming) {
179
+ const k = eventKey(e);
180
+ if (seen.has(k)) continue;
181
+ seen.add(k);
182
+ merged.push(e);
183
+ }
184
+ merged.sort((a, b) => (a.ts || 0) - (b.ts || 0));
185
+ await this.events.put(provider, { events: merged.slice(-PER_KEY_LIMIT) });
186
+ });
161
187
  }
162
188
  for (const [groupId, rows] of Object.entries(speedResults || {})) {
163
189
  if (!Array.isArray(rows) || rows.length === 0) continue;
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,30 @@ 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
+ }
37
65
  export function apply(ctx, config) {
38
66
  // 本行在 profile 中的 entry id 就是 settings 命名空间;缺失时回落到包名。
39
67
  const SELF_NS = (ctx.fiber && ctx.fiber.entry && ctx.fiber.entry.options && ctx.fiber.entry.options.id) || 'model-channel-manager';
@@ -946,14 +974,21 @@ export function apply(ctx, config) {
946
974
  }
947
975
  function handleTestRequest(next) {
948
976
  const req = next.testRequest;
949
- // nonce 统一为字符串 UUID(0.3.2 起 client 换 randomUUID,防同毫秒碰撞);
950
- // 旧数字 nonce(Date.now()%1e9)仍被接受:typeof 兼容两种,比较用 !==。
977
+ // nonce 接受 number(现行 client 的随机 53-bit 整数)与 string(0.3.2–0.3.8 的 UUID)
978
+ // 两种历史形态;去重一律按字符串键严格比较,不再做数值大小推断。
951
979
  const nonceOf = (v) => (typeof v === 'number' || typeof v === 'string') ? v : null;
952
980
  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)
981
+ const reqKey = nonceKey(reqNonce);
982
+ const claimedKey = nonceKey(claimedTestNonce);
983
+ const settled = settledNonceKey(next.lastTestHandledNonce);
984
+ const entry = reqKey !== null ? (next.testResults || {})[reqKey] : null;
985
+ // 终态判定用 finishedAt(写入后不会被覆盖),不只信 status:
986
+ // 整树快照回写曾把 status 改回 running 却保留 finishedAt/code/error(0.3.11 事故)。
987
+ 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 }))
956
990
  return;
991
+ markConsumed(consumedTestNonces, reqKey);
957
992
  claimedTestNonce = reqNonce;
958
993
  // nonce 的落盘推迟到本次测试得出结论之后:只有「真跑过」才算 handled。
959
994
  // 启动早于凭据服务就绪时会以 MISSING_CREDENTIAL 失败,那要释放认领重试;
@@ -963,23 +998,28 @@ export function apply(ctx, config) {
963
998
  bus.writeHealth({ lastTestHandledNonce: reqNonce }).catch((e) => console.error('[model-channel-manager] lastTestHandledNonce 写入失败:', e));
964
999
  return setResult(entry);
965
1000
  };
966
- // 0.2 的配置写入是「整字段落盘」,不再有 path-ops;结果集按当前值合并后整段写回。
1001
+ // 0.3.12:结果按「单键叶写」落盘(path-ops),不再把整个 testResults 读出来整段写回。
1002
+ // 整段写回的 payload 是调用时刻的快照,落盘晚于并发的终态写时会把该 nonce 的
1003
+ // status 改回 running(深合并保留 finishedAt/code/error)——线上事故的直接成因。
1004
+ // 叶写只动这一个 nonce 的条目,物理上不可能覆盖别人的、也不可能自我回退。
967
1005
  const setResult = async (entry) => {
968
1006
  const key = String(reqNonce);
969
1007
  const value = Object.assign({}, entry, { nonce: reqNonce, provider: req.provider, model: req.model });
970
- const cur = healthOf().testResults || {};
971
1008
  if (bus !== null)
972
- await bus.writeHealth({ testResults: Object.assign({}, cur, { [key]: value }) }).catch((e) => console.error('[model-channel-manager] testResults 写入失败:', e));
1009
+ await bus.writeHealthLeaf(['testResults', key], value).catch((e) => console.error('[model-channel-manager] testResults 写入失败:', e));
973
1010
  };
974
- // 修剪低频执行:只在条目数超限时砍到 50(不在每次写入时整包重写)
1011
+ // 修剪低频执行:只在条目数超限时砍到 50。用 unset 叶删(update 的深合并删不掉键)
975
1012
  const maybePrune = () => {
976
1013
  const all = healthOf().testResults || {};
977
1014
  const entries = Object.entries(all);
978
1015
  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));
1016
+ const removed = entries
1017
+ .sort((a, b) => ((b[1] && b[1].finishedAt) || 0) - ((a[1] && a[1].finishedAt) || 0))
1018
+ .slice(50)
1019
+ .map(([k]) => k);
1020
+ if (removed.length === 0 || bus === null) return;
1021
+ bus.writeHealthOps(removed.map((k) => ({ op: 'unset', path: ['health', 'testResults', k] })))
1022
+ .catch((e) => console.error('[model-channel-manager] testResults 修剪失败:', e));
983
1023
  };
984
1024
  const done = async (r) => {
985
1025
  try {
@@ -996,6 +1036,7 @@ export function apply(ctx, config) {
996
1036
  if (notReady(e)) {
997
1037
  // 凭据服务尚未就绪(典型是启动瞬间的重放):不写成假 error,释放认领并延后重试
998
1038
  claimedTestNonce = null;
1039
+ releaseConsumed(consumedTestNonces, reqKey); // 释放后才能靠重放再消费
999
1040
  scheduleReplay('test', () => reloadFromConfig(false));
1000
1041
  return;
1001
1042
  }
@@ -1096,21 +1137,41 @@ export function apply(ctx, config) {
1096
1137
  flushDigest();
1097
1138
  }, 'model-channel health digest');
1098
1139
  // settings 存量 records/speedResults → domain 一次性迁移(桶已存在即跳过,domain 权威)。
1140
+ // 清空旧键必须走 path-ops:update 是深合并,`records: {}` 清不掉已有键——
1141
+ // 0.3.8 用合并写「清空」实际没删,旧流水一直留在 profile 里(每 5s describe 都要带着它)。
1099
1142
  const migrateHealthToDomain = async () => {
1100
1143
  if (healthStore === null || bus === null) return;
1101
1144
  const health = healthOf();
1102
- if (health.healthMigrated === true) return;
1145
+ const alreadyMigrated = health.healthMigrated === true;
1146
+ const hasRecords = health.records !== undefined && Object.keys(health.records || {}).length > 0;
1147
+ const hasSpeed = health.speedResults !== undefined && Object.keys(health.speedResults || {}).length > 0;
1148
+ if (!hasRecords && !hasSpeed) {
1149
+ // 无存量:只补哨兵(幂等)
1150
+ if (!alreadyMigrated) {
1151
+ await bus.writeHealthOps([{ op: 'set', path: ['health', 'healthMigrated'], value: true }])
1152
+ .catch((e) => console.warn('[model-channel-manager] health marker write failed:', e && e.message));
1153
+ }
1154
+ return;
1155
+ }
1156
+ // 有存量就必须导入(合并幂等)——不能只看 marker:schema 缺陷期间 marker 可能已被
1157
+ // 误写而数据从未导入(0.3.11 线上事故),照 marker 跳过再清理 = 直接删掉旧账。
1103
1158
  let ok = false;
1104
1159
  try {
1105
1160
  ok = await healthStore.migrateFrom(health.records || {}, health.speedResults || {});
1106
1161
  }
1107
- catch (_e) {
1108
- ok = false; // 下次启动再试
1162
+ catch (e) {
1163
+ console.warn('[model-channel-manager] legacy health import failed (will retry next boot):', e && e.message);
1164
+ return;
1109
1165
  }
1110
- if (!ok) return; // store 已关闭(卸载竞态):不写标记,避免标记与数据永久不一致
1111
- // 同一次合并写:迁移标记 + 清空 settings 里的旧存量(否则 writeHealth 的
1112
- // Object.assign 会把 records/speedResults 永远带下去,profile 膨胀问题未真正解决)
1113
- bus.writeHealth({ healthMigrated: true, records: {}, speedResults: {} }).catch(() => { });
1166
+ if (!ok) return; // store 已关闭(卸载竞态):不清理,避免标记与数据永久不一致
1167
+ // 导入成功才真删除旧键(path-ops;update 的深合并清不掉)+ 落哨兵
1168
+ const ops = [];
1169
+ if (hasRecords) ops.push({ op: 'unset', path: ['health', 'records'] });
1170
+ if (hasSpeed) ops.push({ op: 'unset', path: ['health', 'speedResults'] });
1171
+ if (!alreadyMigrated) ops.push({ op: 'set', path: ['health', 'healthMigrated'], value: true });
1172
+ if (ops.length === 0) return;
1173
+ await bus.writeHealthOps(ops).catch((e) => console.warn('[model-channel-manager] legacy health cleanup failed:', e && e.message));
1174
+ console.log('[model-channel-manager] legacy health imported into domain and cleared from settings');
1114
1175
  };
1115
1176
 
1116
1177
  // ---------- settings 总线接入(响应式:settings 服务异步初始化,apply 时查询太早) ----------
@@ -1118,8 +1179,27 @@ export function apply(ctx, config) {
1118
1179
  let runtimeRestored = false;
1119
1180
  // 非持久去重:回写要等当前 HMR 事务结束才落盘,这中间 volatile-update 可能带着同一个 nonce 再来,
1120
1181
  // 内存里先认领,避免同一请求被重复执行(并顺带消掉重复的回写风暴)。
1182
+ //
1183
+ // 0.3.12:改为严格 consume-once(字符串键集合),不再依赖 nonce 的数值单调性。
1184
+ // 旧实现 last = Math.max(上一轮结算值, 本次认领值),而两个 nonce 都是随机 53-bit:
1185
+ // 「上一轮 > 本轮」时判等直接失效(≈50%),于是测试在每次 loader/volatile-update
1186
+ // (包括宿主自己每 5s 的 digest 回写)都被重新消费——重复上游请求(真实计费)
1187
+ // 且结果条目互相污染。线上物证:同 nonce 一条记录同时带
1188
+ // `code TIMEOUT / 60000ms` 与 `ok:true / text`(单次执行不可能)。
1189
+ // 回归见 tests/task-dedup.test.cjs。
1121
1190
  let claimedTestNonce = null; // string UUID 或旧数字;null = 未认领
1122
1191
  let claimedSpeedNonce = null; // string UUID 或旧数字;null = 未认领
1192
+ const consumedTestNonces = new Set(); // 已消费过的 nonce 字符串键(含本次)
1193
+ const consumedSpeedNonces = new Set();
1194
+ const CONSUMED_NONCE_CAP = 256; // 上限:只用于防重复消费,不需要无限历史
1195
+ const markConsumed = (set, key) => {
1196
+ set.add(key);
1197
+ if (set.size > CONSUMED_NONCE_CAP) {
1198
+ const oldest = set.values().next().value;
1199
+ set.delete(oldest);
1200
+ }
1201
+ };
1202
+ const releaseConsumed = (set, key) => { if (key !== null) set.delete(key); };
1123
1203
  // 启动早于凭据服务就绪:此时重放测试/测速会以 MISSING_CREDENTIAL 失败。
1124
1204
  // 这类错误是「环境还没准备好」而不是「渠道故障」,必须释放认领、延后重试,不能写成假 error。
1125
1205
  const notReady = (e) => {
@@ -1163,23 +1243,26 @@ export function apply(ctx, config) {
1163
1243
  runtime.delete(id);
1164
1244
  rewireRoutes();
1165
1245
  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;
1246
+ // 与 handleTestRequest 同一策略:nonce 兼容字符串/数字,去重按字符串键严格等值
1247
+ const speedKey = nonceKey(req && req.nonce);
1248
+ const speedClaimed = nonceKey(claimedSpeedNonce);
1249
+ const speedSettled = settledNonceKey(health.lastHandledNonce);
1250
+ if (req && typeof req.group === 'string'
1251
+ && shouldConsumeNonce({ consumed: consumedSpeedNonces, key: speedKey, claimed: speedClaimed, settled: speedSettled, hasTerminal: false })) {
1252
+ markConsumed(consumedSpeedNonces, speedKey);
1253
+ claimedSpeedNonce = req.nonce;
1172
1254
  const cfgRow = pullConfig().groups.find((g) => g.id === req.group);
1173
1255
  // nonce 落盘推迟到本次测速得出结论之后(同 handleTestRequest 的理由):
1174
1256
  // 提前写会把「没就绪」的重试用自己的持久值挡掉。
1175
1257
  const settleSpeed = () => {
1176
1258
  if (bus !== null)
1177
- bus.writeHealth({ lastHandledNonce: speedNonce }).catch(() => { });
1259
+ bus.writeHealth({ lastHandledNonce: req.nonce }).catch(() => { });
1178
1260
  };
1179
1261
  if (cfgRow)
1180
1262
  runSpeedTest(cfgRow).then((r) => {
1181
1263
  if (r && r.deferred) {
1182
1264
  claimedSpeedNonce = null;
1265
+ releaseConsumed(consumedSpeedNonces, speedKey);
1183
1266
  scheduleReplay('speed', () => reloadFromConfig(false));
1184
1267
  return;
1185
1268
  }
@@ -1188,6 +1271,7 @@ export function apply(ctx, config) {
1188
1271
  }).catch((e) => {
1189
1272
  if (notReady(e)) {
1190
1273
  claimedSpeedNonce = null;
1274
+ releaseConsumed(consumedSpeedNonces, speedKey);
1191
1275
  scheduleReplay('speed', () => reloadFromConfig(false));
1192
1276
  return;
1193
1277
  }
@@ -1218,70 +1302,74 @@ export function apply(ctx, config) {
1218
1302
  // 后写的直接覆盖先写的(实测 lastTestHandledNonce 与 records 就这样丢过)。
1219
1303
  // 所以写入串行化:排队后逐个重新读当前值再合并,不让两次写入互相覆写。
1220
1304
  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
- };
1305
+ const enqueueHealthWrite = (run) => {
1227
1306
  const queued = healthWrites.then(run, run);
1228
1307
  healthWrites = queued.catch(() => { });
1229
1308
  return queued;
1230
1309
  };
1310
+ // 0.2 的 health 子树写入曾用「整树读-改-写」(cur = healthOf(); update({health:{...cur, patch}}))。
1311
+ // 只要内存视图落后于已提交的行(volatile 提交与事件之间有窗口),旧快照就会把新值写回去:
1312
+ // 线上物证是终态写入后 status 又被改回 running(同一 nonce 同时有 finishedAt/error 与 running)。
1313
+ // 0.3.12 起一律「叶写」:patch 里出现的每个顶层键单独 set,不再回灌未见过的值。
1314
+ // 需要只动某个子键时用 writeHealthLeaf(如 testResults.<nonce>)。
1315
+ const writeHealth = (patch) => enqueueHealthWrite(async () => {
1316
+ const ops = Object.keys(patch || {}).map((k) => ({ op: 'set', path: ['health', k], value: patch[k] }));
1317
+ if (ops.length === 0) return;
1318
+ return outsideTransaction(() => settings.mutate(SELF_NS, ops, undefined));
1319
+ });
1320
+ // path-ops 写入:update 是深合并,空对象清不掉旧键;需要真删除时走 mutate。
1321
+ const writeHealthOps = (ops) => enqueueHealthWrite(async () => outsideTransaction(() => settings.mutate(SELF_NS, ops, undefined)));
1322
+ // 单叶写入:把 value 精确写到 health.<path...>,不读、不带任何快照
1323
+ const writeHealthLeaf = (path, value) => enqueueHealthWrite(async () => outsideTransaction(() => settings.mutate(SELF_NS, [{ op: 'set', path: ['health', ...path], value }], undefined)));
1231
1324
  bus = {
1232
1325
  settings,
1233
1326
  writeHealth,
1327
+ writeHealthOps,
1328
+ writeHealthLeaf,
1234
1329
  cfgScope: { get: () => cfgOf() },
1235
1330
  healthScope: { get: () => healthOf() },
1236
1331
  };
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');
1332
+ // 卸载竞态防护:异步链回到插件时先确认 fiber 仍存活(uid 被清除即已卸载)。
1247
1333
  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);
1334
+ const fiberAlive = () => ctx.fiber !== undefined && ctx.fiber.uid !== undefined && ctx.fiber.uid === fiberUid;
1335
+ // 启动不等待 domain:先用现有 settings 数据把路由与健康页带起来(旧 records 尚在
1336
+ // settings 时也能立即显示);domain 就绪后再以它为权威重载。
1337
+ reloadFromConfig(true);
1338
+ ctx.on('loader/volatile-update', () => {
1339
+ reloadFromConfig(false);
1340
+ console.log('[model-channel-manager] config hot-reloaded, routes:', pullConfig().groups.map((g) => g.id).join(', ') || '(none)');
1341
+ });
1342
+ boot();
1343
+ scheduleDigest(); // 首屏投影:让健康页不用等下一次真实请求
1344
+ // storageDomain 是可选服务,必须经响应式 inject 取得「已声明依赖」的上下文:
1345
+ // 直接 ctx.get('storageDomain') 只能拿到未注入的裸服务,随后任何属性访问都会被
1346
+ // Cordis 代理拒绝(实测 "cannot get property storageDomain without inject",
1347
+ // domain 永远打不开、健康流水静默降级为内存)。inject 回调在服务可用时触发;
1348
+ // profile 没有 storage 栈时它不触发,插件保持内存模式,路由与面板不受影响。
1349
+ ctx.inject(['storageDomain'], (dctx) => {
1350
+ const store = new HealthStore(dctx);
1251
1351
  Promise.resolve()
1252
1352
  .then(() => store.open())
1253
1353
  .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
- })
1354
+ if (!fiberAlive()) {
1355
+ void store.close().catch(() => { }); // 已卸载:由 facility closeAll 兜底回收
1356
+ return;
1357
+ }
1358
+ healthStore = store;
1359
+ ctx.effect(() => () => { void store.close(); }, 'model-channel health domain close');
1360
+ console.log('[model-channel-manager] health domain ready: model_channel_health');
1361
+ })
1262
1362
  .catch((e) => {
1263
- console.warn('[model-channel-manager] storageDomain open failed, health records stay in-memory only:', e && e.message);
1264
- })
1363
+ console.warn('[model-channel-manager] storageDomain open failed, health records stay in-memory only:', e && e.message);
1364
+ })
1265
1365
  .then(() => (healthStore !== null ? migrateHealthToDomain().catch(() => { }) : null))
1266
1366
  .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)');
1367
+ if (!fiberAlive()) return;
1368
+ reloadFromConfig(true); // domain 权威数据覆盖内存镜像
1369
+ scheduleDigest(); // 立刻把投影刷新到 settings(健康页首屏可读)
1370
+ })
1371
+ .catch(() => { }); // 卸载竞态下 reload 内部可能 throw,链尾兜底防 unhandled rejection
1283
1372
  });
1284
- // boot 已在 domain ready 链尾(或降级分支)调用,此处不再调
1285
1373
  });
1286
1374
  // ---------- 全局 LLM 请求健康拦截 (涵盖所有非虚拟路由的真实渠道模型调用) ----------
1287
1375
  ctx.on('llm/stream', async function* (options, next) {