@ljwei-stak/dsh-model-router 0.16.0 → 0.16.2

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.
@@ -7,6 +7,8 @@
7
7
  * a package name, executable or argument. Installs are serialized (npm global
8
8
  * state must not interleave), output is kept as a bounded tail, and success is
9
9
  * always confirmed by a fresh probe rather than the installer's exit code.
10
+ * When a newer release is known, a probe that is still older fails the job
11
+ * instead of reporting success.
10
12
  */
11
13
  import { spawn } from 'node:child_process'
12
14
  import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
@@ -15,6 +17,7 @@ import { getOfficialTool, installCommandLine, OFFICIAL_TOOLS } from './official-
15
17
  import { discoverZCodeBundle, unverifiedZCodeInstalls } from './zcode-bundle.mjs'
16
18
  import { openZCodeInstaller } from './zcode-installer.mjs'
17
19
  import { compareReleaseVersions, latestVersions } from './latest-versions.mjs'
20
+ import { versionStillOlder } from './version-order.mjs'
18
21
 
19
22
  const PROBE_TIMEOUT_MS = 8_000
20
23
  const INSTALL_TIMEOUT_MS = 900_000
@@ -30,6 +33,44 @@ const probeCache = new Map()
30
33
  const PROBE_CACHE_MS = 60_000
31
34
  const installJobs = new Map()
32
35
  let installChain = Promise.resolve()
36
+ const CLAUDE_UPDATE_TIMEOUT_MS = 180_000
37
+
38
+ /** Non-interactive updater for a native Claude Code install. npm does not move that copy. */
39
+ export const CLAUDE_UPDATE_ARGS = Object.freeze(['update'])
40
+
41
+ /**
42
+ * Quote a fixed registry command for `shell: true`.
43
+ * Node concatenates shell arguments without escaping; Windows cmd would otherwise
44
+ * split on characters inside package specs such as `@scope/pkg@latest`.
45
+ */
46
+ export function shellCommandLine(executable, args, platform = process.platform) {
47
+ const quote = value => {
48
+ const text = String(value)
49
+ if (text.includes('\0') || /[\r\n]/.test(text)) throw new Error('命令参数含有非法字符。')
50
+ if (platform === 'win32') return `"${text.replace(/"/g, '""')}"`
51
+ return `'${text.replace(/'/g, `'"'"'`)}'`
52
+ }
53
+ return [executable, ...(args ?? [])].map(quote).join(' ')
54
+ }
55
+
56
+ /** Visible failure when the installer exited cleanly but the probed copy is still older. */
57
+ export function staleInstallMessage(label, installedVersion, latestVersion) {
58
+ return `${label} 的更新没有生效:检测到的版本仍是 ${installedVersion},低于最新 ${latestVersion}。PATH 上可能有另一份更早的安装。请查看本行安装日志。`
59
+ }
60
+
61
+ /**
62
+ * Which updater to run. `order` is compareReleaseVersions(installed, latest):
63
+ * -1 older, 0 same, 1 newer, null when either version is unknown.
64
+ */
65
+ export function officialUpdatePlan({ toolId, installed, order, platform }) {
66
+ if (installed && order === 1) return 'refuse-downgrade'
67
+ if (installed && order === 0) return 'already-current'
68
+ if (installed && order === -1 && toolId === 'claude-code') return 'claude-update-then-registry'
69
+ if (installed && (order === -1 || order === null) && toolId === 'minimax-code' && platform === 'win32') {
70
+ return 'minimax-official-installer'
71
+ }
72
+ return 'registry-install'
73
+ }
33
74
 
34
75
  async function trustedExecutionReadiness(toolId) {
35
76
  // Loaded only during an install to avoid coupling fast version probes to
@@ -50,7 +91,10 @@ function runCapture(executable, args, { timeoutMs, onOutput, useShell = false, s
50
91
  let stopReason = null
51
92
  let child
52
93
  try {
53
- child = spawn(executable, args, {
94
+ // One pre-quoted command string so Node's shell wrapper cannot split
95
+ // `@scope/pkg@latest`. PowerShell keeps useShell false and an args array.
96
+ const command = useShell ? shellCommandLine(executable, args) : executable
97
+ child = spawn(command, useShell ? [] : args, {
54
98
  stdio: ['ignore', 'pipe', 'pipe'],
55
99
  windowsHide: true,
56
100
  // Windows npm CLIs install as .cmd shims; only cmd.exe can execute
@@ -145,14 +189,14 @@ async function fetchOfficialMiniMaxInstaller(signal) {
145
189
  return officialMiniMaxInstaller(Buffer.concat(chunks))
146
190
  }
147
191
 
148
- async function runOfficialMiniMaxInstaller(job) {
192
+ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
149
193
  let staging = null
150
194
  let prefixRoot = null
151
195
  try {
152
196
  job.finishedAt = null
153
197
  job.error = null
154
198
  job.phase = 'downloading-and-verifying'
155
- pushLine(job, '— npm 原生依赖未就绪;改用 MiniMax 官方 Windows 安装脚本(安装最新版)—')
199
+ pushLine(job, reason || '— npm 原生依赖未就绪;改用 MiniMax 官方 Windows 安装脚本(安装最新版)—')
156
200
  const prefixResult = await runCapture('npm', ['config', 'get', 'prefix'], {
157
201
  timeoutMs: 5_000, useShell: true, signal: job.controller.signal,
158
202
  })
@@ -208,6 +252,10 @@ async function runOfficialMiniMaxInstaller(job) {
208
252
  if (!probe.installed || !readiness.ready) {
209
253
  throw new Error(`官方安装器完成,但执行入口未通过 npm registry 摘要核验:${readiness.reason ?? probe.detail}`)
210
254
  }
255
+ const latest = await latestVersions.lookup(job.tool).catch(() => null)
256
+ if (latest?.version && versionStillOlder(probe.version, latest.version)) {
257
+ throw new Error(staleInstallMessage(job.tool.label, probe.version, latest.version))
258
+ }
211
259
  job.status = 'succeeded'
212
260
  job.finishedAt = new Date().toISOString()
213
261
  pushLine(job, `— 官方安装器已完成,MiniMax Code ${probe.version ?? ''} 的执行入口已核验 —`)
@@ -444,6 +492,86 @@ export function startInstall(toolId) {
444
492
  return jobView(job)
445
493
  }
446
494
 
495
+ function captureLines(job) {
496
+ return chunk => {
497
+ for (const line of String(chunk).split(/\r?\n/)) {
498
+ if (line.trim()) pushLine(job, line.slice(0, 500))
499
+ }
500
+ }
501
+ }
502
+
503
+ /** Mark the job succeeded only when the fresh probe is installed, ready, and not still older. */
504
+ async function finishVerifiedInstall(job, probe) {
505
+ job.postInstallProbe = probe
506
+ if (job.cancelRequested) { markCancelled(job); return true }
507
+ if (!probe.installed) {
508
+ job.status = 'failed'
509
+ job.finishedAt = new Date().toISOString()
510
+ job.error = `安装命令已完成,但重探测失败:${probe.detail}`
511
+ return true
512
+ }
513
+ const latest = await latestVersions.lookup(job.tool).catch(() => null)
514
+ if (latest?.version && versionStillOlder(probe.version, latest.version)) {
515
+ failBecauseStillOlder(job, probe, latest.version)
516
+ return true
517
+ }
518
+ const readiness = await trustedExecutionReadiness(job.tool.id)
519
+ if (job.cancelRequested) { markCancelled(job); return true }
520
+ if (!readiness.ready) {
521
+ if (IS_WINDOWS && job.tool.id === 'minimax-code') {
522
+ await runOfficialMiniMaxInstaller(job)
523
+ return true
524
+ }
525
+ job.status = 'failed'
526
+ job.finishedAt = new Date().toISOString()
527
+ job.error = `安装命令和版本探测已成功,但官方执行入口未就绪:${readiness.reason}`
528
+ return true
529
+ }
530
+ job.status = 'succeeded'
531
+ job.finishedAt = new Date().toISOString()
532
+ pushLine(job, `— 已安装 ${job.tool.label} ${probe.version ?? ''},官方执行入口已核验(新版本未经插件测试)—`)
533
+ return true
534
+ }
535
+
536
+ function failBecauseStillOlder(job, probe, latestVersion) {
537
+ job.postInstallProbe = probe
538
+ job.status = 'failed'
539
+ job.finishedAt = new Date().toISOString()
540
+ job.error = staleInstallMessage(job.tool.label, probe?.version, latestVersion)
541
+ }
542
+
543
+ /**
544
+ * Native Claude Code lives outside the npm prefix. `claude update` is the
545
+ * vendor's non-interactive updater; return true when the job is finished.
546
+ */
547
+ async function tryClaudeUpdate(job, latestVersion) {
548
+ if (job.cancelRequested) { markCancelled(job); return true }
549
+ pushLine(job, '$ claude update')
550
+ job.phase = 'installing'
551
+ const outcome = await runCapture('claude', CLAUDE_UPDATE_ARGS, {
552
+ timeoutMs: CLAUDE_UPDATE_TIMEOUT_MS,
553
+ useShell: IS_WINDOWS,
554
+ signal: job.controller.signal,
555
+ onOutput: captureLines(job),
556
+ })
557
+ if (job.status !== 'running') return true
558
+ if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return true }
559
+ if (!outcome.ok) {
560
+ pushLine(job, `— claude update 未完成(退出码 ${outcome.code ?? '信号终止'}),将继续尝试注册表安装 —`)
561
+ return false
562
+ }
563
+ await ensureNpmPrefixOnPath()
564
+ probeCache.delete(job.tool.id)
565
+ const probe = await probeToolWith(job.tool, defaultRunner)
566
+ if (job.cancelRequested) { markCancelled(job); return true }
567
+ if (latestVersion && versionStillOlder(probe.version, latestVersion)) {
568
+ job.postInstallProbe = probe
569
+ pushLine(job, `— claude update 已结束,但探测到的版本仍是 ${probe.version},低于 ${latestVersion} —`)
570
+ return false
571
+ }
572
+ return finishVerifiedInstall(job, probe)
573
+ }
574
+
447
575
  async function runInstallJob(manager, args, job) {
448
576
  if (job.status !== 'running') return
449
577
  job.phase = 'preflight'
@@ -452,10 +580,12 @@ async function runInstallJob(manager, args, job) {
452
580
  probeCache.delete(job.tool.id)
453
581
  const before = await probeToolWith(job.tool, defaultRunner)
454
582
  if (job.cancelRequested) { markCancelled(job); return }
583
+ let latest = null
584
+ let order = null
455
585
  if (before.installed) {
456
- const latest = await latestVersions.lookup(job.tool, { fresh: true }).catch(() => null)
586
+ latest = await latestVersions.lookup(job.tool, { fresh: true }).catch(() => null)
457
587
  if (job.cancelRequested) { markCancelled(job); return }
458
- const order = latest?.version ? compareReleaseVersions(before.version, latest.version) : null
588
+ order = latest?.version ? compareReleaseVersions(before.version, latest.version) : null
459
589
  if (order === 1) {
460
590
  job.status = 'failed'
461
591
  job.finishedAt = new Date().toISOString()
@@ -476,59 +606,76 @@ async function runInstallJob(manager, args, job) {
476
606
  pushLine(job, `— 已是最新版本 ${before.version},但执行入口未就绪:${readiness.reason};重新安装修复 —`)
477
607
  }
478
608
  }
609
+ const plan = officialUpdatePlan({
610
+ toolId: job.tool.id,
611
+ installed: before.installed === true,
612
+ order,
613
+ platform: process.platform,
614
+ })
615
+ let triedClaudeUpdate = false
616
+ if (plan === 'claude-update-then-registry') {
617
+ triedClaudeUpdate = true
618
+ const finished = await tryClaudeUpdate(job, latest?.version)
619
+ if (finished || job.status !== 'running') return
620
+ } else if (plan === 'minimax-official-installer') {
621
+ await runOfficialMiniMaxInstaller(job, {
622
+ reason: '— 已安装的 MiniMax Code 由官方安装目录接管;直接运行官方 Windows 安装脚本以更新到最新版 —',
623
+ })
624
+ return
625
+ }
479
626
  job.phase = 'installing'
480
627
  const outcome = await runCapture(manager, args, {
481
628
  timeoutMs: INSTALL_TIMEOUT_MS,
482
629
  useShell: IS_WINDOWS,
483
630
  signal: job.controller.signal,
484
- onOutput: chunk => {
485
- for (const line of chunk.split(/\r?\n/)) {
486
- if (line.trim()) pushLine(job, line.slice(0, 500))
487
- }
488
- },
631
+ onOutput: captureLines(job),
489
632
  })
490
633
  if (job.status !== 'running') return
491
634
  if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
492
- job.finishedAt = new Date().toISOString()
493
635
  job.exitCode = outcome.code
494
636
  job.timedOut = outcome.timedOut
495
637
  if (outcome.timedOut) {
496
638
  job.status = 'failed'
639
+ job.finishedAt = new Date().toISOString()
497
640
  job.error = `安装超时(${INSTALL_TIMEOUT_MS / 1000}s),已终止。`
498
- } else if (outcome.ok) {
499
- job.phase = 'verifying'
500
- await ensureNpmPrefixOnPath()
501
- probeCache.delete(job.tool.id)
502
- const probe = await probeToolWith(job.tool, defaultRunner)
503
- if (job.cancelRequested) { markCancelled(job); return }
504
- job.postInstallProbe = probe
505
- if (!probe.installed) {
506
- job.status = 'failed'
507
- job.error = `安装命令已完成,但重探测失败:${probe.detail}`
508
- } else {
509
- const latest = await latestVersions.lookup(job.tool).catch(() => null)
510
- if (latest?.version && compareReleaseVersions(probe.version, latest.version) === -1) {
511
- pushLine(job, `— 注意:PATH 上的 ${job.tool.label} 仍是 ${probe.version},低于最新 ${latest.version};可能有另一份旧安装排在 PATH 前面 —`)
512
- }
513
- const readiness = await trustedExecutionReadiness(job.tool.id)
514
- if (job.cancelRequested) { markCancelled(job); return }
515
- if (!readiness.ready) {
516
- if (IS_WINDOWS && job.tool.id === 'minimax-code') await runOfficialMiniMaxInstaller(job)
517
- else {
518
- job.status = 'failed'
519
- job.error = `安装命令和版本探测已成功,但官方执行入口未就绪:${readiness.reason}`
520
- }
521
- } else {
522
- job.status = 'succeeded'
523
- pushLine(job, `— 已安装 ${job.tool.label} ${probe.version ?? ''},官方执行入口已核验(新版本未经插件测试)—`)
524
- }
641
+ return
642
+ }
643
+ if (!outcome.ok) {
644
+ if (IS_WINDOWS && job.tool.id === 'minimax-code') {
645
+ await runOfficialMiniMaxInstaller(job)
646
+ return
525
647
  }
526
- } else if (IS_WINDOWS && job.tool.id === 'minimax-code') {
527
- await runOfficialMiniMaxInstaller(job)
528
- } else {
529
648
  job.status = 'failed'
649
+ job.finishedAt = new Date().toISOString()
530
650
  job.error = `安装命令失败(退出码 ${outcome.code ?? '信号终止'})。${outcome.stderr.split('\n').find(Boolean)?.slice(0, 200) ?? ''}`
651
+ return
652
+ }
653
+ job.phase = 'verifying'
654
+ await ensureNpmPrefixOnPath()
655
+ probeCache.delete(job.tool.id)
656
+ const probe = await probeToolWith(job.tool, defaultRunner)
657
+ if (job.cancelRequested) { markCancelled(job); return }
658
+ job.postInstallProbe = probe
659
+ const latestAfter = (await latestVersions.lookup(job.tool).catch(() => null)) ?? latest
660
+ const stillOlder = Boolean(latestAfter?.version && versionStillOlder(probe.version, latestAfter.version))
661
+ if (stillOlder && job.tool.id === 'claude-code' && !triedClaudeUpdate) {
662
+ triedClaudeUpdate = true
663
+ const finished = await tryClaudeUpdate(job, latestAfter.version)
664
+ if (finished || job.status !== 'running') return
665
+ failBecauseStillOlder(job, job.postInstallProbe ?? probe, latestAfter.version)
666
+ return
667
+ }
668
+ if (stillOlder && IS_WINDOWS && job.tool.id === 'minimax-code') {
669
+ pushLine(job, `— npm 完成后 ${job.tool.label} 仍是 ${probe.version},低于最新 ${latestAfter.version};改用官方安装脚本 —`)
670
+ await runOfficialMiniMaxInstaller(job)
671
+ return
672
+ }
673
+ if (stillOlder) {
674
+ pushLine(job, `— 注意:PATH 上的 ${job.tool.label} 仍是 ${probe.version},低于最新 ${latestAfter.version} —`)
675
+ failBecauseStillOlder(job, probe, latestAfter.version)
676
+ return
531
677
  }
678
+ await finishVerifiedInstall(job, probe)
532
679
  }
533
680
 
534
681
  async function runZCodeInstallJob(job) {
@@ -4,13 +4,15 @@
4
4
  * errors, cost, review and user ratings). One JSON file under the DSH home. Never stores keys.
5
5
  *
6
6
  * Several Host processes may share one DSH home, so every update takes a
7
- * lock file (`state.json.lock`, exclusive create; stale locks are broken),
7
+ * lock file (`state.json.lock`, exclusive create; only stale dead-owner locks
8
+ * are recovered). A busy live owner causes a bounded, explicit timeout,
8
9
  * re-reads the file, applies the change to the fresh copy and writes it back
9
10
  * atomically (temp file + rename). Reads re-read the file when its mtime or
10
11
  * size changed. A file that cannot be parsed is kept as
11
12
  * `state.json.corrupt-<timestamp>` and a notice is recorded instead of
12
13
  * silently discarding the history.
13
14
  */
15
+ import { randomUUID } from 'node:crypto'
14
16
  import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
15
17
  import { homedir } from 'node:os'
16
18
  import { dirname, isAbsolute, join, resolve } from 'node:path'
@@ -39,7 +41,7 @@ const LOCK_STALE_MS = 15_000
39
41
  const LOCK_TIMEOUT_MS = 10_000
40
42
 
41
43
  function emptyState() {
42
- return { version: STATE_VERSION, onboarding: { completedAt: null }, health: null, quota: {}, authFailures: {}, runs: [], dynamicData: { liveBench: null, pricing: null, status: {}, revision: '' }, archivedSpending: sanitizeArchivedSpending(), notices: [], terminalSessions: [] }
44
+ return { version: STATE_VERSION, historyIncomplete: false, onboarding: { completedAt: null }, health: null, quota: {}, authFailures: {}, runs: [], dynamicData: { liveBench: null, pricing: null, status: {}, revision: '' }, archivedSpending: sanitizeArchivedSpending(), notices: [], terminalSessions: [] }
43
45
  }
44
46
 
45
47
  function sanitizeNotices(value) {
@@ -55,16 +57,62 @@ function retainRuns(state, maxRuns) {
55
57
  return state
56
58
  }
57
59
 
60
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
61
+ const nonnegative = value => typeof value === 'number' && Number.isFinite(value) && value >= 0
62
+ function invalidArchivedSpending(value) {
63
+ if (value === undefined || value === null) return false // old v1 files omit it
64
+ if (!record(value)) return true
65
+ for (const [field, pattern] of [['days', /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/u],
66
+ ['months', /^\d{4}-(?:0[1-9]|1[0-2])$/u]]) {
67
+ if (value[field] === undefined) continue
68
+ if (!record(value[field])) return true
69
+ for (const [key, totals] of Object.entries(value[field])) {
70
+ if (!pattern.test(key) || !record(totals)) return true
71
+ if (['costUsd', 'unknown', 'subscriptionUsd', 'subscriptionRuns'].some(name => totals[name] !== undefined
72
+ && (!nonnegative(totals[name]) || (['unknown', 'subscriptionRuns'].includes(name) && !Number.isInteger(totals[name]))))) return true
73
+ }
74
+ }
75
+ return false
76
+ }
77
+
78
+ function invalidRunSpending(run) {
79
+ if (!record(run) || invalidArchivedSpending(run.priorAttemptSpending)) return true
80
+ for (const field of ['packages', 'reviews']) {
81
+ if (run[field] === undefined) continue // missing legacy fields are not fabricated
82
+ if (!Array.isArray(run[field])) return true
83
+ for (const item of run[field]) {
84
+ if (!record(item) || (item.ran !== undefined && typeof item.ran !== 'boolean')) return true
85
+ if (['costUsd', 'referenceCostUsd'].some(name => item[name] !== undefined && item[name] !== null && !nonnegative(item[name]))) return true
86
+ if (item.ran === true || nonnegative(item.costUsd)) {
87
+ const at = item.finishedAt ?? run.createdAt
88
+ if (!nonnegative(at) || !Number.isFinite(new Date(at).getTime())) return true
89
+ }
90
+ }
91
+ }
92
+ return false
93
+ }
94
+
58
95
  function sanitize(value, maxRuns) {
59
- if (!value || typeof value !== 'object' || value.version !== STATE_VERSION) return emptyState()
96
+ if (!value || typeof value !== 'object' || Array.isArray(value) || value.version !== STATE_VERSION
97
+ || !Array.isArray(value.runs)) {
98
+ const error = new Error('模型路由状态版本或结构不受支持;原文件未修改,请先恢复或升级兼容版本。')
99
+ error.code = 'ROUTER_STATE_UNSUPPORTED'
100
+ throw error
101
+ }
102
+ const runs = value.runs.filter(run => run && typeof run.id === 'string')
60
103
  return retainRuns({
61
104
  version: STATE_VERSION,
105
+ // Unlike bounded notices, an incomplete-history marker cannot silently
106
+ // expire. Restoring a verified state file is required to recover budgets.
107
+ historyIncomplete: value.historyIncomplete === true || runs.length !== value.runs.length
108
+ || invalidArchivedSpending(value.archivedSpending) || runs.some(invalidRunSpending)
109
+ || sanitizeNotices(value.notices).some(notice => notice.kind === 'state-corrupt'),
62
110
  onboarding: { completedAt: Number.isFinite(value.onboarding?.completedAt) ? value.onboarding.completedAt : null },
63
111
  health: value.health && Array.isArray(value.health.tools) ? value.health : null,
64
112
  quota: value.quota && typeof value.quota === 'object' && !Array.isArray(value.quota) ? value.quota : {},
65
113
  authFailures: Object.fromEntries(Object.entries(value.authFailures && typeof value.authFailures === 'object' && !Array.isArray(value.authFailures) ? value.authFailures : {})
66
114
  .filter(([, entry]) => Number.isFinite(entry?.at))),
67
- runs: Array.isArray(value.runs) ? value.runs.filter(run => run && typeof run.id === 'string') : [],
115
+ runs,
68
116
  dynamicData: value.dynamicData && typeof value.dynamicData === 'object' && !Array.isArray(value.dynamicData) ? value.dynamicData : { liveBench: null, pricing: null, status: {}, revision: '' },
69
117
  archivedSpending: sanitizeArchivedSpending(value.archivedSpending),
70
118
  notices: sanitizeNotices(value.notices),
@@ -93,36 +141,66 @@ export function terminalSessionRecord(record) {
93
141
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
94
142
  const stamp = at => new Date(at).toISOString().replace(/[:.]/g, '-')
95
143
 
96
- /** Exclusive lock file; a lock older than `staleMs` (crashed holder) is broken. */
144
+ function ownerPid(raw) {
145
+ try {
146
+ const pid = JSON.parse(raw).pid
147
+ if (Number.isSafeInteger(pid) && pid > 0) return pid
148
+ } catch { /* support the previous pid/timestamp format */ }
149
+ const pid = Number(/^(\d+)\s+\d+\s*$/u.exec(raw)?.[1])
150
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : null
151
+ }
152
+
153
+ function ownerIsDead(pid) {
154
+ if (pid === null) return false
155
+ try { process.kill(pid, 0); return false }
156
+ catch (error) { return error?.code === 'ESRCH' }
157
+ }
158
+
159
+ /** Never steal a live or unknown owner's lock, even after the wait timeout. */
97
160
  async function acquireLock(lockFile, { staleMs = LOCK_STALE_MS, timeoutMs = LOCK_TIMEOUT_MS, now = Date.now } = {}) {
98
- const started = now()
161
+ // Elapsed waits must not freeze or jump when wall-clock time changes.
162
+ const started = performance.now()
99
163
  for (let attempt = 0; ; attempt += 1) {
100
164
  try {
101
165
  const handle = await open(lockFile, 'wx')
102
- await handle.writeFile(`${process.pid} ${now()}\n`, 'utf8').catch(() => {})
166
+ const owner = `${JSON.stringify({ pid: process.pid, token: randomUUID(), at: now() })}\n`
167
+ try { await handle.writeFile(owner, 'utf8') }
168
+ catch (error) { await handle.close(); await unlink(lockFile).catch(() => {}); throw error }
103
169
  await handle.close()
104
- return async () => { await unlink(lockFile).catch(() => {}) }
170
+ return async () => {
171
+ if (await readFile(lockFile, 'utf8').catch(() => null) === owner) await unlink(lockFile).catch(() => {})
172
+ }
105
173
  } catch (error) {
106
174
  if (error?.code !== 'EEXIST') throw error
107
175
  const info = await stat(lockFile).catch(() => null)
108
- if (info && now() - info.mtimeMs > staleMs) {
109
- await unlink(lockFile).catch(() => {})
110
- continue
176
+ const owner = await readFile(lockFile, 'utf8').catch(() => null)
177
+ if (info && owner !== null && now() - info.mtimeMs > staleMs && ownerIsDead(ownerPid(owner))) {
178
+ const currentInfo = await stat(lockFile).catch(() => null)
179
+ if (currentInfo?.ino === info.ino && currentInfo?.mtimeMs === info.mtimeMs
180
+ && await readFile(lockFile, 'utf8').catch(() => null) === owner) {
181
+ await unlink(lockFile).catch(() => {})
182
+ continue
183
+ }
111
184
  }
112
- if (now() - started > timeoutMs) {
113
- // A live holder that never releases: break it rather than lose the write.
114
- await unlink(lockFile).catch(() => {})
115
- continue
185
+ if (performance.now() - started >= timeoutMs) {
186
+ const failure = new Error('模型路由状态正在被其他操作使用,保存未完成;请稍后重试。')
187
+ failure.code = 'ROUTER_STATE_LOCK_TIMEOUT'
188
+ throw failure
116
189
  }
117
190
  await sleep(Math.min(5 + attempt * 5, 50) + Math.floor(Math.random() * 10))
118
191
  }
119
192
  }
120
193
  }
121
194
 
122
- export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUNS, now = Date.now } = {}) {
195
+ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUNS, now = Date.now,
196
+ lockTimeoutMs = LOCK_TIMEOUT_MS, lockStaleMs = LOCK_STALE_MS } = {}) {
197
+ if (![lockTimeoutMs, lockStaleMs].every(value => Number.isFinite(value) && value >= 1 && value <= 3_600_000)) {
198
+ throw new RangeError('Lock timing must be between 1 and 3600000 milliseconds')
199
+ }
123
200
  const historyLimit = Number.isInteger(maxRuns) && maxRuns > 0 ? Math.min(maxRuns, MAX_RUNS) : MAX_RUNS
124
201
  let cache = null
125
202
  let cacheKey = ''
203
+ let cacheCorrupt = false
126
204
  let chain = Promise.resolve()
127
205
  let pendingNotices = []
128
206
  const lockFile = `${file}.lock`
@@ -131,43 +209,53 @@ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUN
131
209
  return info ? `${info.mtimeMs}:${info.size}:${info.ino}` : 'missing'
132
210
  }
133
211
  /** Parse the file; keep an unparseable file as a backup and record a notice. */
134
- const readFresh = async () => {
212
+ const readFresh = async ({ recoverCorrupt = false } = {}) => {
135
213
  let raw
136
214
  try { raw = await readFile(file, 'utf8') }
137
215
  catch (error) {
138
216
  if (error?.code === 'ENOENT') return { state: emptyState(), corrupt: false }
139
217
  throw error
140
218
  }
141
- try { return { state: sanitize(JSON.parse(raw), historyLimit), corrupt: false } }
219
+ let parsed
220
+ try { parsed = JSON.parse(raw) }
142
221
  catch {
222
+ // Readers do not rename files outside the writer lock. Recheck under the
223
+ // lock so a concurrent valid replacement cannot be discarded as corrupt.
224
+ if (!recoverCorrupt) return { state: emptyState(), corrupt: true }
143
225
  const at = now()
144
- const backup = `${file}.corrupt-${stamp(at)}`
145
- let saved = true
146
- try { await rename(file, backup) } catch { saved = false }
226
+ const backup = `${file}.corrupt-${stamp(at)}-${randomUUID()}`
227
+ await rename(file, backup)
147
228
  pendingNotices.push({
148
- kind: 'state-corrupt', at, backup: saved ? backup : null,
149
- message: saved
150
- ? `模型路由状态文件已损坏,无法读取,已备份为 ${backup} 并重新开始记录。执行历史、评价和订阅冷却信息需要从备份中人工恢复。`
151
- : '模型路由状态文件已损坏,无法读取,备份失败;已重新开始记录。',
229
+ kind: 'state-corrupt', at, backup,
230
+ message: `模型路由状态文件已损坏,无法读取,已备份为 ${backup} 并重新开始记录。执行历史、评价和订阅冷却信息需要从备份中人工恢复。`,
152
231
  })
153
- return { state: emptyState(), corrupt: true }
232
+ return { state: { ...emptyState(), historyIncomplete: true }, corrupt: true }
154
233
  }
234
+ // Valid JSON with an unknown schema is not corrupt: preserve it exactly,
235
+ // and refuse both reads-as-empty and writes that would downgrade it.
236
+ return { state: sanitize(parsed, historyLimit), corrupt: false }
155
237
  }
156
238
  const load = async () => {
157
239
  const key = await fileKey()
158
240
  if (cache && key === cacheKey) return cache
159
241
  const fresh = await readFresh()
160
242
  cache = fresh.state
161
- cacheKey = fresh.corrupt ? '' : key
243
+ cacheKey = key
244
+ cacheCorrupt = fresh.corrupt
162
245
  return cache
163
246
  }
164
247
  const persist = async state => {
165
248
  await mkdir(dirname(file), { recursive: true })
249
+ const committed = structuredClone(state)
250
+ const serialized = `${JSON.stringify(committed)}\n`
166
251
  const temp = `${file}.${process.pid}.${now()}.${Math.random().toString(36).slice(2, 8)}.tmp`
167
- await writeFile(temp, `${JSON.stringify(state)}\n`, 'utf8')
168
- await rename(temp, file)
169
- cache = state
252
+ try {
253
+ await writeFile(temp, serialized, 'utf8')
254
+ await rename(temp, file)
255
+ } finally { await unlink(temp).catch(() => {}) }
256
+ cache = committed
170
257
  cacheKey = await fileKey()
258
+ cacheCorrupt = false
171
259
  }
172
260
  /**
173
261
  * Serialize read-modify-write updates within this process and, through the
@@ -176,17 +264,26 @@ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUN
176
264
  const update = change => {
177
265
  const next = chain.then(async () => {
178
266
  await mkdir(dirname(file), { recursive: true })
179
- const release = await acquireLock(lockFile, { now })
267
+ const release = await acquireLock(lockFile, { now, timeoutMs: lockTimeoutMs, staleMs: lockStaleMs })
180
268
  try {
181
269
  cache = null
182
- const state = await load()
183
- if (pendingNotices.length) { state.notices.push(...pendingNotices); pendingNotices = [] }
270
+ const { state } = await readFresh({ recoverCorrupt: true })
271
+ if (pendingNotices.length) state.notices.push(...pendingNotices)
184
272
  const result = await change(state)
273
+ const returned = result === undefined ? undefined : structuredClone(result)
185
274
  retainRuns(state, historyLimit)
186
275
  state.notices = sanitizeNotices(state.notices)
187
276
  state.terminalSessions = sanitizeTerminalSessions(state.terminalSessions)
188
277
  await persist(state)
189
- return result
278
+ pendingNotices = []
279
+ return returned
280
+ } catch (error) {
281
+ // A rejected callback or disk write must not leak uncommitted state
282
+ // into the reader cache, including partial rating/budget changes.
283
+ cache = null
284
+ cacheKey = ''
285
+ cacheCorrupt = false
286
+ throw error
190
287
  } finally {
191
288
  await release()
192
289
  }
@@ -198,9 +295,13 @@ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUN
198
295
  const read = async () => {
199
296
  await chain
200
297
  const state = await load()
201
- if (pendingNotices.length) {
298
+ if (cacheCorrupt || pendingNotices.length) {
202
299
  const notices = [...pendingNotices]
203
- try { await update(() => {}) } catch { return structuredClone({ ...state, notices: [...state.notices, ...notices] }) }
300
+ try { await update(() => {}) }
301
+ catch {
302
+ return structuredClone({ ...state, notices: [...state.notices, ...notices,
303
+ { kind: 'state-recovery-pending', at: now(), message: '模型路由状态恢复未完成;原文件保留,请稍后重试。' }] })
304
+ }
204
305
  return structuredClone(cache)
205
306
  }
206
307
  return structuredClone(state)
@@ -233,10 +334,10 @@ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUN
233
334
  return entry
234
335
  }),
235
336
  appendRun: run => update(current => { current.runs.push(run); return run }),
236
- updateRun: (id, change) => update(current => {
337
+ updateRun: (id, change) => update(async current => {
237
338
  const run = current.runs.find(item => item.id === id)
238
339
  if (!run) throw new Error(`未找到运行记录 ${id}`)
239
- change(run)
340
+ await change(run)
240
341
  return structuredClone(run)
241
342
  }),
242
343
  /** Remove one notice (for example after the user read the corrupt-file warning). */