maxpool 1.24.7 → 1.24.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.24.7",
3
+ "version": "1.24.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",
@@ -219,6 +219,15 @@ const DEFAULT_SCHEDULER = {
219
219
  // undiscounted, as do the hard gates (safetyMaxActivePerAccount, usage cap,
220
220
  // cooldowns) — those are the anti-dogpile backstops, not price signals.
221
221
  fastRefillDiscount: 0.6, // 0 = off (full cost), 0.6 = discount up to 60% of the two balancing terms
222
+ // RESET-AWARE PREFERENCE (2026-10-08, owner): an account holding a usable, unexpired
223
+ // limit reset (Claude cedar_ember grant / z.ai card) has capacity its siblings lack —
224
+ // when it reaches its wall the prober redeems the reset and it refills (reset-policy
225
+ // R1). So its BALANCING costs (pace, utilization, spread) are discounted: it soaks
226
+ // more traffic and spends the reset instead of letting it expire. The SAFETY terms
227
+ // (capPenalty, reserve/critical, ramp, failure) and every hard gate stay undiscounted,
228
+ // so it is never dogpiled and never preferred over an unhealthy-looking pick.
229
+ // 0 = off. 0.35 ≈ a 1.5x share at otherwise-equal health.
230
+ resetBoostDiscount: 0.35,
222
231
  fastRefillFadeUtil: 0.65, // discount reaches 0 at this session utilization (weeklySoftThreshold)
223
232
  recoveryRampWeight: 4, // decaying penalty applied to a just-recovered account
224
233
  recoveryRampMs: 5 * 60_000, // how long the post-recovery ramp lasts
@@ -2770,6 +2779,31 @@ export class AccountManager {
2770
2779
  * - ramp: ease a just-recovered account back in instead of slamming it
2771
2780
  * - failures: direct per-account backoff after errors
2772
2781
  */
2782
+ /**
2783
+ * Does this account hold a banked reset that can actually lift its BINDING limit?
2784
+ * Claude grants: usable, not paused, unexpired. z.ai cards: unexpired five-hour cards
2785
+ * always count; weekly cards count only when the account HAS a weekly limit (a legacy
2786
+ * plan has none — z.ai support, 2026-10-03 — so a weekly card there lifts nothing).
2787
+ * Disabled accounts never count (owner gate R0). Display state only otherwise.
2788
+ */
2789
+ _holdsUsableReset(account, now = Date.now()) {
2790
+ if (!account || account.enabled === false) return false;
2791
+ const grants = account.resetGrants?.grants || [];
2792
+ if (grants.some(g => g && g.usableNow !== false && !g.paused && (g.endsAt == null || g.endsAt > now))) return true;
2793
+ const cards = account.resetCards;
2794
+ if (!cards) return false;
2795
+ const live = c => c && !c.expired && (c.expiresAt == null || c.expiresAt > now);
2796
+ if ((cards.fiveHour || []).some(live)) return true;
2797
+ const hasWeeklyLimit = account.quota?.providerWk != null || account.quota?.providerWkReset != null;
2798
+ return hasWeeklyLimit && (cards.weekly || []).some(live);
2799
+ }
2800
+
2801
+ _resetMultiplier(account, now = Date.now()) {
2802
+ const d = Number(this.scheduler.resetBoostDiscount) || 0;
2803
+ if (d <= 0) return 1;
2804
+ return this._holdsUsableReset(account, now) ? 1 - Math.min(d, 0.9) : 1;
2805
+ }
2806
+
2773
2807
  _scoreAccount(account, requestInfo = {}, ctx = null, criticalUnlock = null) {
2774
2808
  const now = ctx?.now ?? Date.now();
2775
2809
  const reqWeight = Math.max(1, requestInfo.weight || 1);
@@ -2826,7 +2860,9 @@ export class AccountManager {
2826
2860
  // FAST-REFILL DISCOUNT: applied to the pace and utilization terms for an
2827
2861
  // account whose only cap is a fast-refilling session window — see the
2828
2862
  // DEFAULT_SCHEDULER block for the full rationale (refillMult computed above).
2829
- const paceCost = this._accountScarcity(account, now) * this.scheduler.paceCostWeight * refillMult;
2863
+ // RESET-AWARE: discounts the three BALANCING terms only (see resetBoostDiscount).
2864
+ const resetMult = this._resetMultiplier(account, now);
2865
+ const paceCost = this._accountScarcity(account, now) * this.scheduler.paceCostWeight * refillMult * resetMult;
2830
2866
 
2831
2867
  // RAW utilization cost — direct, not pace-adjusted. The pace cost above discounts
2832
2868
  // by how far into the window you are, so an account at 80% with 2h left is only
@@ -2834,7 +2870,7 @@ export class AccountManager {
2834
2870
  // benching, but wrong for load balancing: an account at 80% should be clearly less
2835
2871
  // attractive than one at 10% even if both are "on pace". Measured 2026-08-10: cc at
2836
2872
  // 80% scored 52.30 vs glm at 10% at 52.15 — a 0.15 gap drowned by round-robin.
2837
- const utilizationCost = this._rawUtilization(account) * this.scheduler.utilizationWeight * refillMult;
2873
+ const utilizationCost = this._rawUtilization(account) * this.scheduler.utilizationWeight * refillMult * resetMult;
2838
2874
 
2839
2875
  // Per-model weekly de-preference: an account whose scoped weekly for THIS
2840
2876
  // request's model (e.g. Fable) is high-but-not-exhausted is a poor pick for
@@ -2860,7 +2896,7 @@ export class AccountManager {
2860
2896
  // 2.3x early in its window, fading to parity at the same ses 0.65 the multiplier
2861
2897
  // already uses. A multiplier of 1 makes every discounted term byte-identical to
2862
2898
  // pre-2026-08-25 behaviour; reserve/critical/ramp/failure are never discounted.
2863
- const spread = share * this.scheduler.spreadShareWeight * refillMult;
2899
+ const spread = share * this.scheduler.spreadShareWeight * refillMult * resetMult;
2864
2900
 
2865
2901
  const ramp = this._recoveryRamp(account, now);
2866
2902
  const reserveCost = this._reserveCost(account, now, weeklyState);
@@ -3344,7 +3380,13 @@ export class AccountManager {
3344
3380
  }
3345
3381
 
3346
3382
  q.lastProbeOkAt = Date.now();
3347
- // A successful probe clears any recorded failure — freshness confirmed.
3383
+ // A successful probe clears any recorded failure — freshness confirmed — including
3384
+ // the org-403 streak (a healthy probe proves the org still accepts OAuth).
3385
+ q.consecutiveOrgGone = 0;
3386
+ if (account.subscriptionGone) {
3387
+ account.subscriptionGone = false;
3388
+ console.log(`[Maxpool] "${account.name}" subscription check passed — un-benched (the org-403 was transient)`);
3389
+ }
3348
3390
  q.lastProbeError = null;
3349
3391
  q.lastProbeErrorAt = null;
3350
3392
  q.lastProbeErrorStatus = null;
@@ -3381,6 +3423,20 @@ export class AccountManager {
3381
3423
  // worth having; the diagnosis that motivated it was wrong.)
3382
3424
  q.consecutiveProbeFailures = (q.consecutiveProbeFailures || 0) + 1;
3383
3425
  const n = q.consecutiveProbeFailures;
3426
+ // ORG-403 LATCH REQUIRES ITS OWN STRIKES (2026-10-08, false "no sub" on
3427
+ // max@gomokka.com). `n` mixes EVERY probe failure — network timeouts first among
3428
+ // them — so after a flapping-network morning one genuine org-403 can arrive with
3429
+ // n already >= 3 and bench a LIVE subscription (measured: latch fired at "x6"
3430
+ // with exactly one org-403 in the whole week's log; 104 successful requests the
3431
+ // same day). The subscription verdict must count only consecutive org-403s.
3432
+ const isOrgGone = status === 403
3433
+ && /not allowed for this organization|disabled.*subscription|subscription.*disabled|organization has disabled/i.test(String(message));
3434
+ if (isOrgGone) {
3435
+ q.consecutiveOrgGone = (q.consecutiveOrgGone || 0) + 1;
3436
+ } else {
3437
+ q.consecutiveOrgGone = 0; // any OTHER outcome breaks the org-gone streak
3438
+ }
3439
+ const orgN = q.consecutiveOrgGone;
3384
3440
  // A SUSTAINED ORG-403 is the subscription being gone (canceled Max plan lapsing
3385
3441
  // server-side — measured 2026-09-18/20: "OAuth authentication is currently not
3386
3442
  // allowed for this organization" on 2solarmax@ and privacy@, both canceled Sep 16).
@@ -3388,11 +3444,10 @@ export class AccountManager {
3388
3444
  // pure waste. Latch subscriptionGone (3 strikes, like the 401 rule): the prober
3389
3445
  // skips the account, the TUI says why, and routing treats it as unavailable. A
3390
3446
  // successful re-auth clears it (updateAccountTokens).
3391
- if (status === 403
3392
- && /not allowed for this organization|disabled.*subscription|subscription.*disabled|organization has disabled/i.test(String(message))
3393
- && n >= 3 && !account.subscriptionGone) {
3447
+ if (isOrgGone && orgN >= 3 && !account.subscriptionGone) {
3394
3448
  account.subscriptionGone = true;
3395
- console.error(`[Maxpool] "${account.name}" subscription disabled at the organization (HTTP 403 x${n}) — benching it. Re-enable after re-subscribing, or remove the account (a → d).`);
3449
+ account._subRecheckAt = Date.now(); // first recheck one full window after latching
3450
+ console.error(`[Maxpool] "${account.name}" subscription disabled at the organization (HTTP 403 x${orgN}) — benching it. Re-enable after re-subscribing, or remove the account (a → d).`);
3396
3451
  }
3397
3452
  // A SUSTAINED 401 is dead credentials, not a blip. Latch refreshDead so (a) the
3398
3453
  // prober stops re-POSTing a rejected token every 60s forever — measured 2026-08-10:
@@ -3438,7 +3493,7 @@ export class AccountManager {
3438
3493
  }
3439
3494
 
3440
3495
  /** Store the latest reset-card listing for an account (prober piggyback).
3441
- * Cards are display/decision state only — routing never reads them. */
3496
+ * Read by redeem policy AND by _scoreAccount's reset-aware preference. */
3442
3497
  applyResetCards(accountIndex, cards) {
3443
3498
  const account = this.accounts[accountIndex];
3444
3499
  if (!account || !cards) return;
@@ -4009,7 +4064,7 @@ export class AccountManager {
4009
4064
  // A dead refresh token (invalid_grant) is PERMANENT until browser re-auth —
4010
4065
  // never auto-retry it. Without this the prober re-POSTs the rejected token every
4011
4066
  // ~60s forever (hammering Anthropic's OAuth endpoint). Cleared on re-login.
4012
- if (account.refreshDead || account.subscriptionGone) return false;
4067
+ if (account.refreshDead || (account.subscriptionGone && !account._subRecheck)) return false;
4013
4068
 
4014
4069
  // A DISABLED account never spends its single-use refresh token. The prober still
4015
4070
  // READS its quota by design (you disable an exhausted account and still want to
package/src/prober.js CHANGED
@@ -12,6 +12,10 @@ import { listResetCards, redeemResetCard } from './zai-reset-cards.js';
12
12
  import { listResetGrants, claimResetGrant } from './claude-reset-grants.js';
13
13
  import { decideZai, decideClaude } from './reset-policy.js';
14
14
 
15
+ // How often a subscription-latched account is rechecked (one probe + at most one
16
+ // token refresh per window — cheap, and bounded so a truly canceled plan isn't hammered).
17
+ export const SUB_RECHECK_MS = 30 * 60_000;
18
+
15
19
  export class Prober {
16
20
  constructor(accountManager, { intervalMs = 0, probeFn = fetchUsage, providerProbeFn = fetchProviderUsage, grantsFn = listResetGrants, timeoutMs = 10_000, log = console.log, usageGapMs = null } = {}) {
17
21
  this.am = accountManager;
@@ -245,7 +249,19 @@ export class Prober {
245
249
  // endpoint 403s before answering anything, so probing is pure waste until the org
246
250
  // accepts OAuth again. Skipping also stops the every-60s hammer that ran 700+ times
247
251
  // on 2solarmax@ between 2026-09-18 and 09-20.
248
- if (account.subscriptionGone) return { ok: false, status: 403 };
252
+ // SELF-CLEAR RECHECK (2026-10-08): the latch used to be terminal — the account was
253
+ // skipped here AND by ensureTokenFresh, so a single transient org-403 benched a live
254
+ // subscription until manual re-auth. Recheck it at a low cadence: a 200 clears the
255
+ // latch (applyUsageData), another org-403 keeps it benched.
256
+ if (account.subscriptionGone) {
257
+ // A latch set outside recordProbeError (state restore, tests, older builds) has no
258
+ // stamp: treat NOW as the latch moment so the first recheck waits a full window.
259
+ if (!account._subRecheckAt) account._subRecheckAt = Date.now();
260
+ const since = Date.now() - account._subRecheckAt;
261
+ if (since < SUB_RECHECK_MS) return { ok: false, status: 403 };
262
+ account._subRecheckAt = Date.now();
263
+ account._subRecheck = true;
264
+ }
249
265
  try {
250
266
  // Claude banked resets (cedar_ember) piggyback on the oauth poll — same
251
267
  // OWNER GATE as z.ai cards: a DISABLED account is never listed or claimed.
@@ -288,6 +304,8 @@ export class Prober {
288
304
  } catch (e) { // best-effort; never let a probe throw
289
305
  this.am.recordProbeError?.(account.index, e?.message || String(e), null);
290
306
  return { ok: false, status: null };
307
+ } finally {
308
+ account._subRecheck = false;
291
309
  }
292
310
  }
293
311
 
package/src/server.js CHANGED
@@ -39,6 +39,9 @@ const isNetworkCode = c => Boolean(c) && NETWORK_ERROR_CODES.has(c);
39
39
  const NETWORK_SOAK_NONSTREAM_MS = Math.max(5_000,
40
40
  Number(process.env.MAXPOOL_NETWORK_SOAK_NONSTREAM_MS) || 45_000);
41
41
  const QUEUE_KEEPALIVE = 'event: ping\ndata: {}\n\n';
42
+ // Claude Code's absolute per-stream ceiling (binary-verified 2026-10-07: idle timeout =
43
+ // min(env-or-300s-floor, 1800000)). Nothing resets it; the stream dies at 30 min.
44
+ const CLI_STREAM_CEILING_MS = 25 * 60_000; // bound network holds strictly below it
42
45
  // 240s, strictly BELOW Claude Code's hard 300s stall floor. At the old 300_000 the two
43
46
  // timers were a dead heat and the client always won — maxpool's clock starts when a chunk
44
47
  // is READ from upstream, strictly before the client parses it. Result: `upstream idle
@@ -1732,12 +1735,19 @@ function computeQueueWindowMs({
1732
1735
  // (the `cc` alias sets 3h) otherwise licenses a multi-hour hold on a connection that is
1733
1736
  // simply gone. Error-fast + client reconnect beats an unattended hold.
1734
1737
  // Network holds are NOT special-cased short any more. maxpool already re-polls every ~1s
1735
- // and each retry issues a FRESH fetch, so a hold IS "keep probing, resume the moment any
1736
- // route returns" — exactly what an unattended agent needs to survive a connectivity blip.
1737
- // Failing fast at 2 minutes handed the turn to Claude Code's retry loop, which is the
1738
- // thing that loses accumulated work. Visibility is paid for by logging/TUI, not by
1739
- // truncating the wait.
1740
- if (cause === 'network' && networkMaxWaitMs != null) windowMs = Math.min(windowMs, Math.max(networkMaxWaitMs, streamClientToleranceMs || 0));
1738
+ // Network holds are NOT special-cased short any more — but they must stay under the
1739
+ // CLI's ABSOLUTE stream ceiling (30 min: Claude Code kills the stream at
1740
+ // min(idle-env, 1800000ms) regardless of bytes flowing — pings reset the idle
1741
+ // watchdog, NOT this ceiling; 49 client-abandoned 30-33-min holds measured
1742
+ // 2026-10-07 when the client's 3h idle header licensed a 3h network hold).
1743
+ // A hold past the ceiling is worse than erroring: the client that started the
1744
+ // request is already gone when capacity returns. The client's own retry loop
1745
+ // (which survives outages natively) takes over after our fast error.
1746
+ // streamClientToleranceMs may be 3h from x-maxpool-client-stream-idle-ms, so it
1747
+ // cannot bound a NETWORK hold — only a capacity/quota hold with real content.
1748
+ if (cause === 'network') {
1749
+ windowMs = Math.min(windowMs, CLI_STREAM_CEILING_MS);
1750
+ }
1741
1751
  return windowMs;
1742
1752
  }
1743
1753
 
@@ -2573,8 +2583,18 @@ async function queueAndRetry(
2573
2583
  // floor applies with nothing resetting it — we must give up well before that so the user
2574
2584
  // sees a real message instead of a client-side timeout.
2575
2585
  if (cause === 'network') {
2586
+ // The client's ABSOLUTE stream ceiling (Claude Code binary, 2026-10-07: idle timeout
2587
+ // is min(env/300s floor, 1800000ms)) kills a stream at 30 min NO MATTER WHAT bytes
2588
+ // flow — pings reset the idle watchdog, not this ceiling. Measured 2026-10-07: 49
2589
+ // client-abandoned holds clustered at 1700-2100s (30-33 min) while streamHoldMaxMs
2590
+ // licensed 7-day holds; the user saw "Waiting for API response · will retry in X"
2591
+ // across sessions with no recovery because the hold outlived every client that
2592
+ // started it. Cap network holds at 25 min: when the network is actually back the
2593
+ // hold resumes in seconds; when it is not, error-fast lets the CLI's OWN retry
2594
+ // (which survives outages and reconnects — the native behavior the owner compared
2595
+ // against) take over instead of dying on our silent 30-min ceiling.
2576
2596
  const budgetMs = requestInfo.stream
2577
- ? Math.max(60_000, Number(queueConfig.networkMaxWaitMs) || 120_000)
2597
+ ? Math.min(Math.max(60_000, Number(queueConfig.networkMaxWaitMs) || 120_000), CLI_STREAM_CEILING_MS)
2578
2598
  : NETWORK_SOAK_NONSTREAM_MS;
2579
2599
  requestInfo.networkSoakDeadline ||= Date.now() + budgetMs;
2580
2600
  if (Date.now() >= requestInfo.networkSoakDeadline) {