@goodandready/dsh-key-rotation 0.8.69 → 0.8.70

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.70 - 2026-10-20
4
+
5
+ ### Bug Fixes
6
+ - **Fix Fatal Crash in sweepExpired & Isolate Clone Metadata (#513, GitHub #26)**:
7
+ - Guarded `sweepExpired` in `lib/pool.js` and the periodic sweep timer in `lib/index.js` with `if (!st || !(st.failedUntil instanceof Map)) continue;` to safely skip non-pool-state values.
8
+ - Isolated clone route metadata tracking inside `CloneLifecycleManager` (`this.knownClones`) rather than polluting `poolState` with `Set` objects (`${provider}:clones`).
9
+ - Added regression test `test/github-issue-26-sweep-expired.test.mjs` verifying `sweepExpired` robustness with heterogeneous data and clean `poolState` boundaries.
10
+
3
11
  ## 0.8.69 - 2026-10-19
4
12
 
5
13
  ### Features
@@ -101,20 +101,37 @@ export class CloneLifecycleManager {
101
101
  this.concurrencyTracker = concurrencyTracker;
102
102
  this.logger = logger || console;
103
103
  this.drainingClones = new Map(); // cloneId -> { provider, ref, startedAt, timer }
104
+ this.knownClones = new Map(); // provider -> Set<string>
104
105
  }
105
106
 
106
107
  /**
107
108
  * Evaluates previous vs current expected clones for a provider.
108
109
  * If a clone was removed, marks it draining instead of instantly destroying it.
109
110
  * @param {string} provider
110
- * @param {Set<string>|string[]} prevClones
111
+ * @param {Set<string>|string[]} [prevClones]
111
112
  * @param {Set<string>|string[]} currentClones
112
- * @param {Function} onDeregister callback when draining completes and route is safe to remove
113
+ * @param {Function} [onDeregister] callback when draining completes and route is safe to remove
113
114
  */
114
- reconcileClones(provider, prevClones, currentClones, onDeregister) {
115
+ reconcileClones(provider, prevOrCurrent, currentOrDeregister, maybeDeregister) {
116
+ let prevClones;
117
+ let currentClones;
118
+ let onDeregister;
119
+
120
+ if (currentOrDeregister instanceof Set || Array.isArray(currentOrDeregister)) {
121
+ prevClones = prevOrCurrent;
122
+ currentClones = currentOrDeregister;
123
+ onDeregister = maybeDeregister;
124
+ } else {
125
+ prevClones = this.knownClones.get(provider) ?? [];
126
+ currentClones = prevOrCurrent;
127
+ onDeregister = currentOrDeregister;
128
+ }
129
+
115
130
  const prevSet = prevClones instanceof Set ? prevClones : new Set(prevClones || []);
116
131
  const currSet = currentClones instanceof Set ? currentClones : new Set(currentClones || []);
117
132
 
133
+ this.knownClones.set(provider, currSet);
134
+
118
135
  for (const cloneId of prevSet) {
119
136
  if (!currSet.has(cloneId)) {
120
137
  this.startDrain(cloneId, provider, onDeregister);
package/lib/index.js CHANGED
@@ -232,6 +232,7 @@ export function apply(ctx, config = {}) {
232
232
  const id = setInterval(() => {
233
233
  const now = Date.now();
234
234
  for (const st of poolState.values()) {
235
+ if (!st || !(st.failedUntil instanceof Map)) continue;
235
236
  for (const [ref, until] of [...(st.failedUntil?.entries() ?? [])]) {
236
237
  if (until <= now && !st.probedAt?.has(ref)) {
237
238
  st.events.push({ at: until, ref, reason: 'probe', cooldownMs: 0, type: 'probe' });
@@ -285,8 +285,7 @@ export function cleanupRemovedProviders({
285
285
  if (cloneLifecycle && typeof cloneLifecycle.reconcileClones === 'function') {
286
286
  for (const p of cfg.providers ?? []) {
287
287
  const liveClones = new Set([...expectedClones].filter(c => c.startsWith(`${p.provider}-`)));
288
- cloneLifecycle.reconcileClones(p.provider, poolState.get(`${p.provider}:clones`) ?? [], liveClones);
289
- poolState.set(`${p.provider}:clones`, liveClones);
288
+ cloneLifecycle.reconcileClones(p.provider, liveClones);
290
289
  }
291
290
  }
292
291
  }
package/lib/pool.js CHANGED
@@ -226,6 +226,7 @@ export function sweepExpired(poolState, now = Date.now(), activeRefs = null, quo
226
226
  let cleared = 0;
227
227
  const activeSet = activeRefs ? new Set(activeRefs) : null;
228
228
  for (const st of poolState.values()) {
229
+ if (!st || !(st.failedUntil instanceof Map)) continue;
229
230
  // #338: decay penalty failCounts for stable keys in maintenance sweep
230
231
  decayPenalties({ state: st }, now);
231
232
  for (const [ref, until] of [...st.failedUntil.entries()]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.69",
3
+ "version": "0.8.70",
4
4
  "packageManager": "pnpm@10.33.2",
5
5
  "description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
6
6
  "keywords": [