@arcaneorion/dsh-model-channel-manager 0.3.12 → 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
@@ -126,7 +126,7 @@ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
126
126
 
127
127
  - 模型行「⚡测试」→ 弹窗输入自定义问题 + maxTokens → 发送
128
128
  - prompt 存 localStorage(`mcm_test_prompt`,pi 同款,全局共用)
129
- - host 用 `llm.stream({provider, model, messages, maxTokens})` 真实调用(与正式对话同链路);60s 超时
129
+ - host 用 `llm.stream({provider, model, messages, maxTokens})` 真实调用(与正式对话同链路);45s 总时限(0.3.13 由 60s 下调,给终态写入留余量)
130
130
  - 结果:`status:'ok'`(ttftMs/latencyMs/text)或 `status:'error'`(code/error)
131
131
  - 模型行内显示 ⏳→✓/✗ 状态标签(hover 见详情)
132
132
 
@@ -231,24 +231,37 @@ react 经 `require('react')`;样式用 `ctx.effect` 自管理;`dsh.client: {
231
231
  - 30m/24h 健康视图是近似口径(按最近活跃过滤,数值为 7 天累计,UI 已标注);精确分窗口
232
232
  需 host 出多份 digest
233
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` 会把同毫秒同渠道两笔当重复合并。
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。
252
265
 
253
266
  ## 引擎超时与生命周期(0.3.3 重构:审计 F01/F02/F15 已修)
254
267
 
@@ -300,6 +313,9 @@ react 经 `require('react')`;样式用 `ctx.effect` 自管理;`dsh.client: {
300
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`)。
301
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 直接复刻事故时序)。
302
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
+
303
319
  ## 0.3.11 修复后的自愈路径(无需手工清库)
304
320
 
305
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.12",
3
+ "version": "0.3.13",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/client.js CHANGED
@@ -566,11 +566,13 @@ window.__ModuleLoader__.load({
566
566
  const pollTest = (nonce, provider, model, attempt = 0) => {
567
567
  if (!apiRef) return
568
568
  const key = provider + '::' + model
569
- // host 侧测试超时 60s,约 55 次 × 1.2s ≈ 66s 后放弃,避免结果被覆盖时无限轮询。
570
- // 0.3.12:放弃时区分「host 没写结果」与「host 还在跑」,不再一律甩锅「未处理/需重启」。
571
- const giveUp = (e) => setTestStates((s) => Object.assign({}, s, { [key]: (e && e.status === 'running')
572
- ? { status: 'error', code: 'POLL_TIMEOUT_RUNNING', error: '测试仍在执行(约 66 秒未返回结果):host 已收到请求,可能是渠道慢或上游挂起;稍后可在健康统计里核对' }
573
- : { status: 'error', code: 'POLL_TIMEOUT', error: '等待测试结果超时:host 未写入结果(可能 host 未重启到新版,或结果写入失败)' } }))
569
+ // host 侧测试超时 45s(0.3.13 从 60s 下调,给终态写入留出余量),
570
+ // 约 55 次 × 1.2s ≈ 66s 后放弃。放弃时按现场分三种提示,不再一律甩锅「未处理」。
571
+ const giveUp = (e, settledHere) => setTestStates((s) => Object.assign({}, s, { [key]: settledHere
572
+ ? { status: 'error', code: 'POLL_TIMEOUT_SETTLED', error: 'host 已完成本次测试但结果条目未能写入(写入失败或被覆盖):请重试,若反复出现请查看宿主日志' }
573
+ : (e && e.status === 'running')
574
+ ? { status: 'error', code: 'POLL_TIMEOUT_RUNNING', error: '测试仍在执行(约 66 秒未返回结果):host 已收到请求,可能是渠道慢或上游挂起;稍后可在健康统计里核对' }
575
+ : { status: 'error', code: 'POLL_TIMEOUT', error: '等待测试结果超时:host 未写入结果(可能 host 未重启到新版,或结果写入失败)' } }))
574
576
  apiRef.settings.describe({}).then((resp) => {
575
577
  const r = resp && resp.result ? resp.result : resp
576
578
  const d = r && r.value !== undefined ? r.value : r
@@ -585,7 +587,9 @@ window.__ModuleLoader__.load({
585
587
  const norm = (e.status === 'ok' || e.status === 'error') ? e : Object.assign({}, e, { status: e.ok === true ? 'ok' : 'error' })
586
588
  setTestStates((s) => Object.assign({}, s, { [key]: norm }))
587
589
  } else if (attempt >= 55) {
588
- giveUp(e)
590
+ // host 已结算(lastTestHandledNonce === 自己的 nonce)但结果条目不在 → 写入失败
591
+ const settled = ns && ns.value ? ns.value.lastTestHandledNonce : undefined
592
+ giveUp(e, settled !== undefined && settled !== null && String(settled) === String(nonce))
589
593
  } else {
590
594
  setTimeout(() => pollTest(nonce, provider, model, attempt + 1), 1200)
591
595
  }
@@ -1097,7 +1101,12 @@ window.__ModuleLoader__.load({
1097
1101
  // digest 是 7 天全窗口聚合;30m/24h 视图按 lastTs 近似(lastTs 在窗口内才计入),
1098
1102
  // 精细窗口统计属后续增强(需要 host 按窗口出多份 digest)。
1099
1103
  // 旧 host 兼容:无 digest 字段时走原始 records 路径(升级窗口不断供)。
1100
- const digestRows = Array.isArray(health.digest) ? health.digest : null
1104
+ // #11(审计):digest 是空数组时不要遮蔽 settings 里仍存在的 records——
1105
+ // 典型场景是 domain 打开成功但迁移失败(host 保留旧流水不清理)或 host 刚重启
1106
+ // 尚未投影。此时回落到 records 路径,页面显示旧流水而不是全 0。
1107
+ const rawRecords = health.records || {}
1108
+ const hasRawRecords = Object.keys(rawRecords).some((k) => Array.isArray(rawRecords[k]) && rawRecords[k].length > 0)
1109
+ const digestRows = (Array.isArray(health.digest) && (health.digest.length > 0 || !hasRawRecords)) ? health.digest : null
1101
1110
  // 按 provider 分组
1102
1111
  const byProvider = new Map()
1103
1112
 
@@ -67,8 +67,17 @@ const healthDomainSpec = defineDomain({
67
67
  },
68
68
  });
69
69
 
70
- /** 事件去重键:同一笔在 settings 与 domain 之间合并时不重复计。 */
71
- const eventKey = (e) => `${e.ts}|${e.provider}|${e.model}`;
70
+ /**
71
+ * 事件去重键:同一笔在 settings 与 domain 之间合并时不重复计。
72
+ * 0.3.13(审计 #10):旧键只有 `ts|provider|model`,同一毫秒同渠道的两笔真实请求
73
+ * 会被当成同一笔合并掉。把判定性字段一起纳入指纹——同毫秒 + 同渠道 + 同结果形态
74
+ * 的两笔在语义上本就不可区分;真正不同的两笔(成功率/延迟/token 不同)不会再被吞。
75
+ */
76
+ const eventKey = (e) => [
77
+ e.ts, e.provider, e.model, e.ok ? 1 : 0, e.code || '',
78
+ e.ttftMs ?? '', e.latencyMs ?? '',
79
+ e.inputTokens ?? '', e.outputTokens ?? '', e.cacheReadTokens ?? '', e.cacheWriteTokens ?? '',
80
+ ].join('|');
72
81
 
73
82
  /**
74
83
  * HealthStore:封装 domain 读写 + 保留策略 + 聚合。
@@ -98,6 +107,9 @@ export class HealthStore {
98
107
  /** effect 用:domain 关闭(写入队列排干后释放)。 */
99
108
  async close() {
100
109
  if (this.ready === null) return;
110
+ // 0.3.13(审计 #10):先排干类内写链再关 domain——旧实现直接 close,
111
+ // 排队中的 append/迁移写入可能在 domain 关闭后才执行(丢最后一笔)。
112
+ await this.eventChain.catch(() => {});
101
113
  const domain = await this.ready.catch(() => null);
102
114
  this.ready = null;
103
115
  this.events = null;
@@ -132,10 +144,14 @@ export class HealthStore {
132
144
  });
133
145
  }
134
146
 
135
- /** 整组测速结果替换写入。 */
147
+ /** 整组测速结果替换写入。0.3.13(审计 #10):也排入同一条写链,
148
+ * 避免与迁移/追加写入交错(快照语义下后写者胜,交错会写出半新半旧的桶)。 */
136
149
  async putSpeedRows(groupId, rows) {
137
150
  if (this.speed === null) return;
138
- await this.speed.put(groupId, { rows });
151
+ await this.enqueueEvent(async () => {
152
+ if (this.speed === null) return;
153
+ await this.speed.put(groupId, { rows });
154
+ });
139
155
  }
140
156
 
141
157
  /** 读全部事件桶(迁移/聚合用)。 */
@@ -187,8 +203,11 @@ export class HealthStore {
187
203
  }
188
204
  for (const [groupId, rows] of Object.entries(speedResults || {})) {
189
205
  if (!Array.isArray(rows) || rows.length === 0) continue;
190
- if (this.speed.get(groupId) !== undefined) continue;
191
- await this.speed.put(groupId, { rows });
206
+ await this.enqueueEvent(async () => {
207
+ if (this.speed === null) return;
208
+ if (this.speed.get(groupId) !== undefined) return; // domain 已有该组结果:以 domain 为准
209
+ await this.speed.put(groupId, { rows });
210
+ });
192
211
  }
193
212
  return true;
194
213
  }
package/src/index.js CHANGED
@@ -62,6 +62,24 @@ export function shouldConsumeNonce({ consumed, key, claimed, settled, hasTermina
62
62
  if (key === claimed || key === settled) return false;
63
63
  return !consumed.has(key);
64
64
  }
65
+ /**
66
+ * 启动清扫的分类(纯函数):新进程里已存在的测试条目分为两类——
67
+ * aborted : status=running 且没有 finishedAt = 上次进程遗留的在飞任务(重载打断),
68
+ * 必须补写 ABORTED 终态,否则客户端只会等到 66s 假超时(审计 R3);
69
+ * repaired : status=running 但有 finishedAt(终态字段已在)= status 被并发整树回写
70
+ * 改坏的僵尸条目,按 ok 归一状态位(审计 #2 的线上物证形态)。
71
+ */
72
+ export function selectStaleTestEntries(testResults) {
73
+ const aborted = [];
74
+ const repaired = [];
75
+ for (const [key, e] of Object.entries(testResults || {})) {
76
+ if (!e || typeof e !== 'object') continue;
77
+ if (e.status !== 'running') continue;
78
+ if (!e.finishedAt) aborted.push(key);
79
+ else if (e.ok === true || e.code || e.error) repaired.push(key);
80
+ }
81
+ return { aborted, repaired };
82
+ }
65
83
  export function apply(ctx, config) {
66
84
  // 本行在 profile 中的 entry id 就是 settings 命名空间;缺失时回落到包名。
67
85
  const SELF_NS = (ctx.fiber && ctx.fiber.entry && ctx.fiber.entry.options && ctx.fiber.entry.options.id) || 'model-channel-manager';
@@ -109,14 +127,25 @@ export function apply(ctx, config) {
109
127
  return g;
110
128
  };
111
129
  // ---------- 可取消超时(避免 Promise.race 留下孤儿定时器) ----------
130
+ // 0.3.13(审计 R3):guard 不能只在 dispose 时 clearTimeout。
131
+ // dispose(= fiber 卸载/重载)时若只清定时器,promise 永不 settle,
132
+ // `await Promise.race([inner.next(), guard.promise])` 就永远挂住:
133
+ // 在飞测试既不结束也不写终态,客户端 66s 后得到假超时。
134
+ // 现在 dispose 会主动 reject(ABORTED),调用方走正常失败路径。
112
135
  const timed = (ms, makeError) => {
113
136
  let rejectFn;
137
+ let settled = false;
138
+ let timer = null;
114
139
  const promise = new Promise((_, reject) => { rejectFn = reject; });
115
- const dispose = ctx.effect(() => {
116
- const timer = setTimeout(() => rejectFn(makeError()), ms);
117
- return () => clearTimeout(timer);
140
+ const fire = (err) => { if (settled) return; settled = true; rejectFn(err); };
141
+ ctx.effect(() => {
142
+ timer = setTimeout(() => fire(makeError()), ms);
143
+ return () => {
144
+ if (timer !== null) clearTimeout(timer);
145
+ fire({ code: 'ABORTED', message: 'plugin reloaded while this attempt was in flight' });
146
+ };
118
147
  }, 'model-channel timeout guard');
119
- return { promise, dispose };
148
+ return { promise, dispose: () => { if (timer !== null) clearTimeout(timer); settled = true; } };
120
149
  };
121
150
  // ---------- 配置归一化(与动态原型逐行一致) ----------
122
151
  function normalizeConfig(raw) {
@@ -894,9 +923,11 @@ export function apply(ctx, config) {
894
923
  // ---------- 单模型真实请求测试(client 经 settings 总线下发,走 DSH 真实 llm.stream 链路) ----------
895
924
  async function runModelTest(provider, model, prompt, maxTokens) {
896
925
  const llm = ctx.llm;
897
- // F15(与 measureCandidate 同一模式):总时限 60s(旧行为每 chunk 重置 guard,
926
+ // F15(与 measureCandidate 同一模式):总时限 45s(旧行为每 chunk 重置 guard,
898
927
  // 持续输出的请求永不超时而 client 已放弃)+ 超时 abort 上游 + 统一 finally 关闭。
899
- const MODEL_TEST_TOTAL_MS = 60000;
928
+ // 0.3.13:60s → 45s。client 轮询上限约 66s,60s 的 host 预算只留 6s 余量给
929
+ // 「终态写入 + 下一次 describe」,跑满就会撞上假超时(审计 #7)。
930
+ const MODEL_TEST_TOTAL_MS = 45000;
900
931
  const controller = new AbortController();
901
932
  const deadline = Date.now() + MODEL_TEST_TOTAL_MS;
902
933
  const inner = llm.stream({ provider, model, messages: [{ role: 'user', content: [{ type: 'text', text: prompt || '你好' }] }], maxTokens: maxTokens || 512, sessionId: 'mcm-modeltest-' + Date.now(), signal: controller.signal });
@@ -972,6 +1003,55 @@ export function apply(ctx, config) {
972
1003
  await closeInner();
973
1004
  }
974
1005
  }
1006
+ // 启动清扫(审计 R3):新进程里任何「status running 但没有 finishedAt」的测试条目
1007
+ // 必然是上一次进程遗留的(进程内在飞任务都有 fiber 生命周期,重载会打断它们,
1008
+ // 旧闭包的收尾写入往往以 "Configuration entry is no longer available" 失败)。
1009
+ // 补写 ABORTED 终态,客户端就不必等到 66s 假超时。
1010
+ // 同时修复「running + finishedAt」的僵尸条目(status 被并发整树回写改坏),
1011
+ // 按 ok 归一状态位。
1012
+ const sweepStaleTestResults = () => {
1013
+ if (bus === null) return;
1014
+ const all = healthOf().testResults || {};
1015
+ const { aborted, repaired } = selectStaleTestEntries(all);
1016
+ for (const key of aborted) {
1017
+ bus.writeHealthLeaf(['testResults', key], Object.assign({}, all[key], {
1018
+ status: 'error', code: 'ABORTED',
1019
+ error: 'host 在测试执行期间重启/重载,结果未写入;请重试',
1020
+ finishedAt: Date.now(),
1021
+ })).catch((err) => console.warn('[model-channel-manager] stale test result sweep failed:', err && err.message));
1022
+ }
1023
+ for (const key of repaired) {
1024
+ bus.writeHealthLeaf(['testResults', key], Object.assign({}, all[key], { status: all[key].ok === true ? 'ok' : 'error' }))
1025
+ .catch((err) => console.warn('[model-channel-manager] zombie test result repair failed:', err && err.message));
1026
+ }
1027
+ if (aborted.length > 0 || repaired.length > 0)
1028
+ console.log('[model-channel-manager] test results swept after restart: aborted =', aborted.length, ', repaired =', repaired.length);
1029
+ };
1030
+ // 兜底重试(审计 R2):testRequest 存在、却既没被认领也没结算 —— 典型是 loader 的
1031
+ // volatile 提交被静默拒绝(document 落盘、fiber 引用不更新、不发 volatile-update),
1032
+ // 请求会被彻底忽略。这里每个 nonce 主动重试一次并留下日志,不再无声失败。
1033
+ const sweepPendingTestRequest = () => {
1034
+ if (bus === null) return;
1035
+ const health = healthOf();
1036
+ const req = health.testRequest;
1037
+ const key = nonceKey(req && req.nonce);
1038
+ if (key === null) return;
1039
+ const entry = (health.testResults || {})[key];
1040
+ const hasTerminal = !!(entry && (entry.finishedAt || entry.status === 'ok' || entry.status === 'error'));
1041
+ // 复用与消费路径同一个(已被测试覆盖的)判定:返回 true = 这个请求还没被消费
1042
+ const needsSweep = shouldConsumeNonce({
1043
+ consumed: consumedTestNonces,
1044
+ key,
1045
+ claimed: nonceKey(claimedTestNonce),
1046
+ settled: settledNonceKey(health.lastTestHandledNonce),
1047
+ hasTerminal,
1048
+ });
1049
+ if (!needsSweep || pendingSweepAttempted.has(key)) return;
1050
+ markConsumed(pendingSweepAttempted, key);
1051
+ console.warn('[model-channel-manager] testRequest nonce', key,
1052
+ '既未结算也未消费——疑似 volatile 提交被静默拒绝,主动重试一次(若仍无结果,请看宿主 stdout 的 loader 警告)');
1053
+ reloadFromConfig(false);
1054
+ };
975
1055
  function handleTestRequest(next) {
976
1056
  const req = next.testRequest;
977
1057
  // nonce 接受 number(现行 client 的随机 53-bit 整数)与 string(0.3.2–0.3.8 的 UUID)
@@ -985,9 +1065,26 @@ export function apply(ctx, config) {
985
1065
  // 终态判定用 finishedAt(写入后不会被覆盖),不只信 status:
986
1066
  // 整树快照回写曾把 status 改回 running 却保留 finishedAt/code/error(0.3.11 事故)。
987
1067
  const hasTerminal = !!(entry && (entry.finishedAt || entry.status === 'ok' || entry.status === 'error'));
988
- if (!req || typeof req.provider !== 'string' || typeof req.model !== 'string'
989
- || !shouldConsumeNonce({ consumed: consumedTestNonces, key: reqKey, claimed: claimedKey, settled, hasTerminal }))
1068
+ if (!req || typeof req.provider !== 'string' || typeof req.model !== 'string' || reqKey === null) {
1069
+ // 字段非法:显式记一次,别再静默吞掉(审计 R2)
1070
+ if (req && reqKey !== null && !warnedTestNonces.has(reqKey)) {
1071
+ markConsumed(warnedTestNonces, reqKey);
1072
+ console.warn('[model-channel-manager] testRequest 字段非法,已忽略(需要 provider/model/nonce):', JSON.stringify(req).slice(0, 200));
1073
+ }
990
1074
  return;
1075
+ }
1076
+ if (Date.now() < testBackoffUntil) return; // 退避窗口内不消费(等重放)
1077
+ if (!shouldConsumeNonce({ consumed: consumedTestNonces, key: reqKey, claimed: claimedKey, settled, hasTerminal })) {
1078
+ // 未消费的显式解释:请求存在但既没被认领、也没终态 → 多半是 volatile 提交被
1079
+ // loader 静默拒绝(document 已落盘、fiber 引用不更新、不发 volatile-update,
1080
+ // 审计 R2),此时请求会被彻底忽略且没有任何可见报错。
1081
+ if (!hasTerminal && reqKey !== claimedKey && !warnedTestNonces.has(reqKey)) {
1082
+ markConsumed(warnedTestNonces, reqKey);
1083
+ console.warn('[model-channel-manager] testRequest 未被消费: nonce', reqKey,
1084
+ '(已结算或已消费命中)——若本次测试始终没有结果,检查宿主 stdout 是否有 loader 的 "volatile config update failed"');
1085
+ }
1086
+ return;
1087
+ }
991
1088
  markConsumed(consumedTestNonces, reqKey);
992
1089
  claimedTestNonce = reqNonce;
993
1090
  // nonce 的落盘推迟到本次测试得出结论之后:只有「真跑过」才算 handled。
@@ -1037,6 +1134,8 @@ export function apply(ctx, config) {
1037
1134
  // 凭据服务尚未就绪(典型是启动瞬间的重放):不写成假 error,释放认领并延后重试
1038
1135
  claimedTestNonce = null;
1039
1136
  releaseConsumed(consumedTestNonces, reqKey); // 释放后才能靠重放再消费
1137
+ releaseConsumed(warnedTestNonces, reqKey);
1138
+ testBackoffUntil = Date.now() + 5000; // 退避窗口:期间任何 volatile-update 都不再消费
1040
1139
  scheduleReplay('test', () => reloadFromConfig(false));
1041
1140
  return;
1042
1141
  }
@@ -1189,9 +1288,16 @@ export function apply(ctx, config) {
1189
1288
  // 回归见 tests/task-dedup.test.cjs。
1190
1289
  let claimedTestNonce = null; // string UUID 或旧数字;null = 未认领
1191
1290
  let claimedSpeedNonce = null; // string UUID 或旧数字;null = 未认领
1291
+ let lastHealthKeyCount = -1; // health 子树键数(用于探测「被兜底清空」,审计 R2/#4)
1192
1292
  const consumedTestNonces = new Set(); // 已消费过的 nonce 字符串键(含本次)
1193
1293
  const consumedSpeedNonces = new Set();
1294
+ const warnedTestNonces = new Set(); // 每个 nonce 只提醒一次(防日志刷屏)
1295
+ const pendingSweepAttempted = new Set(); // 兜底重试每个 nonce 只做一次
1194
1296
  const CONSUMED_NONCE_CAP = 256; // 上限:只用于防重复消费,不需要无限历史
1297
+ // notReady(凭据服务未就绪)释放认领后的退避窗口:期间不再消费,等 scheduleReplay 到点。
1298
+ // 否则释放后任何一次 volatile-update 都会立刻再消费,并发起多个 60s 上游请求(审计 #6)。
1299
+ let testBackoffUntil = 0;
1300
+ let speedBackoffUntil = 0;
1195
1301
  const markConsumed = (set, key) => {
1196
1302
  set.add(key);
1197
1303
  if (set.size > CONSUMED_NONCE_CAP) {
@@ -1223,6 +1329,13 @@ export function apply(ctx, config) {
1223
1329
  const cfg = cfgOf();
1224
1330
  state.config = clone(cfg) || { groups: [] };
1225
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;
1226
1339
  state.records = healthStore !== null
1227
1340
  ? clone(healthStore.allEventBuckets())
1228
1341
  : clone(health.records || {});
@@ -1247,6 +1360,7 @@ export function apply(ctx, config) {
1247
1360
  const speedKey = nonceKey(req && req.nonce);
1248
1361
  const speedClaimed = nonceKey(claimedSpeedNonce);
1249
1362
  const speedSettled = settledNonceKey(health.lastHandledNonce);
1363
+ if (req && typeof req.group === 'string' && Date.now() < speedBackoffUntil) return;
1250
1364
  if (req && typeof req.group === 'string'
1251
1365
  && shouldConsumeNonce({ consumed: consumedSpeedNonces, key: speedKey, claimed: speedClaimed, settled: speedSettled, hasTerminal: false })) {
1252
1366
  markConsumed(consumedSpeedNonces, speedKey);
@@ -1263,6 +1377,7 @@ export function apply(ctx, config) {
1263
1377
  if (r && r.deferred) {
1264
1378
  claimedSpeedNonce = null;
1265
1379
  releaseConsumed(consumedSpeedNonces, speedKey);
1380
+ speedBackoffUntil = Date.now() + 5000; // 退避:别让 volatile-update 立刻再消费
1266
1381
  scheduleReplay('speed', () => reloadFromConfig(false));
1267
1382
  return;
1268
1383
  }
@@ -1272,6 +1387,7 @@ export function apply(ctx, config) {
1272
1387
  if (notReady(e)) {
1273
1388
  claimedSpeedNonce = null;
1274
1389
  releaseConsumed(consumedSpeedNonces, speedKey);
1390
+ speedBackoffUntil = Date.now() + 5000; // 退避:同上
1275
1391
  scheduleReplay('speed', () => reloadFromConfig(false));
1276
1392
  return;
1277
1393
  }
@@ -1321,11 +1437,16 @@ export function apply(ctx, config) {
1321
1437
  const writeHealthOps = (ops) => enqueueHealthWrite(async () => outsideTransaction(() => settings.mutate(SELF_NS, ops, undefined)));
1322
1438
  // 单叶写入:把 value 精确写到 health.<path...>,不读、不带任何快照
1323
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)));
1324
1444
  bus = {
1325
1445
  settings,
1326
1446
  writeHealth,
1327
1447
  writeHealthOps,
1328
1448
  writeHealthLeaf,
1449
+ writeConfig,
1329
1450
  cfgScope: { get: () => cfgOf() },
1330
1451
  healthScope: { get: () => healthOf() },
1331
1452
  };
@@ -1334,6 +1455,8 @@ export function apply(ctx, config) {
1334
1455
  const fiberAlive = () => ctx.fiber !== undefined && ctx.fiber.uid !== undefined && ctx.fiber.uid === fiberUid;
1335
1456
  // 启动不等待 domain:先用现有 settings 数据把路由与健康页带起来(旧 records 尚在
1336
1457
  // settings 时也能立即显示);domain 就绪后再以它为权威重载。
1458
+ // 清扫必须在首次消费 testRequest 之前:新进程里已存在的 running 条目都是上次遗留的。
1459
+ sweepStaleTestResults();
1337
1460
  reloadFromConfig(true);
1338
1461
  ctx.on('loader/volatile-update', () => {
1339
1462
  reloadFromConfig(false);
@@ -1341,6 +1464,11 @@ export function apply(ctx, config) {
1341
1464
  });
1342
1465
  boot();
1343
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');
1344
1472
  // storageDomain 是可选服务,必须经响应式 inject 取得「已声明依赖」的上下文:
1345
1473
  // 直接 ctx.get('storageDomain') 只能拿到未注入的裸服务,随后任何属性访问都会被
1346
1474
  // Cordis 代理拒绝(实测 "cannot get property storageDomain without inject",
@@ -1479,7 +1607,7 @@ export function apply(ctx, config) {
1479
1607
  return;
1480
1608
  }
1481
1609
  if (migrated.groups.length > 0) {
1482
- await bus.settings.update(SELF_NS, { groups: migrated.groups });
1610
+ await bus.writeConfig({ groups: migrated.groups });
1483
1611
  state.config = { groups: migrated.groups };
1484
1612
  console.log('[model-channel-manager] migrated legacy config from workspace .channel-manager/config.json');
1485
1613
  }