maxpool 1.5.12 → 1.5.14
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 +1 -1
- package/src/account-manager.js +30 -2
- package/src/index.js +42 -6
- package/src/tui.js +6 -8
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -1617,10 +1617,27 @@ export class AccountManager {
|
|
|
1617
1617
|
return 'normal';
|
|
1618
1618
|
}
|
|
1619
1619
|
|
|
1620
|
+
// True only when the account is REJECTED ACCOUNT-WIDE — a 'rejected' unified
|
|
1621
|
+
// status CORROBORATED by an actually-exhausted unified bucket. A bare 'rejected'
|
|
1622
|
+
// with healthy unified buckets is a PER-MODEL sub-limit rejection (e.g. Fable
|
|
1623
|
+
// weekly, tracked in scopedWeekly) that Anthropic reports on the unified-status
|
|
1624
|
+
// header — it must NOT bench or "block" the whole account, which still has
|
|
1625
|
+
// headroom for its other models. Guards both routing (_weeklyRawState) and the
|
|
1626
|
+
// TUI "blocked" label against the per-model→account-wide conflation. NOTE: this
|
|
1627
|
+
// is a display/inter-429 hint, not the primary bench — a genuinely-dead account
|
|
1628
|
+
// is benched account-wide by markRateLimited (status='throttled') on its next 429.
|
|
1629
|
+
_isAccountWideRejected(account) {
|
|
1630
|
+
const q = account?.quota || {};
|
|
1631
|
+
if (q.unifiedStatus !== 'rejected') return false;
|
|
1632
|
+
const floor = this.scheduler.weeklyExhaustedThreshold;
|
|
1633
|
+
return (Number.isFinite(q.unified5h) && q.unified5h >= floor)
|
|
1634
|
+
|| (Number.isFinite(q.unified7d) && q.unified7d >= floor);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1620
1637
|
_weeklyRawState(account) {
|
|
1621
1638
|
const q = account.quota;
|
|
1622
1639
|
this._clearExpiredQuotas(account);
|
|
1623
|
-
if (
|
|
1640
|
+
if (this._isAccountWideRejected(account)) return 'exhausted';
|
|
1624
1641
|
if (q.unified7d == null) return 'unknown';
|
|
1625
1642
|
|
|
1626
1643
|
const used = clamp01(q.unified7d);
|
|
@@ -1727,7 +1744,18 @@ export class AccountManager {
|
|
|
1727
1744
|
}
|
|
1728
1745
|
|
|
1729
1746
|
const uStatus = headers['anthropic-ratelimit-unified-status'];
|
|
1730
|
-
if (uStatus)
|
|
1747
|
+
if (uStatus) {
|
|
1748
|
+
// 'rejected' with HEALTHY unified buckets is a PER-MODEL sub-limit rejection
|
|
1749
|
+
// (e.g. Fable weekly) — Anthropic surfaces it on the account-wide status
|
|
1750
|
+
// header, but recording it account-wide would bench the account for EVERY
|
|
1751
|
+
// model and render it "blocked" (the per-model cap is tracked in scopedWeekly).
|
|
1752
|
+
// Only honor 'rejected' when a unified bucket corroborates a real account block.
|
|
1753
|
+
const floor = this.scheduler.weeklyExhaustedThreshold;
|
|
1754
|
+
const unifiedExhausted =
|
|
1755
|
+
(Number.isFinite(account.quota.unified5h) && account.quota.unified5h >= floor)
|
|
1756
|
+
|| (Number.isFinite(account.quota.unified7d) && account.quota.unified7d >= floor);
|
|
1757
|
+
account.quota.unifiedStatus = (uStatus === 'rejected' && !unifiedExhausted) ? 'allowed' : uStatus;
|
|
1758
|
+
}
|
|
1731
1759
|
|
|
1732
1760
|
// Standard rate limits (API key accounts)
|
|
1733
1761
|
const tokensLimit = parseInt(headers['anthropic-ratelimit-tokens-limit'], 10);
|
package/src/index.js
CHANGED
|
@@ -156,10 +156,20 @@ async function supervisorCommand() {
|
|
|
156
156
|
// supervisor's acceptor is only LIVE during the brief cutover gap (it stops
|
|
157
157
|
// once a worker confirms it is the sole acceptor). A bare handler is required
|
|
158
158
|
// so the rare gap-accepted socket isn't left dangling.
|
|
159
|
-
const relistenMaster = () => new Promise((resolve, reject) => {
|
|
159
|
+
const relistenMaster = (retriesOnBusy = 20) => new Promise((resolve, reject) => {
|
|
160
160
|
if (masterServer.listening) { resolve(); return; }
|
|
161
|
-
const onErr = err => { masterServer.removeListener('listening', onListen); reject(err); };
|
|
162
161
|
const onListen = () => { masterServer.removeListener('error', onErr); resolve(); };
|
|
162
|
+
const onErr = err => {
|
|
163
|
+
masterServer.removeListener('listening', onListen);
|
|
164
|
+
// A just-SIGKILLed worker (seamless-reload fallback) may not have released the
|
|
165
|
+
// listening fd yet — SIGKILL is async; the port frees within a few ms. Retry
|
|
166
|
+
// bounded (~1s) rather than crashing the supervisor via handleServerListenError.
|
|
167
|
+
if (err.code === 'EADDRINUSE' && retriesOnBusy > 0) {
|
|
168
|
+
setTimeout(() => relistenMaster(retriesOnBusy - 1).then(resolve, reject), 50);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
reject(err);
|
|
172
|
+
};
|
|
163
173
|
masterServer.once('error', onErr);
|
|
164
174
|
masterServer.once('listening', onListen);
|
|
165
175
|
masterServer.listen(port, host);
|
|
@@ -347,6 +357,19 @@ async function supervisorCommand() {
|
|
|
347
357
|
|
|
348
358
|
try {
|
|
349
359
|
while (true) {
|
|
360
|
+
// Ensure the master socket is LISTENING before handing it to a cold worker.
|
|
361
|
+
// After the previous primary worker sent MSG_PRIMARY the supervisor ran
|
|
362
|
+
// closeMasterAccept() (masterServer.close()), and the exiting worker released
|
|
363
|
+
// the last fd — so on every respawn masterServer is closed. Without this,
|
|
364
|
+
// spawnWorker sends a dead handle, the worker ignores it (falsy-handle guard),
|
|
365
|
+
// and never becomes primary → the interactive `r` restart hangs blank. Idempotent
|
|
366
|
+
// (no-op when already listening, e.g. the very first iteration after line 172).
|
|
367
|
+
try {
|
|
368
|
+
await relistenMaster();
|
|
369
|
+
} catch (err) {
|
|
370
|
+
handleServerListenError(err, host, port);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
350
373
|
const worker = spawnWorker({ reload: false });
|
|
351
374
|
const startedAt = Date.now();
|
|
352
375
|
const result = await superviseTurn(worker);
|
|
@@ -725,7 +748,10 @@ async function serverWorkerCommand() {
|
|
|
725
748
|
if (draining) return;
|
|
726
749
|
// Interactive (live TUI) → full cold restart so the fresh worker re-renders the
|
|
727
750
|
// TUI; headless/service → zero-downtime seamless baton (nothing visual to lose).
|
|
728
|
-
|
|
751
|
+
// Test-only: force the interactive cold-restart path headless (a pty can't be
|
|
752
|
+
// allocated in-suite), so the exit-75 respawn is exercised without a real TUI.
|
|
753
|
+
const forceCold = process.env.MAXPOOL_TEST_FORCE_COLD_RESTART === '1';
|
|
754
|
+
if (!forceCold && reloadStrategy({ supervised, useTUI }) === 'seamless') {
|
|
729
755
|
try {
|
|
730
756
|
process.send({ type: MSG_RELOAD_REQUEST });
|
|
731
757
|
return;
|
|
@@ -991,6 +1017,12 @@ async function serverWorkerCommand() {
|
|
|
991
1017
|
// Cold start: take the socket and go primary immediately.
|
|
992
1018
|
await listenOnHandle(handle);
|
|
993
1019
|
await becomePrimary({ viaTakeover: false });
|
|
1020
|
+
} else if (msg?.type === MSG_LISTEN && !handle) {
|
|
1021
|
+
// Defense-in-depth for the stale-handle hang: if the supervisor ever hands
|
|
1022
|
+
// a cold worker a dead/absent socket, fail LOUD and exit-75 so it respawns —
|
|
1023
|
+
// never sit alive-but-not-primary (the blank-screen `r`-restart hang).
|
|
1024
|
+
console.error('[Maxpool] Cold worker received MSG_LISTEN with no socket handle — exiting for respawn.');
|
|
1025
|
+
process.exit(SERVER_RESTART_EXIT_CODE);
|
|
994
1026
|
} else if (msg?.type === MSG_PROBE_READY) {
|
|
995
1027
|
// Headless reload worker: we've booted the new code and restored quota
|
|
996
1028
|
// in memory. Confirm readiness (we are NOT yet accepting / writing).
|
|
@@ -1013,9 +1045,13 @@ async function serverWorkerCommand() {
|
|
|
1013
1045
|
await releaseBatonAndDrain();
|
|
1014
1046
|
}
|
|
1015
1047
|
} catch (err) {
|
|
1016
|
-
|
|
1017
|
-
//
|
|
1018
|
-
|
|
1048
|
+
console.error(`[Maxpool] Worker message error: ${err.message}`);
|
|
1049
|
+
// A COLD worker that failed to listen / become primary has no baton to roll
|
|
1050
|
+
// back to — it would strand blank. Exit-75 so the supervisor respawns it
|
|
1051
|
+
// (closes the silent-non-primary hang class). A RELOAD worker mid-baton must
|
|
1052
|
+
// NOT exit (that kills the supervisor loop) — report MSG_FAILED so the
|
|
1053
|
+
// supervisor rolls back and the old worker stays primary (zero disruption).
|
|
1054
|
+
if (!isReloadWorker) process.exit(SERVER_RESTART_EXIT_CODE);
|
|
1019
1055
|
try { process.send({ type: MSG_FAILED, reason: err.message }); } catch { /* ignore */ }
|
|
1020
1056
|
}
|
|
1021
1057
|
});
|
package/src/tui.js
CHANGED
|
@@ -925,14 +925,12 @@ 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
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
//
|
|
933
|
-
|
|
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') {
|
|
928
|
+
// "blocked" = Anthropic is rejecting the WHOLE account (a 'rejected' unified
|
|
929
|
+
// status corroborated by an exhausted unified bucket). A per-model cap (Fable)
|
|
930
|
+
// is NOT account-wide — it shows as the separate "… maxed" tag, leaving the
|
|
931
|
+
// status column truthful. Keys on a.status (not effectiveStatus) so a genuine
|
|
932
|
+
// block wins even inside an upstream-throttle window.
|
|
933
|
+
if (a.enabled !== false && a.status === 'active' && this.am._isAccountWideRejected?.(a)) {
|
|
936
934
|
effectiveStatus = 'blocked';
|
|
937
935
|
}
|
|
938
936
|
switch (effectiveStatus) {
|