@goodandready/dsh-key-rotation 0.8.73 → 0.8.75

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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.75 (2026-10-30)
4
+
5
+ ### Dependencies, Cleanup & Audit Finalization (Block 3 Release)
6
+ - **Zero Runtime Dependencies & Security Hardening (Issues #520, #535)**: Completely removed `undici` dependency and eliminated 5 high/moderate vulnerabilities (`pnpm audit --prod` reports 0 vulnerabilities); `safe-fetch.js` rewritten using standard `globalThis.fetch` and pure Node.js DNS/IP validation.
7
+ - **Atomic I/O Cleanup (Issue #521)**: Crash-safe file writes now safely clean up `.tmp` files on write/sync/rename errors, and `cleanupStaleTmpFiles` purges abandoned temporary files on startup.
8
+ - **Request Budget Inheritance & Signal Management (Issue #464)**: `maxAttempts` and `maxDurationMs` properly propagated from settings to `buildRuntime()` and `pool-builder`; `createRequestBudget` removes caller `AbortSignal` listeners upon `dispose()`; guaranteed cleanup in `rotate.js` `finally` block; traffic smoother now receives `budget.signal`.
9
+ - **Selection Trace Diagnosability (Issue #461)**: Candidate evaluation now logs detailed `SKIPPED` steps (`PAUSED`, `REVOKED`, `BROKEN`, `DRAINING`, `COOLDOWN`, `CAPABILITY_MISMATCH`, `QUOTA_EXHAUSTED`); added interactive Selection Trace UI inspector in settings card.
10
+ - **Managed Key Drain UI & Concurrency Robustness (Issue #463)**: Added interactive Managed Key Drain section in settings card; enhanced `ConcurrencyTracker` with `touch()` and prolonged 30m stale threshold for long-running streaming generation.
11
+ - **Offline Simulator Parity (Issue #462)**: Audit verification finalized following Block 2 quarantine cloning, Map lookup, and per-Context isolation fixes.
12
+
13
+ ## 0.8.74 (2026-10-30)
14
+
15
+ ### Rotation & Routing Reliability (Block 2 Release)
16
+ - **Error Status Normalization (Issue #545)**: `isSwitchableError` now normalizes string and numeric `code: "429"` / `429` to HTTP 429 when `status` is absent, ensuring default `switchCodes` trigger key rotation.
17
+ - **Quick Action Skip (Issue #540)**: Quick action skip now atomically synchronizes both `targetPool.state.pointer` and `cursor`, ensuring resolver round-robin picks the newly advanced key immediately.
18
+ - **Tiers & Tier-Priority Routing (Issue #541)**: Propagate `p.tiers` to `pool-builder` options, fix `typeof tiers === 'object'` syntax error, and add `tier-priority` strategy to Schemastery and resolver.
19
+ - **Traffic Smoothing Leaky Bucket (Issue #538)**: Implemented Generic Cell Rate Algorithm (GCRA) in `TrafficSmoother` to atomically reserve micro-queue slots ahead of sleep, preventing two concurrent requests from claiming the same time slot.
20
+ - **Simulator Quota & Broken Key Parity (Issue #534)**: `simulateRouting` now clones `brokenUntil`, `tokenUsage`, and `quotaWindows` into simulator pool state, properly filtering out unavailable keys.
21
+ - **Simulator Provider Map Lookup (Issue #536)**: Corrected `runtime.providerToPool` Map resolution in `ops-telemetry` simulator route, returning 404 for unrecognized providers.
22
+ - **Config Import Concurrency (Issue #553)**: Added optimistic revision check via `expectedRevision` in `handleConfigImport`, returning 409 Conflict upon concurrent modification.
23
+ - **Config Import Fail-Closed (Issue #533)**: `handleConfigImport` fails closed with 503 Service Unavailable when `settings.replace` is unavailable, preventing false-positive `{ applied: true }`.
24
+ - **Instance & Context Isolation (Issue #544)**: Introduced `KeyDrainManager`, `SelectionTraceStore`, and `DiagBuffer` classes with per-context lifecycle binding; disposal of one plugin instance cleanly purges only its own draining keys and telemetry without corrupting peer instances.
25
+
3
26
  ## 0.8.73 (2026-10-30)
4
27
 
5
28
  ### Security & Hardening (Block 1 Security Release)
package/lib/atomic-io.js CHANGED
@@ -1,5 +1,6 @@
1
- // lib/atomic-io.js — crash-safe file writes and safe JSON loads (#264).
1
+ // lib/atomic-io.js — crash-safe file writes and safe JSON loads (#264, #521).
2
2
  // Never overwrite a non-empty destination with empty/corrupt parse results.
3
+ // Clean up orphaned temporary files on failure or during startup.
3
4
 
4
5
  import { promises as fs } from 'node:fs';
5
6
  import path from 'node:path';
@@ -8,6 +9,7 @@ import crypto from 'node:crypto';
8
9
 
9
10
  /**
10
11
  * Atomically write data to filePath (temp + rename in same directory).
12
+ * Cleans up temporary file if open, write, sync, or rename fails (#521).
11
13
  * @param {string} filePath
12
14
  * @param {string|Buffer} data
13
15
  */
@@ -15,14 +17,55 @@ export async function atomicWriteFile(filePath, data) {
15
17
  const dir = path.dirname(filePath);
16
18
  await fs.mkdir(dir, { recursive: true });
17
19
  const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
18
- const fh = await fs.open(tmp, 'w');
20
+ let fh = null;
19
21
  try {
22
+ fh = await fs.open(tmp, 'w');
20
23
  await fh.writeFile(data);
21
24
  await fh.sync();
22
- } finally {
23
25
  await fh.close();
26
+ fh = null;
27
+ await fs.rename(tmp, filePath);
28
+ } catch (err) {
29
+ if (fh) {
30
+ try { await fh.close(); } catch {}
31
+ }
32
+ try { await fs.unlink(tmp); } catch {}
33
+ throw err;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Clean up stale temporary files left behind by crashed or interrupted writes (#521).
39
+ * @param {string} filePath
40
+ * @param {number} [maxAgeMs=3600000]
41
+ * @returns {Promise<number>} number of files unlinked
42
+ */
43
+ export async function cleanupStaleTmpFiles(filePath, maxAgeMs = 3600000) {
44
+ if (!filePath || typeof filePath !== 'string') return 0;
45
+ try {
46
+ const dir = path.dirname(filePath);
47
+ const base = path.basename(filePath);
48
+ const prefix = `.${base}.`;
49
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
50
+ const now = Date.now();
51
+ let cleaned = 0;
52
+ for (const entry of entries) {
53
+ if (!entry.isFile()) continue;
54
+ if (entry.name.startsWith(prefix) && entry.name.endsWith('.tmp')) {
55
+ const fullPath = path.join(dir, entry.name);
56
+ try {
57
+ const st = await fs.stat(fullPath);
58
+ if (now - st.mtimeMs > maxAgeMs) {
59
+ await fs.unlink(fullPath);
60
+ cleaned++;
61
+ }
62
+ } catch {}
63
+ }
64
+ }
65
+ return cleaned;
66
+ } catch {
67
+ return 0;
24
68
  }
25
- await fs.rename(tmp, filePath);
26
69
  }
27
70
 
28
71
  /**
package/lib/client.js CHANGED
@@ -61,6 +61,24 @@ window.__ModuleLoader__.load({
61
61
  sectionStatsDesc: 'Real-time telemetry of active key pools, healthy credentials, and failover status.',
62
62
  sectionFailover: 'Failover Triggers & Error Codes',
63
63
  sectionFailoverDesc: 'Select error conditions that trigger automatic switching to the next available key.',
64
+ maxAttemptsLabel: 'Max Attempts (Budget)',
65
+ maxDurationLabel: 'Max Duration ms (Budget)',
66
+ traceTitle: 'Key Selection Traces',
67
+ traceDesc: 'Candidate evaluation history, skips and selection decisions for requests.',
68
+ traceRefresh: 'Refresh Traces',
69
+ traceClear: 'Clear History',
70
+ traceEmpty: 'No selection traces recorded yet.',
71
+ traceSelected: 'Selected Key',
72
+ traceOutcome: 'Outcome',
73
+ drainTitle: 'Managed Key Drain',
74
+ drainDesc: 'Safely transition a key out of rotation waiting for active requests to finish.',
75
+ drainKeyPlaceholder: 'Key ref (e.g. DEEPSEEK_API_KEY_2)',
76
+ drainStart: 'Start Drain',
77
+ drainCancel: 'Cancel Drain',
78
+ drainActiveStreams: 'Active requests',
79
+ drainCanComplete: 'Ready to remove',
80
+ drainDraining: 'Draining Keys',
81
+ drainEmpty: 'No keys currently draining.',
64
82
  sectionTiming: 'Timing & Rotation Schedule',
65
83
  sectionTimingDesc: 'Configure cooldown durations and scheduled periodic rotation windows.',
66
84
  sectionBackup: 'Backup & Snapshots',
@@ -317,6 +335,24 @@ window.__ModuleLoader__.load({
317
335
  sectionStatsDesc: '活跃密钥池、健康凭证和故障转移状态的实时遥测。',
318
336
  sectionFailover: '故障转移触发器与错误代码',
319
337
  sectionFailoverDesc: '选择触发自动切换至下一个可用密钥的错误条件。',
338
+ maxAttemptsLabel: '最大重试次数 (预算)',
339
+ maxDurationLabel: '最大超时毫秒 (预算)',
340
+ traceTitle: '密钥选择追踪 (Trace)',
341
+ traceDesc: '查看请求的候选密钥评估、跳过原因和最终选择决策。',
342
+ traceRefresh: '刷新追踪',
343
+ traceClear: '清空历史',
344
+ traceEmpty: '暂无选择追踪记录。',
345
+ traceSelected: '所选密钥',
346
+ traceOutcome: '结果',
347
+ drainTitle: '安全下线密钥 (Drain)',
348
+ drainDesc: '平滑下线密钥并等待当前在途请求完成,避免中途断流。',
349
+ drainKeyPlaceholder: '密钥标识 (例如 DEEPSEEK_API_KEY_2)',
350
+ drainStart: '开始下线',
351
+ drainCancel: '取消下线',
352
+ drainActiveStreams: '在途请求数',
353
+ drainCanComplete: '可安全移除',
354
+ drainDraining: '下线中的密钥',
355
+ drainEmpty: '当前没有正在下线的密钥。',
320
356
  sectionTiming: '时间与轮换计划',
321
357
  sectionTimingDesc: '配置冷却持续时间和定期轮换计划窗口。',
322
358
  sectionBackup: '备份与快照',
@@ -1257,6 +1293,16 @@ window.__ModuleLoader__.load({
1257
1293
  // Auto-Discovery State (#491)
1258
1294
  const [discoveredKeys, setDiscoveredKeys] = React.useState([]);
1259
1295
  const [autoImporting, setAutoImporting] = React.useState(false);
1296
+
1297
+ // Selection Trace State (#461)
1298
+ const [selectionTraces, setSelectionTraces] = React.useState([]);
1299
+ const [loadingTraces, setLoadingTraces] = React.useState(false);
1300
+
1301
+ // Managed Key Drain State (#463)
1302
+ const [drainingKeys, setDrainingKeys] = React.useState([]);
1303
+ const [drainRefInput, setDrainRefInput] = React.useState('');
1304
+ const [loadingDrain, setLoadingDrain] = React.useState(false);
1305
+ const [drainActionError, setDrainActionError] = React.useState('');
1260
1306
  const [autoImportMsg, setAutoImportMsg] = React.useState('');
1261
1307
 
1262
1308
  // Test Matrix State (#490)
@@ -1472,6 +1518,70 @@ window.__ModuleLoader__.load({
1472
1518
  }
1473
1519
  };
1474
1520
 
1521
+ const loadTraces = async () => {
1522
+ setLoadingTraces(true);
1523
+ try {
1524
+ const res = await fetch('/dsh-key-rotation/trace?limit=20');
1525
+ const data = await res.json();
1526
+ if (data && data.ok) {
1527
+ setSelectionTraces(data.traces || []);
1528
+ }
1529
+ } catch (_) {}
1530
+ finally {
1531
+ setLoadingTraces(false);
1532
+ }
1533
+ };
1534
+
1535
+ const clearTraces = async () => {
1536
+ try {
1537
+ await fetch('/dsh-key-rotation/trace', { method: 'DELETE' });
1538
+ setSelectionTraces([]);
1539
+ } catch (_) {}
1540
+ };
1541
+
1542
+ const loadDrainStatus = async () => {
1543
+ setLoadingDrain(true);
1544
+ try {
1545
+ const res = await fetch('/dsh-key-rotation/drain');
1546
+ const data = await res.json();
1547
+ if (data && data.ok) {
1548
+ setDrainingKeys(data.draining || []);
1549
+ }
1550
+ } catch (_) {}
1551
+ finally {
1552
+ setLoadingDrain(false);
1553
+ }
1554
+ };
1555
+
1556
+ const startKeyDrain = async (ref) => {
1557
+ if (!ref || !ref.trim()) return;
1558
+ setDrainActionError('');
1559
+ try {
1560
+ const res = await fetch('/dsh-key-rotation/drain', {
1561
+ method: 'POST',
1562
+ headers: { 'Content-Type': 'application/json' },
1563
+ body: JSON.stringify({ ref: ref.trim() }),
1564
+ });
1565
+ const data = await res.json();
1566
+ if (data && data.ok) {
1567
+ setDrainRefInput('');
1568
+ await loadDrainStatus();
1569
+ } else {
1570
+ setDrainActionError(data?.error?.message || 'Drain request failed');
1571
+ }
1572
+ } catch (e) {
1573
+ setDrainActionError(String(e?.message || e));
1574
+ }
1575
+ };
1576
+
1577
+ const cancelKeyDrain = async (ref) => {
1578
+ if (!ref) return;
1579
+ try {
1580
+ await fetch(`/dsh-key-rotation/drain?ref=${encodeURIComponent(ref)}`, { method: 'DELETE' });
1581
+ await loadDrainStatus();
1582
+ } catch (_) {}
1583
+ };
1584
+
1475
1585
  const [optimisticReset, setOptimisticReset] = React.useState({});
1476
1586
  const [selected, setSelected] = React.useState(new Set());
1477
1587
  const [bulkCooldown, setBulkCooldown] = React.useState('');
@@ -2928,6 +3038,10 @@ window.__ModuleLoader__.load({
2928
3038
  field(t('rpmLimitLabel'), textInput(String(val.rpmLimit ?? 0), (v) => setField((cur) => ({ ...cur, rpmLimit: Number(v) || 0 })))),
2929
3039
  field(t('tpmLimitLabel'), textInput(String(val.tpmLimit ?? 0), (v) => setField((cur) => ({ ...cur, tpmLimit: Number(v) || 0 }))))
2930
3040
  ),
3041
+ h('div', { className: 'krot-grid-2', style: { marginTop: '10px' } },
3042
+ field(t('maxAttemptsLabel'), textInput(String(val.maxAttempts ?? 0), (v) => setField((cur) => ({ ...cur, maxAttempts: Number(v) || 0 })))),
3043
+ field(t('maxDurationLabel'), textInput(String(val.maxDurationMs ?? 0), (v) => setField((cur) => ({ ...cur, maxDurationMs: Number(v) || 0 }))))
3044
+ ),
2931
3045
  h('div', { className: 'krot-grid-3', style: { marginTop: '10px' } },
2932
3046
  field(t('routingStrategyLabel'), h('select', {
2933
3047
  className: 'krot-in',
@@ -3017,6 +3131,68 @@ window.__ModuleLoader__.load({
3017
3131
  ) : null
3018
3132
  );
3019
3133
 
3134
+ const drainSection = h('div', { className: 'krot-section-card' },
3135
+ h('div', { className: 'krot-section-title' },
3136
+ h('span', null, '⏳ ' + t('drainTitle')),
3137
+ h('span', { className: 'krot-badge ' + (drainingKeys.length > 0 ? 'krot-badge-warn' : 'krot-badge-ok') },
3138
+ drainingKeys.length > 0 ? (drainingKeys.length + ' ' + t('drainDraining')) : t('drainEmpty')
3139
+ )
3140
+ ),
3141
+ h('div', { className: 'krot-section-desc' }, t('drainDesc')),
3142
+ h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center', marginTop: '10px' } },
3143
+ h('input', {
3144
+ className: 'krot-in',
3145
+ placeholder: t('drainKeyPlaceholder'),
3146
+ value: drainRefInput,
3147
+ onChange: (e) => setDrainRefInput(e.target.value),
3148
+ style: { maxWidth: '240px' },
3149
+ }),
3150
+ btn(t('drainStart'), () => startKeyDrain(drainRefInput), { primary: true, disabled: !drainRefInput.trim() }),
3151
+ btn(t('traceRefresh'), loadDrainStatus, { disabled: loadingDrain })
3152
+ ),
3153
+ drainActionError ? h('div', { className: 'krot-warn', style: { marginTop: '8px' } }, '⚠ ' + drainActionError) : null,
3154
+ drainingKeys.length > 0 ? h('div', { style: { marginTop: '10px', display: 'flex', flexDirection: 'column', gap: '6px' } },
3155
+ drainingKeys.map((dk) => h('div', { key: dk.ref, className: 'krot-stat-box', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 12px' } },
3156
+ h('span', { style: { fontWeight: 600 } }, dk.ref),
3157
+ h('span', { className: 'krot-badge ' + (dk.canComplete ? 'krot-badge-ok' : 'krot-badge-warn') },
3158
+ dk.canComplete ? t('drainCanComplete') : (t('drainActiveStreams') + ': ' + dk.inFlight)
3159
+ ),
3160
+ btn(t('drainCancel'), () => cancelKeyDrain(dk.ref), { danger: true })
3161
+ ))
3162
+ ) : null
3163
+ );
3164
+
3165
+ const traceSection = h('div', { className: 'krot-section-card' },
3166
+ h('div', { className: 'krot-section-title' },
3167
+ h('span', null, '🧭 ' + t('traceTitle')),
3168
+ h('span', { className: 'krot-badge krot-badge-ok' }, selectionTraces.length + ' traces')
3169
+ ),
3170
+ h('div', { className: 'krot-section-desc' }, t('traceDesc')),
3171
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', marginTop: '10px' } },
3172
+ btn(loadingTraces ? '...' : t('traceRefresh'), loadTraces, { disabled: loadingTraces }),
3173
+ btn(t('traceClear'), clearTraces, { disabled: selectionTraces.length === 0 })
3174
+ ),
3175
+ selectionTraces.length > 0 ? h('div', { style: { marginTop: '10px', display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '280px', overflowY: 'auto' } },
3176
+ selectionTraces.slice().reverse().map((tr) => h('div', { key: tr.requestId, style: { border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '6px', padding: '8px', fontSize: '11px' } },
3177
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', marginBottom: '4px' } },
3178
+ h('span', { style: { fontWeight: 600 } }, (tr.provider || 'default') + (tr.model ? ' / ' + tr.model : '')),
3179
+ h('span', { className: 'krot-badge ' + (tr.outcome === 'SUCCESS' ? 'krot-badge-ok' : 'krot-badge-bad') }, tr.outcome)
3180
+ ),
3181
+ h('div', { style: { color: 'var(--dsw-alias-label-secondary)', marginBottom: '4px' } },
3182
+ tr.selectedRef ? (t('traceSelected') + ': ' + tr.selectedRef) : ''
3183
+ ),
3184
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', paddingLeft: '8px', borderLeft: '2px solid var(--dsw-alias-border-l2)' } },
3185
+ (tr.steps || []).map((st, i) => h('div', { key: i, style: { display: 'flex', gap: '6px' } },
3186
+ h('span', { style: { color: st.decision === 'SELECTED' ? 'var(--dsw-alias-state-success-primary)' : st.decision === 'SKIPPED' ? 'var(--dsw-alias-label-tertiary)' : 'var(--dsw-alias-state-danger-primary)', fontWeight: 600 } }, st.decision),
3187
+ h('span', { style: { fontWeight: 600 } }, st.ref || '-'),
3188
+ h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, st.reasonCode ? ('[' + st.reasonCode + ']') : ''),
3189
+ h('span', null, st.details || '')
3190
+ ))
3191
+ )
3192
+ ))
3193
+ ) : h('div', { className: 'krot-hint', style: { marginTop: '8px' } }, t('traceEmpty'))
3194
+ );
3195
+
3020
3196
  const backupSection = h('div', { className: 'krot-section-card' },
3021
3197
  h('div', { className: 'krot-section-title' },
3022
3198
  h('span', null, '💾 ' + t('sectionBackup')),
@@ -3305,6 +3481,8 @@ window.__ModuleLoader__.load({
3305
3481
  poolsSection,
3306
3482
  failoverSection,
3307
3483
  timingSection,
3484
+ drainSection,
3485
+ traceSection,
3308
3486
  backupSection,
3309
3487
  simulatorSection,
3310
3488
  ),
@@ -148,7 +148,7 @@ export class CloneLifecycleManager {
148
148
  startDrain(cloneId, provider, onDeregister) {
149
149
  if (this.drainingClones.has(cloneId)) return;
150
150
 
151
- markKeyDraining(cloneId, provider);
151
+ markKeyDraining(cloneId, provider, this.keyDrainManager);
152
152
  const entry = {
153
153
  cloneId,
154
154
  provider,
@@ -176,7 +176,7 @@ export class CloneLifecycleManager {
176
176
 
177
177
  if (inFlight === 0) {
178
178
  this.drainingClones.delete(cloneId);
179
- cancelKeyDrain(cloneId);
179
+ cancelKeyDrain(cloneId, this.keyDrainManager);
180
180
  if (this.logger?.info) {
181
181
  this.logger.info(`[dsh-key-rotation] clone ${cloneId} (${entry.provider}) drained cleanly; safe to deregister`);
182
182
  }
@@ -191,11 +191,11 @@ export class CloneLifecycleManager {
191
191
 
192
192
  cancelDrain(cloneId) {
193
193
  this.drainingClones.delete(cloneId);
194
- cancelKeyDrain(cloneId);
194
+ cancelKeyDrain(cloneId, this.keyDrainManager);
195
195
  }
196
196
 
197
197
  isDraining(cloneId) {
198
- return this.drainingClones.has(cloneId) || isKeyDraining(cloneId);
198
+ return this.drainingClones.has(cloneId) || isKeyDraining(cloneId, this.keyDrainManager);
199
199
  }
200
200
 
201
201
  getDrainingList() {
@@ -209,7 +209,7 @@ export class CloneLifecycleManager {
209
209
 
210
210
  destroy() {
211
211
  for (const cloneId of this.drainingClones.keys()) {
212
- cancelKeyDrain(cloneId);
212
+ cancelKeyDrain(cloneId, this.keyDrainManager);
213
213
  }
214
214
  this.drainingClones.clear();
215
215
  }
@@ -1,7 +1,8 @@
1
- // concurrency.js — per-key in-flight counter + least-connections picking (issues #193, #407).
1
+ // concurrency.js — per-key in-flight counter + least-connections picking (issues #193, #407, #463).
2
2
 
3
3
  const CONCURRENCY_DEFAULT_LIMIT = 0;
4
- const CONCURRENCY_STALE_LOCK_MS = 5 * 60 * 1000;
4
+ // Default stale timeout: 30 minutes to support prolonged streaming generation (#463)
5
+ const CONCURRENCY_STALE_LOCK_MS = 30 * 60 * 1000;
5
6
 
6
7
  export class ConcurrencyTracker {
7
8
  constructor(opts) {
@@ -42,6 +43,14 @@ export class ConcurrencyTracker {
42
43
  return true;
43
44
  }
44
45
 
46
+ touch(ref, now) {
47
+ now = now || Date.now();
48
+ const e = this._inFlight.get(ref);
49
+ if (e && e.count > 0) {
50
+ e.lastAcquired = now;
51
+ }
52
+ }
53
+
45
54
  release(ref, now) {
46
55
  now = now || Date.now();
47
56
  const e = this._inFlight.get(ref);
@@ -55,13 +64,24 @@ export class ConcurrencyTracker {
55
64
  if (!ref) return 0;
56
65
  const e = this._inFlight.get(ref);
57
66
  if (!e) return 0;
58
- if (now - e.lastAcquired > this._staleMs) return 0;
67
+ if (now - e.lastAcquired > this._staleMs) {
68
+ e.count = 0;
69
+ return 0;
70
+ }
59
71
  return e.count;
60
72
  }
61
73
 
62
- snapshot() {
74
+ snapshot(now) {
75
+ now = now || Date.now();
63
76
  const out = {};
64
- for (const [k, v] of this._inFlight) out[k] = { count: v.count };
77
+ for (const [k, v] of this._inFlight) {
78
+ let count = v.count;
79
+ if (now - v.lastAcquired > this._staleMs) {
80
+ count = 0;
81
+ v.count = 0;
82
+ }
83
+ out[k] = { count };
84
+ }
65
85
  return out;
66
86
  }
67
87
 
package/lib/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { KeyDrainManager } from './key-drain.js';
2
+ import { SelectionTraceStore } from './selection-trace.js';
3
+ import { DiagBuffer } from './ops-telemetry.js';
1
4
  import { createResolver } from './resolver.js';
2
5
  import { decryptSecret } from './crypto-storage.js';
3
6
  // dsh-key-rotation — per-provider API key rotation for DeepSeek Harness.
@@ -98,7 +101,7 @@ const SettingsSchema = Schema.object({
98
101
  rateLimitThreshold: Schema.number().default(0.1),
99
102
  proactiveRateLimitGuard: Schema.boolean().default(true),
100
103
  selfHealingIntervalMinutes: Schema.number().default(30),
101
- routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency', 'sticky']).default('round-robin'),
104
+ routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency', 'sticky', 'tier-priority']).default('round-robin'),
102
105
  rpmLimit: Schema.number().default(0),
103
106
  tpmLimit: Schema.number().default(0),
104
107
  webhookActionToken: Schema.string().role('secret').default(''),
@@ -148,7 +151,7 @@ const SettingsSchema = Schema.object({
148
151
  })).default({}),
149
152
  cooldownMs: Schema.number(),
150
153
  maxCooldownMs: Schema.number(),
151
- routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency', 'sticky']),
154
+ routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency', 'sticky', 'tier-priority']),
152
155
  proactiveRateLimitGuard: Schema.boolean(),
153
156
  rpmLimit: Schema.number(),
154
157
  tpmLimit: Schema.number(),
@@ -196,13 +199,16 @@ export function apply(ctx, config = {}) {
196
199
  const latencyHistogram = new LatencyHistogram();
197
200
  const quotaStore = new QuotaStore();
198
201
  const concurrencyTracker = new ConcurrencyTracker();
202
+ const keyDrainManager = new KeyDrainManager();
203
+ const selectionTraceStore = new SelectionTraceStore();
204
+ const diagBuffer = new DiagBuffer();
199
205
 
200
206
  const moduleBreaker = new CircuitBreaker({ threshold: 5, openMs: 30000, halfOpenProbes: 1, now: nowMono });
201
207
  const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
202
208
  const alertDebouncer = new AlertDebouncer({ sender: webhookSender, debounceMs: 2000 });
203
209
  const moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => alertDebouncer.enqueue(url, payload) });
204
210
  const trafficSmoother = new TrafficSmoother({ maxQueueWaitMs: 800 });
205
- const cloneLifecycle = new CloneLifecycleManager({ concurrencyTracker, logger });
211
+ const cloneLifecycle = new CloneLifecycleManager({ concurrencyTracker, logger, keyDrainManager });
206
212
  ctx.effect(() => () => { cloneLifecycle.destroy(); }, 'dsh-key-rotation: clone lifecycle cleanup');
207
213
 
208
214
  const sandboxService = createSandboxService({ getRuntime: () => buildRuntime() });
@@ -214,7 +220,11 @@ export function apply(ctx, config = {}) {
214
220
 
215
221
  setupIdleHealEffect(ctx, getConfig, buildRuntime);
216
222
  setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, ensureSandboxRunner, logger);
217
- setupKeyDrainEffect(ctx);
223
+ setupKeyDrainEffect(ctx, keyDrainManager);
224
+ ctx.effect(() => () => {
225
+ selectionTraceStore.clear();
226
+ diagBuffer.clear();
227
+ }, 'dsh-key-rotation: telemetry cleanup');
218
228
 
219
229
  const poolState = new Map();
220
230
  const { schedulePersist, persistenceSnapshot } = setupPersistence(ctx, {
@@ -363,6 +373,8 @@ export function apply(ctx, config = {}) {
363
373
  showChatHeaderChip: cfg.showChatHeaderChip !== false,
364
374
  switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000,
365
375
  warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0,
376
+ maxAttempts: typeof cfg.maxAttempts === 'number' && cfg.maxAttempts > 0 ? Math.floor(cfg.maxAttempts) : 0,
377
+ maxDurationMs: typeof cfg.maxDurationMs === 'number' && cfg.maxDurationMs > 0 ? Math.floor(cfg.maxDurationMs) : 0,
366
378
  index, pools,
367
379
  breaker: moduleBreaker, notifyQueue: moduleNotifyQueue, concurrencyTracker, quotaStore, latencyHistogram, trafficSmoother,
368
380
  providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider,
@@ -461,6 +473,9 @@ export function apply(ctx, config = {}) {
461
473
  circuitBreaker: moduleBreaker,
462
474
  notifyQueue: moduleNotifyQueue,
463
475
  quotaStore,
476
+ keyDrainManager,
477
+ selectionTraceStore,
478
+ diagBuffer,
464
479
  });
465
480
 
466
481
  ctx.effect(() => {
package/lib/key-drain.js CHANGED
@@ -1,56 +1,91 @@
1
- // lib/key-drain.js — Managed key draining and graceful replacement (#463, #502).
1
+ // lib/key-drain.js — Managed key draining and graceful replacement (#463, #502, #544).
2
2
  // Allows draining an old key ref (no new dispatches assigned) while letting
3
3
  // in-flight active streams complete cleanly before permanent removal.
4
+ // KeyDrainManager provides instance-level isolation (#544).
4
5
 
5
- const drainingKeys = new Map(); // ref -> { provider, startedAt, inFlightCount }
6
+ export class KeyDrainManager {
7
+ constructor() {
8
+ this.drainingKeys = new Map(); // ref -> { provider, startedAt, inFlightCount }
9
+ }
10
+
11
+ markKeyDraining(ref, provider = '') {
12
+ if (!ref) return false;
13
+ this.drainingKeys.set(ref, {
14
+ ref,
15
+ provider,
16
+ startedAt: Date.now(),
17
+ });
18
+ return true;
19
+ }
20
+
21
+ isKeyDraining(ref) {
22
+ if (!ref) return false;
23
+ return this.drainingKeys.has(ref);
24
+ }
25
+
26
+ cancelKeyDrain(ref) {
27
+ if (!ref) return false;
28
+ return this.drainingKeys.delete(ref);
29
+ }
30
+
31
+ getDrainingStatus(ref, concurrencyTracker) {
32
+ if (!ref || !this.drainingKeys.has(ref)) return null;
33
+ const entry = this.drainingKeys.get(ref);
34
+ const inFlight = concurrencyTracker && typeof concurrencyTracker.getActive === 'function'
35
+ ? concurrencyTracker.getActive(ref)
36
+ : (concurrencyTracker && typeof concurrencyTracker.getInFlight === 'function'
37
+ ? concurrencyTracker.getInFlight(ref)
38
+ : 0);
39
+
40
+ return {
41
+ ref,
42
+ provider: entry.provider,
43
+ startedAt: entry.startedAt,
44
+ elapsedMs: Date.now() - entry.startedAt,
45
+ inFlight,
46
+ canComplete: inFlight === 0,
47
+ };
48
+ }
49
+
50
+ getAllDrainingKeys(concurrencyTracker) {
51
+ const result = [];
52
+ for (const ref of this.drainingKeys.keys()) {
53
+ result.push(this.getDrainingStatus(ref, concurrencyTracker));
54
+ }
55
+ return result;
56
+ }
6
57
 
7
- export function markKeyDraining(ref, provider = '') {
8
- if (!ref) return false;
9
- drainingKeys.set(ref, {
10
- ref,
11
- provider,
12
- startedAt: Date.now(),
13
- });
14
- return true;
58
+ clearAllDraining() {
59
+ this.drainingKeys.clear();
60
+ }
15
61
  }
16
62
 
17
- export function isKeyDraining(ref) {
18
- if (!ref) return false;
19
- return drainingKeys.has(ref);
63
+ const defaultDrainManager = new KeyDrainManager();
64
+
65
+ export function markKeyDraining(ref, provider = '', manager = null) {
66
+ return (manager || defaultDrainManager).markKeyDraining(ref, provider);
20
67
  }
21
68
 
22
- export function cancelKeyDrain(ref) {
23
- if (!ref) return false;
24
- return drainingKeys.delete(ref);
69
+ export function isKeyDraining(ref, manager = null) {
70
+ return (manager || defaultDrainManager).isKeyDraining(ref);
25
71
  }
26
72
 
27
- export function getDrainingStatus(ref, concurrencyTracker) {
28
- if (!ref || !drainingKeys.has(ref)) return null;
29
- const entry = drainingKeys.get(ref);
30
- const inFlight = concurrencyTracker && typeof concurrencyTracker.getActive === 'function'
31
- ? concurrencyTracker.getActive(ref)
32
- : (concurrencyTracker && typeof concurrencyTracker.getInFlight === 'function'
33
- ? concurrencyTracker.getInFlight(ref)
34
- : 0);
35
-
36
- return {
37
- ref,
38
- provider: entry.provider,
39
- startedAt: entry.startedAt,
40
- elapsedMs: Date.now() - entry.startedAt,
41
- inFlight,
42
- canComplete: inFlight === 0,
43
- };
73
+ export function cancelKeyDrain(ref, manager = null) {
74
+ return (manager || defaultDrainManager).cancelKeyDrain(ref);
44
75
  }
45
76
 
46
- export function getAllDrainingKeys(concurrencyTracker) {
47
- const result = [];
48
- for (const ref of drainingKeys.keys()) {
49
- result.push(getDrainingStatus(ref, concurrencyTracker));
50
- }
51
- return result;
77
+ export function getDrainingStatus(ref, concurrencyTracker, manager = null) {
78
+ return (manager || defaultDrainManager).getDrainingStatus(ref, concurrencyTracker);
79
+ }
80
+
81
+ export function getAllDrainingKeys(concurrencyTracker, manager = null) {
82
+ return (manager || defaultDrainManager).getAllDrainingKeys(concurrencyTracker);
52
83
  }
53
84
 
54
- export function clearAllDraining() {
55
- drainingKeys.clear();
85
+ export function clearAllDraining(manager = null) {
86
+ if (manager) {
87
+ manager.clearAllDraining();
88
+ } else {
89
+ defaultDrainManager.clearAllDraining();
90
+ }
56
91
  }
package/lib/lifecycle.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { healIdleCooldowns, autoUnbreakBrokenKeys } from './heal.js';
3
3
  import { decryptSecret } from './crypto-storage.js';
4
4
  import { StatePersistence, resolveStatePath } from './persistence.js';
5
+ import { cleanupStaleTmpFiles } from './atomic-io.js';
5
6
  import path from 'node:path';
6
7
  import { envValue } from './pool.js';
7
8
  import { clearAllDraining } from './key-drain.js';
@@ -97,6 +98,7 @@ export function setupPersistence(ctx, { cfg0, poolState, moduleBreaker, verboseL
97
98
  dataDir: hostDirs[0],
98
99
  });
99
100
  if (cfg0.persistenceEnabled !== false && resolvedPath) {
101
+ cleanupStaleTmpFiles(resolvedPath).catch(() => {});
100
102
  statePersistence = new StatePersistence({ filePath: resolvedPath });
101
103
  statePersistence.load().then((snap) => {
102
104
  if (!snap) return;
@@ -251,10 +253,10 @@ export function setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, en
251
253
  }, 'dsh-key-rotation: proactive health probing');
252
254
  }
253
255
 
254
- export function setupKeyDrainEffect(ctx) {
256
+ export function setupKeyDrainEffect(ctx, keyDrainManager = null) {
255
257
  return ctx.effect(() => {
256
258
  return () => {
257
- clearAllDraining();
259
+ clearAllDraining(keyDrainManager);
258
260
  };
259
261
  }, 'dsh-key-rotation: clear draining keys on teardown');
260
262
  }