maxpool 1.3.1 → 1.5.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "description": "Multi-account Claude Code proxy with adaptive, rate-aware load balancing across Claude accounts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -13,7 +13,7 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "start": "node src/index.js",
16
- "test": "node --test",
16
+ "test": "bash scripts/run-tests.sh",
17
17
  "lint": "eslint src/ test/",
18
18
  "release": "bash scripts/release.sh"
19
19
  },
@@ -9,6 +9,16 @@ import { refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
9
9
  // an Infinity session-kill / error-fast.
10
10
  const BOUNDED_REPOLL_HOLD_MS = 60_000;
11
11
 
12
+ // Session rebalancing (issue #1): a bound session may migrate OFF a hot account
13
+ // onto a much-healthier one, but ONLY on a thinking-safe request (see
14
+ // _migrationSafeForRequest). These margins force a CLEAR, flap-stable win so a
15
+ // session can never ping-pong between two similarly-loaded accounts.
16
+ const REBALANCE_SCORE_MARGIN = 0.5; // candidate score must be ≤ 50% of the bound account's
17
+ const REBALANCE_MIN_ABS_GAP = 0.5; // …with a small absolute floor so near-zero scores don't micro-churn
18
+ // Weekly-pressure tiers, healthiest first. A fresh (unknown) account is the best
19
+ // migration target; migration requires the candidate be a STRICTLY healthier tier.
20
+ const WEEKLY_TIER = { unknown: 0, normal: 0, soft: 1, reserve: 2, critical: 3, exhausted: 4 };
21
+
12
22
  function emptyQuota() {
13
23
  return {
14
24
  // Standard API rate limits (API key accounts)
@@ -191,6 +201,21 @@ export class AccountManager {
191
201
  bytes: 0, // aggregate buffered body bytes across all held requests
192
202
  };
193
203
  this.admissionPaused = false;
204
+ // Single-writer baton: only the lease holder may rotate OAuth refresh tokens
205
+ // (refresh tokens are single-use; two refreshers brick the account). A worker
206
+ // booted headless during a reload starts WITHOUT the lease and refreshes
207
+ // nothing until it acquires the baton. Default true so the standalone /
208
+ // direct-listen (non-supervised, headless service) path is unchanged.
209
+ this.writerLease = true;
210
+ }
211
+
212
+ /**
213
+ * Acquire/release the single-writer baton. While released, ensureTokenFresh is
214
+ * a no-op (the worker serves on its existing access tokens but never rotates a
215
+ * single-use refresh token — that's the lease holder's job).
216
+ */
217
+ setWriterLease(held) {
218
+ this.writerLease = Boolean(held);
194
219
  }
195
220
 
196
221
  /**
@@ -546,7 +571,7 @@ export class AccountManager {
546
571
  console.log(`[Maxpool] Anthropic recovery probe failed; retrying in ${retryAfter}s (${reason})`);
547
572
  }
548
573
 
549
- noteAmbiguousRateLimit(accountIndex, fingerprint, retryAfterSeconds) {
574
+ noteAmbiguousRateLimit(accountIndex, fingerprint, _retryAfterSeconds) {
550
575
  if (!fingerprint) return false;
551
576
  const now = Date.now();
552
577
  const windowMs = 30_000;
@@ -860,6 +885,62 @@ export class AccountManager {
860
885
  || ['reserve', 'critical', 'exhausted'].includes(this._weeklyRawState(account));
861
886
  }
862
887
 
888
+ // ── Session rebalancing (issue #1) ────────────────────────────────────────
889
+ // A bound session normally sticks to its account (continuity + Anthropic signed-
890
+ // thinking signature validity). These let it migrate OFF a hot account onto fresh
891
+ // capacity, but only when it's provably safe and clearly worth it.
892
+
893
+ /** Per-request safety gate: a request is safe to migrate to a DIFFERENT account
894
+ * iff its body carries NO signed thinking (replaying a signed thinking block to
895
+ * another account → non-retryable "invalid signature"). Uses the PER-REQUEST
896
+ * body signal only — never the session-sticky policy — and fails CLOSED on any
897
+ * body we couldn't fully scan (non-JSON / parse error → bodyThinkingScanned unset). */
898
+ _migrationSafeForRequest(requestInfo = {}) {
899
+ return requestInfo.bodyThinkingScanned === true
900
+ && requestInfo.requiresAnthropicThinkingIntegrity !== true;
901
+ }
902
+
903
+ /** Flap-stable "hot": weekly reserve/critical or 5h-cap pressure — signals that
904
+ * do NOT flip the instant a request migrates (unlike live in-flight, left to the
905
+ * score loop). A healthy bound account is never hot, so it never migrates → no
906
+ * ping-pong. */
907
+ _isBoundAccountHot(account) {
908
+ return this._isNearQuota(account);
909
+ }
910
+
911
+ /** Decide whether a bound session should leave its (hot) account THIS request.
912
+ * All gates must hold: thinking-safe + not mid queue-admission + bound is hot +
913
+ * a genuinely-healthy alternative that is BOTH much cheaper AND a strictly
914
+ * healthier weekly tier (so concurrency jitter alone can never trigger a move). */
915
+ _shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx) {
916
+ if (!this._migrationSafeForRequest(requestInfo)) return false;
917
+ if (requestInfo.queueTicket || requestInfo.queueAdmitted) return false;
918
+ if (!this._isBoundAccountHot(bound)) return false;
919
+
920
+ const boundScore = this._scoreAccount(bound, requestInfo, scoringCtx);
921
+ const boundTier = WEEKLY_TIER[this._weeklyRawState(bound)] ?? 0;
922
+
923
+ let bestScore = Infinity;
924
+ let bestTier = Infinity;
925
+ for (const account of this.accounts) {
926
+ if (account.index === bound.index) continue;
927
+ if (excludedIndexes.has(account.index)) continue;
928
+ if (!this._matchesRequest(account, profile, requestInfo)) continue;
929
+ // Genuinely-healthy alternatives only (normal/soft/unknown weekly).
930
+ if (!this._isAvailable(account, { allowWeeklyReserve: false, allowWeeklyCritical: false })) continue;
931
+ const score = this._scoreAccount(account, requestInfo, scoringCtx);
932
+ if (score < bestScore) {
933
+ bestScore = score;
934
+ bestTier = WEEKLY_TIER[this._weeklyRawState(account)] ?? 0;
935
+ }
936
+ }
937
+ if (!Number.isFinite(bestScore)) return false; // no healthy alternative
938
+
939
+ return bestScore <= boundScore * REBALANCE_SCORE_MARGIN
940
+ && (boundScore - bestScore) >= REBALANCE_MIN_ABS_GAP
941
+ && bestTier < boundTier;
942
+ }
943
+
863
944
  _retryInfo(account) {
864
945
  const now = Date.now();
865
946
  const q = account.quota || {};
@@ -989,7 +1070,13 @@ export class AccountManager {
989
1070
  }
990
1071
  }
991
1072
  const bound = this._boundAccount(requestInfo.sessionKey, profile, excludedIndexes, requestInfo);
992
- if (bound && !this._hasHigherPriorityAvailable(bound, profile, excludedIndexes, requestInfo)) return bound;
1073
+ if (bound
1074
+ && !this._hasHigherPriorityAvailable(bound, profile, excludedIndexes, requestInfo)
1075
+ && !this._shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx)) {
1076
+ return bound;
1077
+ }
1078
+ // Else fall through to the candidate score loop, which re-homes the session
1079
+ // onto the best healthy account via _bindSession on acquire.
993
1080
 
994
1081
  const weeklyPasses = hasBinding
995
1082
  ? [
@@ -1106,6 +1193,16 @@ export class AccountManager {
1106
1193
  if (!binding.homeName || priority < binding.homePriority) {
1107
1194
  binding.homeName = account.name;
1108
1195
  binding.homePriority = priority;
1196
+ } else if (priority === binding.homePriority && account.name !== binding.homeName) {
1197
+ // Same-priority move. If the previous home is still AVAILABLE we left it by
1198
+ // CHOICE (session rebalancing off a hot-but-usable account) → re-home, so
1199
+ // _boundAccount (which prefers homeName) doesn't snap the session straight
1200
+ // back to the hot home next request. If the old home is UNAVAILABLE this is a
1201
+ // FAILOVER → keep homeName so the session returns to it once it recovers.
1202
+ const oldHome = this.accounts.find(a => a.name === binding.homeName);
1203
+ if (oldHome && this._isAvailable(oldHome, { allowWeeklyReserve: true })) {
1204
+ binding.homeName = account.name;
1205
+ }
1109
1206
  }
1110
1207
  binding.currentName = account.name;
1111
1208
  this.sessionBindings.set(sessionKey, binding);
@@ -1635,6 +1732,12 @@ export class AccountManager {
1635
1732
  const account = this.accounts[accountIndex];
1636
1733
  if (!account || account.type !== 'oauth' || !account.refreshToken) return true;
1637
1734
 
1735
+ // Single-writer baton: a worker without the lease NEVER rotates a single-use
1736
+ // refresh token (doing so would invalidate the lease holder's token →
1737
+ // invalid_grant → bricked account). It serves on its existing access token
1738
+ // for the bounded drain; the lease holder owns all rotation.
1739
+ if (!this.writerLease) return true;
1740
+
1638
1741
  if (!force && !isTokenExpiringSoon(account.expiresAt)) return true;
1639
1742
 
1640
1743
  // Coalesce concurrent refreshes
@@ -1642,6 +1745,9 @@ export class AccountManager {
1642
1745
 
1643
1746
  account._refreshPromise = (async () => {
1644
1747
  console.log(`[Maxpool] Refreshing token for account "${account.name}"...`);
1748
+ // Record the token we're rotating FROM so the persistence layer's
1749
+ // generation guard can detect another writer having already advanced it.
1750
+ account._refreshedFrom = account.refreshToken;
1645
1751
  try {
1646
1752
  const newTokens = await this._refreshAccessToken(account.refreshToken);
1647
1753
  account.credential = newTokens.accessToken;
@@ -1673,6 +1779,18 @@ export class AccountManager {
1673
1779
  return account._refreshPromise;
1674
1780
  }
1675
1781
 
1782
+ /**
1783
+ * Await every in-flight OAuth token refresh to settle. The single-writer baton
1784
+ * uses this on RELEASE: a refresh that passed the `if(!writerLease) return` gate
1785
+ * BEFORE the lease was dropped is still awaiting its OAuth POST; the new worker
1786
+ * must not acquire the lease and rotate the SAME single-use token until these
1787
+ * settle, or the upstream invalidates one token → invalid_grant → bricked.
1788
+ */
1789
+ async drainRefreshes() {
1790
+ const pending = this.accounts.map(a => a._refreshPromise).filter(Boolean);
1791
+ if (pending.length) await Promise.allSettled(pending);
1792
+ }
1793
+
1676
1794
  /**
1677
1795
  * Set a callback to persist refreshed tokens to config.
1678
1796
  */
package/src/config.js CHANGED
@@ -171,28 +171,100 @@ export async function loadState() {
171
171
  }
172
172
  }
173
173
 
174
- export async function saveState(state) {
175
- await atomicWrite(getStatePath(), JSON.stringify(state, null, 2) + '\n');
174
+ /**
175
+ * Read just the `_generation` integer of an on-disk JSON file (config or state)
176
+ * without parsing it as a typed object. Returns 0 when the file is missing,
177
+ * unreadable, or carries no generation (legacy files), so a first write always
178
+ * advances to >=1. Never throws.
179
+ */
180
+ export async function readGeneration(path) {
181
+ try {
182
+ const parsed = JSON.parse(await readFile(path, 'utf-8'));
183
+ const g = Number(parsed?._generation);
184
+ return Number.isFinite(g) && g > 0 ? g : 0;
185
+ } catch {
186
+ return 0;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Generation-guarded state write (defense-in-depth single-writer enforcement).
192
+ *
193
+ * `state.quota` carries the learned quota. We stamp a monotonic `_generation`
194
+ * and refuse to clobber a file whose on-disk generation is NEWER than the one
195
+ * we last observed — that means another writer (a stale ex-lease worker) raced
196
+ * us, and overwriting would revert fresher quota. The baton sequencing already
197
+ * guarantees a single writer; this guard catches a sequencing bug.
198
+ *
199
+ * Pass `{ expectedGeneration }` to assert the on-disk file is still at the
200
+ * generation you read; omit it to read-then-bump unconditionally (single-owner
201
+ * fast path). Returns the generation written, or null when refused.
202
+ */
203
+ let _stateWriteChain = Promise.resolve();
204
+
205
+ export function saveState(state, { expectedGeneration = null } = {}) {
206
+ // Serialize state writes (a 60s interval flush can otherwise race the final
207
+ // baton flush, read the same on-disk generation, and double-write).
208
+ const run = async () => {
209
+ const path = getStatePath();
210
+ const onDisk = await readGeneration(path);
211
+ if (expectedGeneration != null && onDisk > expectedGeneration) {
212
+ // A newer writer advanced the file under us — refuse the stale flush.
213
+ return null;
214
+ }
215
+ const next = onDisk + 1;
216
+ await atomicWrite(path, JSON.stringify({ ...state, _generation: next }, null, 2) + '\n');
217
+ return next;
218
+ };
219
+ const result = _stateWriteChain.then(run, run);
220
+ _stateWriteChain = result.then(() => {}, () => {});
221
+ return result;
176
222
  }
177
223
 
224
+ /** Barrier: resolves once every queued state write has settled. */
225
+ export const flushStateWrites = () => _stateWriteChain;
226
+
178
227
  let _configWriteChain = Promise.resolve();
179
228
 
229
+ /** Barrier: resolves once every queued config write (e.g. a fire-and-forget
230
+ * token-refresh persist) has settled. Awaited on baton release so a rotated
231
+ * token can't be left unwritten when the new worker boots from disk (M3). */
232
+ export const flushConfigWrites = () => _configWriteChain;
233
+
180
234
  /**
181
235
  * Serialize an in-process read-modify-write of the config so concurrent updates
182
236
  * cannot lose each other's writes — e.g. a background OAuth token refresh
183
237
  * (fire-and-forget) racing a TUI account add/delete. Each update re-reads the
184
238
  * latest config, applies updater(config), then saves atomically.
185
239
  *
240
+ * Defense-in-depth single-writer guard: the config carries a monotonic
241
+ * `_generation`. If `guardGeneration` is supplied and the on-disk generation is
242
+ * already NEWER than it, the write is REFUSED (a stale ex-lease worker raced the
243
+ * current writer). The updater additionally receives the on-disk generation as
244
+ * `config._generation` so a refresh updater can SKIP a token rotation it sees a
245
+ * fresher writer already performed. The generation is bumped on every write.
246
+ *
186
247
  * NOTE: this serializes writes within THIS process only. A separate
187
248
  * `maxpool import`/`login` process writing concurrently is not coordinated
188
249
  * (that would require a lockfile) — but those are short, rare, human-driven.
189
250
  */
190
- export function atomicConfigUpdate(updater) {
251
+ export function atomicConfigUpdate(updater, { guardGeneration = null } = {}) {
191
252
  const run = async () => {
192
253
  const config = await loadConfig() || createDefaultConfig();
193
- await updater(config);
254
+ const onDisk = Number(config._generation) || 0;
255
+ if (guardGeneration != null && onDisk > guardGeneration) {
256
+ // Refuse: a newer writer already advanced the config. Surface the live
257
+ // config so the caller can reconcile, but write nothing.
258
+ const refused = new Error('config generation advanced; stale write refused');
259
+ refused.code = 'STALE_GENERATION';
260
+ refused.onDiskGeneration = onDisk;
261
+ refused.config = config;
262
+ throw refused;
263
+ }
264
+ const result = await updater(config);
265
+ config._generation = onDisk + 1;
194
266
  await saveConfig(config);
195
- return config;
267
+ return result === undefined ? config : result;
196
268
  };
197
269
  // Run after any in-flight update regardless of whether it resolved or
198
270
  // rejected, so one failure doesn't poison the chain; the caller still sees