maxpool 1.5.9 → 1.5.11

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.5.9",
3
+ "version": "1.5.11",
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",
@@ -75,10 +75,12 @@ const DEFAULT_SCHEDULER = {
75
75
  // (never a provider — GLM/Kimi can't validate an Anthropic signature). Anthropic
76
76
  // thinking-block signatures are content/model integrity, NOT account-bound —
77
77
  // verified empirically 2026-07-02 (a `partnerships`-signed block replayed under
78
- // `personal` returned 200). DEFAULT OFF until the revert-to-issuer fail-safe lands
79
- // (issue #16): with it off, a heavy thinking session still can't strand an account,
80
- // but it also can't poison a session if Anthropic ever changes the contract.
81
- crossAccountThinkingMigration: false,
78
+ // `personal` returned 200). ON by default: a heavy thinking session can now spread
79
+ // its later load onto fresh accounts instead of stranding one. The revert-to-issuer
80
+ // fail-safe (server.js, on `invalid_thinking_signature` for a migrated request)
81
+ // makes this safe even if Anthropic ever account-binds signatures — a rejected
82
+ // replay self-heals to the issuer instead of poisoning the session.
83
+ crossAccountThinkingMigration: true,
82
84
  };
83
85
  const LOAD_EVENT_MAX_AGE_MS = 60 * 60 * 1000;
84
86
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
@@ -438,6 +440,12 @@ export class AccountManager {
438
440
 
439
441
  acquireAccount(requestInfo = {}, excludedIndexes = new Set()) {
440
442
  this._noteRequestPolicy(requestInfo);
443
+ // Capture the session's prior account BEFORE selection, so the caller can tell
444
+ // whether this acquire MOVED the session (the thinking-signature fail-safe needs
445
+ // the pre-migration issuing account to revert to).
446
+ const prevCurrentName = requestInfo.sessionKey
447
+ ? this._sessionBinding(requestInfo.sessionKey)?.currentName
448
+ : null;
441
449
  const account = this.getActiveAccount(requestInfo, excludedIndexes);
442
450
  if (!account) return null;
443
451
 
@@ -449,7 +457,22 @@ export class AccountManager {
449
457
  account.inFlight++;
450
458
  account.activeWeight += weight;
451
459
  account.lastUsedAt = Date.now();
452
- return { account, weight, startedAt: Date.now(), upstreamThrottleProbe };
460
+ // Non-null only when this acquire moved the session off its prior account.
461
+ const migratedFromName = (prevCurrentName && account.name !== prevCurrentName) ? prevCurrentName : null;
462
+ return { account, weight, startedAt: Date.now(), upstreamThrottleProbe, migratedFromName };
463
+ }
464
+
465
+ /** Fail-safe: snap a session's binding back to the pre-migration issuing account
466
+ * after a rejected cross-account thinking replay, so the retry (and future
467
+ * requests) route to the account that actually generated the thinking blocks.
468
+ * Defensive — signatures are portable in practice (verified 2026-07-02); this
469
+ * only fires if Anthropic ever account-binds them. */
470
+ revertSessionBinding(sessionKey, name) {
471
+ if (!sessionKey || !name) return;
472
+ const binding = this._sessionBinding(sessionKey);
473
+ if (!binding) return;
474
+ binding.currentName = name;
475
+ this.sessionBindings.set(sessionKey, binding);
453
476
  }
454
477
 
455
478
  releaseAccount(lease, outcome = {}) {
@@ -1136,6 +1159,22 @@ export class AccountManager {
1136
1159
  const profile = requestInfo.profile || 'claude';
1137
1160
  const scoringCtx = this._scoringContext();
1138
1161
 
1162
+ // Fail-safe retry pin: steer this request's remaining retry/queue chain onto a
1163
+ // specific account (the pre-migration issuer, after a cross-account thinking
1164
+ // replay was rejected). Honored ahead of everything else, but FALLS THROUGH to
1165
+ // normal selection whenever that account is excluded/unavailable — a down issuer
1166
+ // never strands the request (the retry then re-migrates and terminates via
1167
+ // excludedIndexes + maxAttempts). See the thinking-signature fail-safe in server.js.
1168
+ if (requestInfo.pinnedAccountName) {
1169
+ const pinned = this.accounts.find(a => a.name === requestInfo.pinnedAccountName);
1170
+ if (pinned && !excludedIndexes.has(pinned.index)
1171
+ && this._matchesRequest(pinned, profile, requestInfo)
1172
+ && this._isAvailable(pinned, { allowWeeklyReserve: true, allowWeeklyCritical: true })) {
1173
+ this.currentIndex = pinned.index;
1174
+ return pinned;
1175
+ }
1176
+ }
1177
+
1139
1178
  const hasBinding = Boolean(requestInfo.sessionKey && this.sessionBindings.has(requestInfo.sessionKey));
1140
1179
  const preferred = this._preferredAccount(profile, excludedIndexes, requestInfo);
1141
1180
  if (preferred) {
package/src/config.js CHANGED
@@ -111,7 +111,7 @@ export function createDefaultConfig() {
111
111
  enabled: true,
112
112
  maxWaitMs: 24 * 60 * 60 * 1000, // hard ceiling for non-streaming/capacity holds; streaming uses streamHoldMaxMs
113
113
  autoMaxWaitMs: null, // 5h/session-cap hold (null = maxWaitMs)
114
- capacityMaxWaitMs: 15 * 60 * 1000, // upstream 529/overload — stays short, never governed by the others
114
+ capacityMaxWaitMs: 15 * 60 * 1000, // short cap for NON-streaming capacity holds + the concurrency-cap clamp. A STREAMING capacity/throttle hold uses maxWaitMs (held on the heartbeat until capacity frees), NOT this.
115
115
  weeklyMaxWaitMs: 24 * 60 * 60 * 1000, // legacy bound; streaming holds use streamHoldMaxMs
116
116
  // Streaming hold ceiling: how long a streaming session is held ALIVE on the
117
117
  // heartbeat waiting for any account to free up. 7d so a session is never
package/src/index.js CHANGED
@@ -139,6 +139,10 @@ async function serverCommand() {
139
139
 
140
140
  async function supervisorCommand() {
141
141
  const { createServer } = await import('node:net');
142
+ // Test-only: the restart integration test delivers a GROUP SIGUSR2 to drive the
143
+ // worker's restart path; ignore it on the supervisor so the group signal doesn't
144
+ // kill it (default SIGUSR2 action is terminate). Gated — never active normally.
145
+ if (process.env.MAXPOOL_TEST_RESTART_SIGNAL === '1') process.on('SIGUSR2', () => {});
142
146
  const config = await loadOrCreateConfig();
143
147
  initEventLog(config, { manageRotation: true }); // supervisor is the single rotation owner
144
148
  const port = config.proxy.port;
@@ -733,8 +737,19 @@ async function serverWorkerCommand() {
733
737
  restartController = new RestartController({
734
738
  pauseAdmission: () => accountManager.setAdmissionPaused(true),
735
739
  restartNow: requestReload,
740
+ // Configurable so ops can tune the bounded pre-restart drain, and so the
741
+ // integration test can exercise the force-restart path without a 10s wait.
742
+ ...(Number.isFinite(config.restartDrainTimeoutMs) ? { drainTimeoutMs: config.restartDrainTimeoutMs } : {}),
736
743
  });
737
744
 
745
+ // Test-only: drive the real interactive restart path (the TUI `r` key) headless,
746
+ // so the end-to-end "restart while requests are in flight completes bounded and
747
+ // the server comes back" is provable without allocating a pty. Gated — never
748
+ // active in normal runs.
749
+ if (process.env.MAXPOOL_TEST_RESTART_SIGNAL === '1') {
750
+ process.on('SIGUSR2', () => restartController?.requestRestart());
751
+ }
752
+
738
753
  const shutdownGracefully = (reason, options = {}) => {
739
754
  // A terminal-close shutdown must NEVER exit non-zero: the supervisor reads a
740
755
  // fast non-zero exit as a crash and respawns a fresh worker — onto the now-dead
package/src/server.js CHANGED
@@ -746,6 +746,24 @@ async function forwardRequest(
746
746
  }
747
747
  if (errorType === 'invalid_thinking_signature') {
748
748
  console.log(`[Maxpool] Non-retryable Anthropic thinking signature error on "${account.name}"`);
749
+ // Fail-safe: if this request had been MIGRATED to a different Claude account
750
+ // this turn (cross-account thinking rebalance), the rejected block was issued
751
+ // by the PRE-MIGRATION account. Revert the session there, exclude the failed
752
+ // target, and retry PINNED to the issuer — so a rejected cross-account replay
753
+ // self-heals instead of poisoning the session into a 400 loop. Signatures are
754
+ // portable in practice (verified 2026-07-02); this only fires if Anthropic
755
+ // ever account-binds them.
756
+ if (lease.migratedFromName && requestInfo.sessionKey
757
+ && canRetryBufferedBody && retryCount + 1 < maxAttempts && !res.headersSent) {
758
+ accountManager.revertSessionBinding(requestInfo.sessionKey, lease.migratedFromName);
759
+ excludedIndexes.add(account.index);
760
+ console.log(`[Maxpool] Thinking-signature fail-safe: reverting session to issuer "${lease.migratedFromName}" and retrying`);
761
+ return forwardRequest(
762
+ req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir,
763
+ retryConfig, queueConfig, { ...requestInfo, pinnedAccountName: lease.migratedFromName },
764
+ canRetryBufferedBody, canQueueBufferedBody, excludedIndexes,
765
+ );
766
+ }
749
767
  }
750
768
 
751
769
  ctx.status = upstreamRes.status;
@@ -934,6 +952,38 @@ function formatRetryDuration(seconds) {
934
952
  return `${s}s`;
935
953
  }
936
954
 
955
+ /**
956
+ * Hold-window ceiling for a queued request. The soonest-recovery oracle
957
+ * (nextRetryForRequest) is the real governor — this is the backstop ceiling that
958
+ * bounds how long a request may WAIT for that recovery.
959
+ *
960
+ * non-streaming (no heartbeat) → short cap (would die on the client's own timeout)
961
+ * streaming + capacity/429/throttle → maxWaitMs (24h): HELD on the SSE heartbeat
962
+ * until an account frees. This is the "hold, don't fail" fix — a throttle clears
963
+ * in seconds and a 5h session-cap in hours, both far under 24h; only a multi-day
964
+ * weekly reset exceeds it and error-fasts with the honest "add an account"
965
+ * message. Previously capped at the short capacityMaxWaitMs (15m), which failed
966
+ * the MOST-clearly-temporary throttle sooner than an ordinary per-account 429.
967
+ * streaming + other (quota) → streamHoldMaxMs (7d)
968
+ * retryPlanCause==='concurrency_cap' → clamped to the short capacity window (a LOCAL
969
+ * transient: a slot frees in seconds; never spin a multi-day hold on it).
970
+ */
971
+ function computeQueueWindowMs({
972
+ cause, stream, retryPlanCause,
973
+ maxWaitMs, capacityMaxWaitMs, nonStreamMaxWaitMs, streamHoldMaxMs,
974
+ }) {
975
+ let windowMs;
976
+ if (!stream) {
977
+ windowMs = cause === 'capacity' ? Math.min(nonStreamMaxWaitMs, capacityMaxWaitMs) : nonStreamMaxWaitMs;
978
+ } else if (cause === 'capacity') {
979
+ windowMs = maxWaitMs;
980
+ } else {
981
+ windowMs = streamHoldMaxMs;
982
+ }
983
+ if (retryPlanCause === 'concurrency_cap') windowMs = Math.min(windowMs, capacityMaxWaitMs);
984
+ return windowMs;
985
+ }
986
+
937
987
  function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRecoverSoon = true) {
938
988
  const thinking = requestInfo.requiresAnthropicThinkingIntegrity
939
989
  || accountManager._requiresAnthropicThinkingIntegrity?.(requestInfo);
@@ -958,7 +1008,7 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
958
1008
  return `All ${n} accounts exhausted. Retry in ${retryAfter}s.`;
959
1009
  }
960
1010
 
961
- export const __serverTest = { unavailableMessage, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest };
1011
+ export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest };
962
1012
 
963
1013
  async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
964
1014
  if (!upstreamRes.body) return '';
@@ -1213,33 +1263,15 @@ async function queueAndRetry(
1213
1263
  const streamHoldMaxMs = queueConfig.streamHoldMaxMs == null
1214
1264
  ? 7 * 24 * 60 * 60 * 1000
1215
1265
  : Math.max(0, Number(queueConfig.streamHoldMaxMs) || 0);
1216
- // Pick the hold ceiling:
1217
- // capacity (upstream 529/overload) → its own short cap, never a long hold
1218
- // non-streaming (no heartbeat) → short cap (would die on client timeout)
1219
- // streaming → up to streamHoldMaxMs (7d), kept alive
1220
- // by the heartbeat
1221
- let queueWindowMs;
1222
- if (cause === 'capacity') {
1223
- queueWindowMs = Math.min(maxWaitMs, capacityMaxWaitMs);
1224
- } else if (!requestInfo.stream) {
1225
- queueWindowMs = nonStreamMaxWaitMs;
1226
- } else {
1227
- queueWindowMs = streamHoldMaxMs;
1228
- }
1229
- // A non-streaming request has no heartbeat regardless of cause, so it must
1230
- // never outlast nonStreamMaxWaitMs even under capacity (it would occupy a
1231
- // slot 3x its documented cap with nothing to reap it).
1232
- if (!requestInfo.stream) queueWindowMs = Math.min(queueWindowMs, nonStreamMaxWaitMs);
1233
-
1234
- // A pure concurrency-cap block (every account healthy but all in-flight/global
1235
- // slots busy — NOT a quota/rate-limit reset) is a LOCAL capacity transient. Bound
1236
- // it by the short capacity window, never the multi-day streaming hold: a slot
1237
- // frees as active requests finish (seconds–minutes), and if the fleet stays
1238
- // saturated past the window the request sheds load (error-fast) instead of
1239
- // spinning a queue slot for up to streamHoldMaxMs (7d) — the soft-deadlock guard.
1240
- if (retryPlan.cause === 'concurrency_cap') {
1241
- queueWindowMs = Math.min(queueWindowMs, capacityMaxWaitMs);
1242
- }
1266
+ const queueWindowMs = computeQueueWindowMs({
1267
+ cause,
1268
+ stream: Boolean(requestInfo.stream),
1269
+ retryPlanCause: retryPlan.cause,
1270
+ maxWaitMs,
1271
+ capacityMaxWaitMs,
1272
+ nonStreamMaxWaitMs,
1273
+ streamHoldMaxMs,
1274
+ });
1243
1275
 
1244
1276
  if (queueWindowMs <= 0) return finishQueuedStreamIfNeeded(res, requestInfo, honestMessage);
1245
1277
 
package/src/tui.js CHANGED
@@ -925,8 +925,19 @@ export class TUI {
925
925
  if (a.enabled !== false && upstreamBlocking && a.status === 'active') {
926
926
  effectiveStatus = a.inFlight > 0 ? 'probing' : 'waiting';
927
927
  }
928
+ // Anthropic is actively REJECTING this account right now (e.g. a per-model weekly
929
+ // sub-limit the general utilization % doesn't expose). It's unusable — surface
930
+ // that instead of a benign green "active", so a low weekly % (the bar keeps its
931
+ // true value) is never misread as available headroom.
932
+ // Keys on a.status (not effectiveStatus) so a rejected account reads 'blocked'
933
+ // even inside an upstream-throttle window (where it would otherwise show
934
+ // probing/waiting) — a rejected account is unusable, not part of the recovery.
935
+ if (a.enabled !== false && a.quota?.unifiedStatus === 'rejected' && a.status === 'active') {
936
+ effectiveStatus = 'blocked';
937
+ }
928
938
  switch (effectiveStatus) {
929
939
  case 'active': status = isCur ? green('active') : 'active'; break;
940
+ case 'blocked': status = red('blocked'); break;
930
941
  case 'probing': status = green('probing'); break;
931
942
  case 'waiting': status = yellow('waiting'); break;
932
943
  case 'paused': status = yellow('paused'); break;