maxpool 1.5.7 → 1.5.9

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.7",
3
+ "version": "1.5.9",
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",
@@ -71,6 +71,14 @@ const DEFAULT_SCHEDULER = {
71
71
  recoveryRampWeight: 4, // decaying penalty applied to a just-recovered account
72
72
  recoveryRampMs: 5 * 60_000, // how long the post-recovery ramp lasts
73
73
  spreadWindowMs: 15 * 60_000, // rolling window used to measure recent per-account load
74
+ // Allow a signed-thinking session to rebalance onto a DIFFERENT Claude account
75
+ // (never a provider — GLM/Kimi can't validate an Anthropic signature). Anthropic
76
+ // thinking-block signatures are content/model integrity, NOT account-bound —
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,
74
82
  };
75
83
  const LOAD_EVENT_MAX_AGE_MS = 60 * 60 * 1000;
76
84
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
@@ -948,16 +956,31 @@ export class AccountManager {
948
956
  * body signal only — never the session-sticky policy — and fails CLOSED on any
949
957
  * body we couldn't fully scan (non-JSON / parse error → bodyThinkingScanned unset). */
950
958
  _migrationSafeForRequest(requestInfo = {}) {
951
- return requestInfo.bodyThinkingScanned === true
952
- && requestInfo.requiresAnthropicThinkingIntegrity !== true;
959
+ // Fail closed on any body we couldn't fully scan (non-JSON / parse error).
960
+ if (requestInfo.bodyThinkingScanned !== true) return false;
961
+ // A signed-thinking request is migration-safe when cross-account thinking
962
+ // migration is enabled: the signature is content/model integrity, not account-
963
+ // bound, and the rebalance candidate filter (_matchesRequest → _isRequestCompatible)
964
+ // already bars providers for thinking, so every eligible target is a Claude
965
+ // account. When the flag is off, keep the conservative bar (never migrate signed
966
+ // thinking) until the revert-to-issuer fail-safe lands (#16).
967
+ if (requestInfo.requiresAnthropicThinkingIntegrity === true) {
968
+ return this.scheduler.crossAccountThinkingMigration === true;
969
+ }
970
+ return true;
953
971
  }
954
972
 
955
- /** Flap-stable "hot": weekly reserve/critical or 5h-cap pressure — signals that
956
- * do NOT flip the instant a request migrates (unlike live in-flight, left to the
957
- * score loop). A healthy bound account is never hot, so it never migrates → no
958
- * ping-pong. */
973
+ /** Flap-stable "hot": burn-PACE reserve/critical/exhausted, or immediate session-
974
+ * quota pressure (5h cap or an API-key token/request limit via
975
+ * `_isSessionQuotaUnavailable`).
976
+ * Pace (not raw level) is the right trigger — it spreads a long/heavy session's
977
+ * later load onto fresh capacity BEFORE it exhausts one account, while a light or
978
+ * near-reset session (whose pace stays normal) never triggers → no churn. Does
979
+ * NOT flip the instant a request migrates (unlike live in-flight, left to the
980
+ * score loop), so a healthy bound account never ping-pongs. */
959
981
  _isBoundAccountHot(account) {
960
- return this._isNearQuota(account);
982
+ return this._isSessionQuotaUnavailable(account)
983
+ || ['reserve', 'critical', 'exhausted'].includes(this._weeklyPaceState(account));
961
984
  }
962
985
 
963
986
  /** Decide whether a bound session should leave its (hot) account THIS request.
@@ -970,7 +993,11 @@ export class AccountManager {
970
993
  if (!this._isBoundAccountHot(bound)) return false;
971
994
 
972
995
  const boundScore = this._scoreAccount(bound, requestInfo, scoringCtx);
973
- const boundTier = WEEKLY_TIER[this._weeklyRawState(bound)] ?? 0;
996
+ // Tier guard on the SAME axis as the trigger (pace, not raw). If the trigger is
997
+ // pace but the tier guard is raw, a pace-hot fast-burner whose RAW tier is still
998
+ // `normal` can never find a strictly-healthier raw tier → migration never fires
999
+ // for the exact account this is meant to relieve. Match the axes.
1000
+ const boundTier = WEEKLY_TIER[this._weeklyPaceState(bound)] ?? 0;
974
1001
 
975
1002
  let bestScore = Infinity;
976
1003
  let bestTier = Infinity;
@@ -983,7 +1010,7 @@ export class AccountManager {
983
1010
  const score = this._scoreAccount(account, requestInfo, scoringCtx);
984
1011
  if (score < bestScore) {
985
1012
  bestScore = score;
986
- bestTier = WEEKLY_TIER[this._weeklyRawState(account)] ?? 0;
1013
+ bestTier = WEEKLY_TIER[this._weeklyPaceState(account)] ?? 0;
987
1014
  }
988
1015
  }
989
1016
  if (!Number.isFinite(bestScore)) return false; // no healthy alternative
@@ -1,14 +1,30 @@
1
1
  const NON_UPSTREAM_ROUTES = new Set(['(queued)', '(none available)']);
2
2
 
3
3
  export class RestartController {
4
- constructor({ pauseAdmission, restartNow, log = console.log }) {
4
+ constructor({
5
+ pauseAdmission,
6
+ restartNow,
7
+ log = console.log,
8
+ // Bounded drain: wait at most this long for in-flight upstream requests to
9
+ // finish, then force the restart anyway. Without a bound, a single long
10
+ // streaming/thinking request (minutes) — or a held stream on the 7-day
11
+ // heartbeat — pins the restart on "Restart pending…" forever (the stuck-`r`
12
+ // bug). Dropped requests reconnect after restart, as the message promises.
13
+ drainTimeoutMs = 10_000,
14
+ setTimeoutFn = setTimeout,
15
+ clearTimeoutFn = clearTimeout,
16
+ }) {
5
17
  this.pauseAdmission = pauseAdmission;
6
18
  this.restartNow = restartNow;
7
19
  this.log = log;
20
+ this.drainTimeoutMs = drainTimeoutMs;
21
+ this.setTimeoutFn = setTimeoutFn;
22
+ this.clearTimeoutFn = clearTimeoutFn;
8
23
  this.activeRequests = new Set();
9
24
  this.upstreamRequests = new Set();
10
25
  this.pending = false;
11
26
  this.restarting = false;
27
+ this._drainTimer = null;
12
28
  }
13
29
 
14
30
  requestStarted(id) {
@@ -41,7 +57,14 @@ export class RestartController {
41
57
 
42
58
  this.pending = true;
43
59
  const queuedOrIdle = Math.max(0, this.activeRequests.size - this.upstreamRequests.size);
44
- this.log(`[Maxpool] Restart pending; admission paused while ${this.upstreamRequests.size} upstream request(s) finish. ${queuedOrIdle} queued/idle request(s) will reconnect after restart.`);
60
+ this.log(`[Maxpool] Restart pending; admission paused while ${this.upstreamRequests.size} upstream request(s) finish (up to ${Math.round(this.drainTimeoutMs / 1000)}s). ${queuedOrIdle} queued/idle request(s) will reconnect after restart.`);
61
+ // Force the restart if the drain overruns — never hang on a long stream.
62
+ this._drainTimer = this.setTimeoutFn(() => {
63
+ if (!this.pending || this.restarting) return;
64
+ this.log(`[Maxpool] Restart drain timed out; forcing restart with ${this.upstreamRequests.size} upstream request(s) still in flight (they will reconnect).`);
65
+ this._restart();
66
+ }, this.drainTimeoutMs);
67
+ this._drainTimer?.unref?.();
45
68
  }
46
69
 
47
70
  _maybeRestart() {
@@ -53,6 +76,10 @@ export class RestartController {
53
76
  if (this.restarting) return;
54
77
  this.restarting = true;
55
78
  this.pending = false;
79
+ if (this._drainTimer) {
80
+ this.clearTimeoutFn(this._drainTimer);
81
+ this._drainTimer = null;
82
+ }
56
83
  this.restartNow();
57
84
  }
58
85
  }