maxpool 1.5.41 → 1.5.43
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/README.md +1 -1
- package/package.json +1 -1
- package/src/account-manager.js +83 -18
- package/src/config.js +15 -7
- package/src/index.js +57 -14
- package/src/server.js +9 -3
- package/src/tui.js +67 -11
package/README.md
CHANGED
|
@@ -270,7 +270,7 @@ TEAMCLAUDE_CONFIG=./my-config.json maxpool server
|
|
|
270
270
|
"weeklySoftThreshold": 0.65,
|
|
271
271
|
"weeklyReserveThreshold": 0.85,
|
|
272
272
|
"weeklyCriticalThreshold": 0.95,
|
|
273
|
-
"weeklyExhaustedThreshold": 0.
|
|
273
|
+
"weeklyExhaustedThreshold": 0.999,
|
|
274
274
|
"weeklyBurnDebtWeight": 0.6
|
|
275
275
|
},
|
|
276
276
|
"retry": {
|
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -91,7 +91,12 @@ const DEFAULT_SCHEDULER = {
|
|
|
91
91
|
weeklySoftThreshold: 0.65,
|
|
92
92
|
weeklyReserveThreshold: 0.85,
|
|
93
93
|
weeklyCriticalThreshold: 0.95,
|
|
94
|
-
|
|
94
|
+
// Use-it-or-lose-it (2026-07-24): bench only at 99.9%, not 98.5% — the weekly quota
|
|
95
|
+
// resets, so reserving the top ~1.5% just wastes it. The thin 0.1% floor is the ONE
|
|
96
|
+
// remaining guard: don't fire a request at an account whose own header already says
|
|
97
|
+
// it's essentially full (a near-guaranteed-waste hard-429). A real 429 sets util≈1.0
|
|
98
|
+
// and still benches here. Critical (0.95-0.999) stays last-resort-only (pass 2).
|
|
99
|
+
weeklyExhaustedThreshold: 0.999,
|
|
95
100
|
weeklyBurnDebtWeight: 0.6,
|
|
96
101
|
// Routing-cost tuning (lower cost = preferred). The goal is to AVOID
|
|
97
102
|
// short-term (rate/concurrency) throttling by spreading load across healthy
|
|
@@ -103,6 +108,18 @@ const DEFAULT_SCHEDULER = {
|
|
|
103
108
|
capPenaltyWeight: 10, // steep penalty per unit of in-flight depth past D (throttle safety floor)
|
|
104
109
|
paceCostWeight: 1.5, // soft de-preference of accounts burning ahead of pace (was the ×6 term)
|
|
105
110
|
scarcityWeight: 6, // legacy; superseded by paceCostWeight (kept so old configs don't error)
|
|
111
|
+
// Reserve-account OVERFLOW model. A weekly-RESERVE account (util 0.85-0.95) used to
|
|
112
|
+
// sit idle behind a healthy-only first pass; now it's eligible in the first pass but
|
|
113
|
+
// ranked BELOW healthy accounts by _reserveCost (so healthy stays first-pick). Among
|
|
114
|
+
// reserve accounts the SOONEST-to-reset is cheapest (use-it-or-lose-it); the further
|
|
115
|
+
// into the band, the costlier (preserve). Critical (≥0.95)/exhausted stay hard-benched
|
|
116
|
+
// in the second pass, never softened. See _reserveCost + _selectNext's 2-pass ladder.
|
|
117
|
+
reserveFloorCost: 5, // base overflow cost — keeps any reserve account behind an idle healthy one (~2)
|
|
118
|
+
reserveBandWeight: 8, // ×(util-0.85)/0.10: deeper into the reserve band ⇒ costlier ⇒ consumed later
|
|
119
|
+
reserveScarcityWeight: 6, // ×weekly _windowScarcity: reset-timing weight, ON TOP of the global paceCost, so
|
|
120
|
+
// a near-reset reserve account is used freely and a far-from-reset one is used last
|
|
121
|
+
reserveConcurrencyTarget: 2, // tighter in-flight cap for reserve (capPenalty bites at inflight>2 ⇒ load fans out
|
|
122
|
+
// across the fleet before any single reserve account is dogpiled toward a 429)
|
|
106
123
|
spreadShareWeight: 3, // multiplies an account's share of recent fleet load (0..1)
|
|
107
124
|
recoveryRampWeight: 4, // decaying penalty applied to a just-recovered account
|
|
108
125
|
recoveryRampMs: 5 * 60_000, // how long the post-recovery ramp lasts
|
|
@@ -121,16 +138,25 @@ const DEFAULT_SCHEDULER = {
|
|
|
121
138
|
// may be served by a provider FAMILY other than its home (Claude ↔ GLM ↔ Kimi).
|
|
122
139
|
// 'never' — strict pin: a Claude session uses Claude only; a GLM session
|
|
123
140
|
// uses GLM only; a Kimi session uses Kimi only.
|
|
124
|
-
// 'when-exhausted'—
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
// session may fall to Kimi once GLM is exhausted.
|
|
141
|
+
// 'when-exhausted'— home family preferred; a Claude session falls back to GLM/Kimi
|
|
142
|
+
// only once all Claude accounts are unavailable, and a GLM session
|
|
143
|
+
// may fall to Kimi once GLM is exhausted.
|
|
128
144
|
// 'always' — providers peer with Claude for a Claude/unknown session
|
|
129
145
|
// (load-balanced, not last-resort).
|
|
146
|
+
// DEFAULT is 'never' (2026-07-24): a Claude Code session stays on Anthropic. Routing
|
|
147
|
+
// Claude→GLM/Kimi proved unreliable (the coding legs 403/429 + ignore the model id),
|
|
148
|
+
// so cross-routing OUT of Claude is OFF by default; re-enable via the TUI routing
|
|
149
|
+
// cycle or config. This governs ONLY the Claude→provider direction.
|
|
130
150
|
// INVARIANT (all policies): a GLM/Kimi-origin session NEVER routes to an Anthropic
|
|
131
151
|
// account — Anthropic 400s on a non-`srvtoolu_` server_tool_use id; that direction
|
|
132
152
|
// is unfixable, not policy-tunable.
|
|
133
|
-
crossProviderFallbackPolicy: '
|
|
153
|
+
crossProviderFallbackPolicy: 'never',
|
|
154
|
+
// The OTHER cross direction, independent of the policy above: may a provider-origin
|
|
155
|
+
// (GLM/Kimi) session cross to the OTHER provider (GLM↔Kimi)? Default ON — both legs are
|
|
156
|
+
// lenient and accept each other's ids, and it's the reliable direction the user wants
|
|
157
|
+
// kept even while Claude→provider is 'never'. Only has effect under policy:'never' (the
|
|
158
|
+
// 'when-exhausted'/'always' paths never pinned to home). Set false for a strict home-pin.
|
|
159
|
+
providerCrossFallback: true,
|
|
134
160
|
};
|
|
135
161
|
const LOAD_EVENT_MAX_AGE_MS = 60 * 60 * 1000;
|
|
136
162
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -1429,14 +1455,16 @@ export class AccountManager {
|
|
|
1429
1455
|
// Else fall through to the candidate score loop, which re-homes the session
|
|
1430
1456
|
// onto the best healthy account via _bindSession on acquire.
|
|
1431
1457
|
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
1437
|
-
//
|
|
1458
|
+
// Two-pass ladder for BOTH bound and unbound sessions. Pass 1 admits healthy AND
|
|
1459
|
+
// reserve accounts together; the reserve accounts carry _reserveCost so a healthy
|
|
1460
|
+
// account is preferred whenever one is comparably loaded — but a reserve account
|
|
1461
|
+
// CAN outrank a badly-slammed healthy one (its capPenalty is unbounded), which is
|
|
1462
|
+
// intended load-spread, not a regression. Pass 2 adds critical only when pass 1 is
|
|
1463
|
+
// empty (the untouched hard fallback). A bound session reaches this loop only once
|
|
1464
|
+
// we've decided to LEAVE its account (the sticky "stay put" path returns above); it
|
|
1465
|
+
// may now re-home onto a lightly-loaded reserve account rather than a slammed
|
|
1466
|
+
// healthy one — deliberate, since the whole point is to USE reserve accounts.
|
|
1438
1467
|
const weeklyPasses = [
|
|
1439
|
-
{ allowWeeklyReserve: false, allowWeeklyCritical: false },
|
|
1440
1468
|
{ allowWeeklyReserve: true, allowWeeklyCritical: false },
|
|
1441
1469
|
{ allowWeeklyReserve: true, allowWeeklyCritical: true },
|
|
1442
1470
|
];
|
|
@@ -1715,9 +1743,13 @@ export class AccountManager {
|
|
|
1715
1743
|
// The transcript can't replay to Claude (a foreign server_tool_use id, or
|
|
1716
1744
|
// content Anthropic rejected on replay) — provider accounts ONLY, regardless
|
|
1717
1745
|
// of policy. Providers are lenient and accept each other's ids (GLM↔Kimi is
|
|
1718
|
-
// fine)
|
|
1746
|
+
// fine). Under 'never' we keep the session on its home provider ONLY when
|
|
1747
|
+
// providerCrossFallback is explicitly off; by default GLM↔Kimi crossing stays
|
|
1748
|
+
// allowed even under 'never' (that reliable direction is governed separately
|
|
1749
|
+
// from the Claude→provider policy).
|
|
1719
1750
|
if (account.type !== 'provider') return false;
|
|
1720
|
-
if (policy === 'never' && homeProvider && account.provider !== homeProvider
|
|
1751
|
+
if (policy === 'never' && homeProvider && account.provider !== homeProvider
|
|
1752
|
+
&& this.scheduler.providerCrossFallback === false) return false;
|
|
1721
1753
|
return true;
|
|
1722
1754
|
}
|
|
1723
1755
|
|
|
@@ -1843,9 +1875,16 @@ export class AccountManager {
|
|
|
1843
1875
|
const concurrency = inflight * this.scheduler.concurrencyWeight;
|
|
1844
1876
|
|
|
1845
1877
|
// Steep soft cap past depth D — the throttle safety floor. No single
|
|
1846
|
-
// account absorbs a deep concurrent burst no matter how "cheap" it looks.
|
|
1878
|
+
// account absorbs a deep concurrent burst no matter how "cheap" it looks. A
|
|
1879
|
+
// RESERVE account gets a tighter D (reserveConcurrencyTarget) so its capPenalty
|
|
1880
|
+
// bites sooner — load fans out across the fleet before a low-quota account is
|
|
1881
|
+
// dogpiled toward a 429.
|
|
1882
|
+
const weeklyState = this._weeklyRawState(account);
|
|
1883
|
+
const concTarget = weeklyState === 'reserve'
|
|
1884
|
+
? this.scheduler.reserveConcurrencyTarget
|
|
1885
|
+
: this.scheduler.perAccountConcurrencyTarget;
|
|
1847
1886
|
const capPenalty = this.scheduler.capPenaltyWeight
|
|
1848
|
-
* Math.max(0, inflight -
|
|
1887
|
+
* Math.max(0, inflight - concTarget);
|
|
1849
1888
|
|
|
1850
1889
|
// Burn-pace COST only (demoted from the old dominant scarcity×6 term): a
|
|
1851
1890
|
// soft de-preference of accounts burning ahead of an even pace. Never a bench.
|
|
@@ -1867,6 +1906,7 @@ export class AccountManager {
|
|
|
1867
1906
|
const spread = share * this.scheduler.spreadShareWeight;
|
|
1868
1907
|
|
|
1869
1908
|
const ramp = this._recoveryRamp(account, now);
|
|
1909
|
+
const reserveCost = this._reserveCost(account, now, weeklyState);
|
|
1870
1910
|
const failurePenalty = account.consecutiveFailures * 5;
|
|
1871
1911
|
// NO unknown-quota bonus. An account whose quota we cannot see must never be
|
|
1872
1912
|
// MORE attractive than a known-healthy one — the old -0.5 nudge (safe only
|
|
@@ -1876,7 +1916,7 @@ export class AccountManager {
|
|
|
1876
1916
|
// default) learns the real number within a cycle. `probing`/requalify still
|
|
1877
1917
|
// flags a never-seen account for learning — that path is unchanged.
|
|
1878
1918
|
|
|
1879
|
-
return concurrency + capPenalty + paceCost + scopedPace + spread + ramp + failurePenalty;
|
|
1919
|
+
return concurrency + capPenalty + paceCost + scopedPace + spread + ramp + reserveCost + failurePenalty;
|
|
1880
1920
|
}
|
|
1881
1921
|
|
|
1882
1922
|
/**
|
|
@@ -1929,6 +1969,31 @@ export class AccountManager {
|
|
|
1929
1969
|
return Math.max(0, used - elapsedFrac);
|
|
1930
1970
|
}
|
|
1931
1971
|
|
|
1972
|
+
/**
|
|
1973
|
+
* Overflow cost for a weekly-RESERVE account (util 0.85-0.95). 0 for every other
|
|
1974
|
+
* tier — normal/soft aren't softened, and critical/exhausted are hard-benched by the
|
|
1975
|
+
* pass gate (never made cheaper here). This is what lets a reserve account be eligible
|
|
1976
|
+
* in the FIRST selection pass yet still rank below healthy accounts: floor keeps it
|
|
1977
|
+
* strictly behind an idle healthy pick, the band term makes deeper-into-reserve costlier
|
|
1978
|
+
* (consumed later / preserved), and the reset-timing term makes the soonest-to-reset the
|
|
1979
|
+
* cheapest reserve pick (use-it-or-lose-it). A slammed HEALTHY account can still score
|
|
1980
|
+
* above a lightly-loaded reserve one (its capPenalty is unbounded) — that's intended
|
|
1981
|
+
* load-spread, not a violation of "healthy first".
|
|
1982
|
+
*/
|
|
1983
|
+
_reserveCost(account, now = Date.now(), weeklyState = this._weeklyRawState(account)) {
|
|
1984
|
+
if (weeklyState !== 'reserve') return 0;
|
|
1985
|
+
const q = account.quota;
|
|
1986
|
+
const util = clamp01(q.unified7d);
|
|
1987
|
+
const band = clamp01(
|
|
1988
|
+
(util - this.scheduler.weeklyReserveThreshold)
|
|
1989
|
+
/ Math.max(1e-6, this.scheduler.weeklyCriticalThreshold - this.scheduler.weeklyReserveThreshold),
|
|
1990
|
+
);
|
|
1991
|
+
const pace = this._windowScarcity(q.unified7d, q.unified7dReset, WEEK_MS, now);
|
|
1992
|
+
return this.scheduler.reserveFloorCost
|
|
1993
|
+
+ this.scheduler.reserveBandWeight * band
|
|
1994
|
+
+ this.scheduler.reserveScarcityWeight * pace;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1932
1997
|
/**
|
|
1933
1998
|
* Decaying penalty applied for `recoveryRampMs` after an account un-parks,
|
|
1934
1999
|
* so a freshly-recovered account (which has ~0 recent load and may look most
|
package/src/config.js
CHANGED
|
@@ -90,18 +90,26 @@ export function createDefaultConfig() {
|
|
|
90
90
|
weeklySoftThreshold: 0.65,
|
|
91
91
|
weeklyReserveThreshold: 0.85,
|
|
92
92
|
weeklyCriticalThreshold: 0.95,
|
|
93
|
-
|
|
93
|
+
// Use-it-or-lose-it: bench only at 99.9% (the weekly quota resets, so reserving
|
|
94
|
+
// the top ~1.5% wastes it). A real 429 sets util≈1.0 and still benches.
|
|
95
|
+
weeklyExhaustedThreshold: 0.999,
|
|
94
96
|
// Cross-PROVIDER fallback policy for 'cc all' (profile=all): whether a session
|
|
95
|
-
// may be served by a provider family other than its home (Claude
|
|
96
|
-
// 'never' —
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
97
|
+
// may be served by a provider family other than its home (Claude → GLM/Kimi).
|
|
98
|
+
// 'never' — (DEFAULT) a Claude Code session stays on Anthropic; no
|
|
99
|
+
// GLM/Kimi fallback. Routing Claude→providers proved unreliable
|
|
100
|
+
// (the coding legs 403/429 + ignore the model id), so it's OFF
|
|
101
|
+
// by default — re-enable here or via the TUI 'f' key.
|
|
102
|
+
// 'when-exhausted'— home family preferred; a Claude session falls to GLM/Kimi
|
|
103
|
+
// only once all Claude accounts are unavailable.
|
|
100
104
|
// 'always' — providers peer with Claude for a Claude/unknown session.
|
|
101
105
|
// A GLM/Kimi-origin session NEVER routes to Anthropic under ANY policy — that
|
|
102
106
|
// direction 400s on the non-`srvtoolu_` tool-use id and is unfixable. Toggle
|
|
103
107
|
// live in the TUI (Routing sub-mode, 'f' key).
|
|
104
|
-
crossProviderFallbackPolicy: '
|
|
108
|
+
crossProviderFallbackPolicy: 'never',
|
|
109
|
+
// The OTHER cross direction (independent of the policy above): may a provider-origin
|
|
110
|
+
// (GLM/Kimi) session cross to the OTHER provider (GLM↔Kimi)? Default ON — reliable,
|
|
111
|
+
// both legs accept each other's ids. Only has effect under policy:'never'.
|
|
112
|
+
providerCrossFallback: true,
|
|
105
113
|
},
|
|
106
114
|
retry: {
|
|
107
115
|
maxAttemptsPerRequest: 0,
|
package/src/index.js
CHANGED
|
@@ -966,6 +966,13 @@ async function serverWorkerCommand() {
|
|
|
966
966
|
crossProviderFallbackPolicy: config.scheduler.crossProviderFallbackPolicy,
|
|
967
967
|
};
|
|
968
968
|
}
|
|
969
|
+
// Persist live-toggled automatic-update flags (the TUI 'u' Updates menu). Same
|
|
970
|
+
// reason as the policy above: without this the toggle takes effect in memory but
|
|
971
|
+
// silently reverts on the next config write / restart. Only write keys defined
|
|
972
|
+
// in-memory so a disk value is never clobbered with undefined.
|
|
973
|
+
for (const key of ['updateCheck', 'autoUpdate', 'autoApply']) {
|
|
974
|
+
if (config[key] !== undefined) diskConfig[key] = config[key];
|
|
975
|
+
}
|
|
969
976
|
// Write in-memory accounts as the authoritative state, preserving
|
|
970
977
|
// extra disk-only fields (e.g. importFrom) where the account still exists.
|
|
971
978
|
// Use live tokens from AccountManager (not the stale config.accounts copy).
|
|
@@ -1038,18 +1045,55 @@ async function serverWorkerCommand() {
|
|
|
1038
1045
|
restartController.requestRestart();
|
|
1039
1046
|
}
|
|
1040
1047
|
};
|
|
1048
|
+
// Manual apply (the TUI 'u' → check & apply now): reload into a freshly-installed
|
|
1049
|
+
// newer version REGARDLESS of autoApply — the user asked for it explicitly, so it must
|
|
1050
|
+
// NOT no-op just because the AUTOMATIC path is off (that no-op is exactly the "relaunch
|
|
1051
|
+
// still shows the old version" pain this menu exists to kill).
|
|
1052
|
+
const applyNow = r => {
|
|
1053
|
+
if (r?.applicable && restartController) {
|
|
1054
|
+
markApplied(r.installedVersion);
|
|
1055
|
+
notifyUpdate('Applying update — seamless reload…');
|
|
1056
|
+
restartController.requestRestart();
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
// ONE in-flight latch shared by the periodic timer, the cold-start probe, AND the
|
|
1060
|
+
// manual action, so two `npm i -g` can never race (global-package corruption). hasLease
|
|
1061
|
+
// already blocks CROSS-worker races; this blocks same-worker timer-vs-manual overlap.
|
|
1062
|
+
let updateInFlight = false;
|
|
1063
|
+
const runUpdateCheck = async ({ announce, apply, forceInstall = false }) => {
|
|
1064
|
+
if (updateInFlight || !hasLease || config?.updateCheck === false) return undefined;
|
|
1065
|
+
updateInFlight = true;
|
|
1066
|
+
try {
|
|
1067
|
+
// The MANUAL action forces the install (autoUpdate:true) so pressing 'u' → 'c'
|
|
1068
|
+
// actually downloads + reloads even when AUTOMATIC updates are off. Without this,
|
|
1069
|
+
// maybeCheckForUpdate short-circuits before installing when config.autoUpdate is
|
|
1070
|
+
// false (the default) and just tells the user to run `npm i -g` by hand — the exact
|
|
1071
|
+
// quit/relaunch dance this menu exists to kill. The periodic + cold-start paths
|
|
1072
|
+
// pass the real config so they still respect the user's autoUpdate choice.
|
|
1073
|
+
const cfg = forceInstall ? { ...config, autoUpdate: true } : config;
|
|
1074
|
+
const r = await maybeCheckForUpdate(cfg, notifyUpdate, info => { accountManager.versionInfo = info; }, { announce });
|
|
1075
|
+
apply(r);
|
|
1076
|
+
return r;
|
|
1077
|
+
} catch { return undefined; /* update path is best-effort; never break the proxy */ }
|
|
1078
|
+
finally { updateInFlight = false; }
|
|
1079
|
+
};
|
|
1080
|
+
// 'u' → check & apply now: force an immediate check + install + seamless reload so the
|
|
1081
|
+
// user never has to quit/relaunch to pick up a release. Reports the up-to-date case too.
|
|
1082
|
+
const checkForUpdatesNow = async () => {
|
|
1083
|
+
if (updateInFlight) { notifyUpdate('Update check already running'); return; }
|
|
1084
|
+
if (!hasLease) { notifyUpdate('Updates run on the primary worker only'); return; }
|
|
1085
|
+
if (config?.updateCheck === false) { notifyUpdate('Update checks are disabled in config'); return; }
|
|
1086
|
+
notifyUpdate('Checking for updates…');
|
|
1087
|
+
const r = await runUpdateCheck({ announce: true, apply: applyNow, forceInstall: true });
|
|
1088
|
+
if (r && !r.hasUpdate) notifyUpdate('Already on the latest version');
|
|
1089
|
+
};
|
|
1090
|
+
if (tui) tui.checkNow = checkForUpdatesNow;
|
|
1041
1091
|
const updateIntervalMs = Math.max(60_000, Number(process.env.MAXPOOL_UPDATE_CHECK_INTERVAL_MS) || 6 * 60 * 60 * 1000);
|
|
1042
1092
|
updateTimer = setInterval(() => {
|
|
1043
|
-
// ONLY the lease-holding primary self-installs — the timer is unconditional across
|
|
1044
|
-
// EVERY worker (incl. a headless reload worker), so gate on hasLease here to avoid
|
|
1045
|
-
// two `npm i -g` racing during a reload overlap (global-package corruption). A
|
|
1046
|
-
// headless reload worker never holds the lease, so it never installs/auto-applies.
|
|
1047
|
-
if (config?.updateCheck === false || !hasLease) return;
|
|
1048
1093
|
// announce:false — the persistent TUI banner is the passive reminder; only real
|
|
1049
|
-
// actions (installing / applying) log
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
.catch(() => {});
|
|
1094
|
+
// actions (installing / applying) log. runUpdateCheck's latch + hasLease gate prevent
|
|
1095
|
+
// racing installs (a headless reload worker never holds the lease, so never installs).
|
|
1096
|
+
runUpdateCheck({ announce: false, apply: applyUpdateIfReady });
|
|
1053
1097
|
}, updateIntervalMs);
|
|
1054
1098
|
updateTimer.unref();
|
|
1055
1099
|
|
|
@@ -1099,11 +1143,10 @@ async function serverWorkerCommand() {
|
|
|
1099
1143
|
// A reload-spawned/takeover worker must NEVER re-probe (1x not 2x traffic).
|
|
1100
1144
|
if (!viaTakeover && !isReloadWorker) {
|
|
1101
1145
|
if (process.env.MAXPOOL_TEST_LOG_UPDATE_CHECK === '1') console.log('[Maxpool] UPDATE_CHECK_FIRED');
|
|
1102
|
-
// Cold-start-behind: download AND (autoApply) self-apply via the SAME helper
|
|
1103
|
-
// periodic path — so the version is applied, not marked-attempted-then-stranded
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
.catch(() => {});
|
|
1146
|
+
// Cold-start-behind: download AND (autoApply) self-apply via the SAME latched helper
|
|
1147
|
+
// as the periodic path — so the version is applied, not marked-attempted-then-stranded,
|
|
1148
|
+
// and it can't race the periodic timer's install.
|
|
1149
|
+
runUpdateCheck({ announce: true, apply: applyUpdateIfReady });
|
|
1107
1150
|
} else if (process.env.MAXPOOL_TEST_LOG_UPDATE_CHECK === '1') {
|
|
1108
1151
|
console.log('[Maxpool] UPDATE_CHECK_SKIPPED (reload)');
|
|
1109
1152
|
}
|
package/src/server.js
CHANGED
|
@@ -1348,16 +1348,22 @@ function classifyRateLimit(account, headers, body, opts = {}) {
|
|
|
1348
1348
|
// genuine account cap with stripped headers must bench the whole account, not one
|
|
1349
1349
|
// model). Neither bucket may be at/above the exhaustion floor.
|
|
1350
1350
|
const haveUnifiedEvidence = Number.isFinite(weekly) || Number.isFinite(fiveHour);
|
|
1351
|
+
// Exhaustion floor for 429-SCOPE classification — kept in sync with the scheduler's
|
|
1352
|
+
// weeklyExhaustedThreshold (0.999, use-it-or-lose-it). A 429 whose unified buckets are
|
|
1353
|
+
// still below the floor isn't weekly-exhaustion, so a model-family + long-retry-after
|
|
1354
|
+
// 429 benches only that model, not the whole (still-usable) account — matching routing,
|
|
1355
|
+
// which now treats 0.95-0.999 as usable (critical), not benched.
|
|
1356
|
+
const EXHAUSTION_FLOOR = 0.999;
|
|
1351
1357
|
const unifiedNotExhausted =
|
|
1352
|
-
(!Number.isFinite(weekly) || weekly <
|
|
1358
|
+
(!Number.isFinite(weekly) || weekly < EXHAUSTION_FLOOR) && (!Number.isFinite(fiveHour) || fiveHour < EXHAUSTION_FLOOR);
|
|
1353
1359
|
const modelScope =
|
|
1354
1360
|
(fam && haveUnifiedEvidence && unifiedNotExhausted && Number.isFinite(retryAfter) && retryAfter >= 30 * 60)
|
|
1355
1361
|
? fam : null;
|
|
1356
1362
|
|
|
1357
1363
|
const quotaHeaderExhaustion =
|
|
1358
1364
|
unifiedStatus === 'rejected'
|
|
1359
|
-
|| (Number.isFinite(fiveHour) && fiveHour >=
|
|
1360
|
-
|| (Number.isFinite(weekly) && weekly >=
|
|
1365
|
+
|| (Number.isFinite(fiveHour) && fiveHour >= EXHAUSTION_FLOOR)
|
|
1366
|
+
|| (Number.isFinite(weekly) && weekly >= EXHAUSTION_FLOOR)
|
|
1361
1367
|
|| (headers['anthropic-ratelimit-tokens-remaining'] != null && tokensRemaining <= 0)
|
|
1362
1368
|
|| (headers['anthropic-ratelimit-requests-remaining'] != null && requestsRemaining <= 0);
|
|
1363
1369
|
if (quotaHeaderExhaustion) return { scope: 'account', fingerprint: null, modelScope };
|
package/src/tui.js
CHANGED
|
@@ -27,9 +27,9 @@ const vw = s => strip(s).length;
|
|
|
27
27
|
// ── Accounts-table columns ───────────────────────────────────
|
|
28
28
|
// Fixed column widths shared by the header row (acctHeader) AND every data row, so
|
|
29
29
|
// the header labels stay aligned with the columns they name. The Account/Provider/
|
|
30
|
-
// Status/Quota start offsets (4/
|
|
30
|
+
// Status/Quota start offsets (4/25/35/49) are pure functions of these widths + the
|
|
31
31
|
// 4-col row prefix, independent of the quota-bar width.
|
|
32
|
-
const NAME_W =
|
|
32
|
+
const NAME_W = 20; // a.name.slice(0, NAME_W).padEnd(NAME_W) — fits a full email like 2solarmax@gmail.com (19)
|
|
33
33
|
const PROVIDER_W = 9; // providerLabel(a).padEnd(PROVIDER_W) — fits "Anthropic"
|
|
34
34
|
const STATUS_W = 13; // rpad(status, STATUS_W) — fits "throttled 59s"
|
|
35
35
|
const ROW_PREFIX = ' '; // ' ' + sel(1) + cur(1) + ' ' — 4 cols before the name
|
|
@@ -250,6 +250,9 @@ export class TUI {
|
|
|
250
250
|
this.syncAccounts = syncAccounts;
|
|
251
251
|
this.onQuit = onQuit;
|
|
252
252
|
this.onRestart = onRestart;
|
|
253
|
+
// Set by index.js after construction (a deferred closure, not a constructor literal —
|
|
254
|
+
// avoids the const TDZ on applyUpdateIfReady, which is defined after `new TUI`).
|
|
255
|
+
this.checkNow = null;
|
|
253
256
|
|
|
254
257
|
this.log = []; // completed activity entries
|
|
255
258
|
this.active = new Map(); // in-flight requests
|
|
@@ -400,6 +403,7 @@ export class TUI {
|
|
|
400
403
|
case 'normal': this._keyNormal(k); break;
|
|
401
404
|
case 'accounts': this._keyAccounts(k); break;
|
|
402
405
|
case 'routing': this._keyRouting(k); break;
|
|
406
|
+
case 'updates': this._keyUpdates(k); break;
|
|
403
407
|
case 'select': this._keySelect(k); break;
|
|
404
408
|
case 'input': this._keyInput(k); break;
|
|
405
409
|
case 'confirm': this._keyConfirm(k); break;
|
|
@@ -435,11 +439,52 @@ export class TUI {
|
|
|
435
439
|
'Reload account credentials and newly added accounts from the config file.',
|
|
436
440
|
() => this._doSync(),
|
|
437
441
|
);
|
|
442
|
+
} else if (k === 'u') {
|
|
443
|
+
this.mode = 'updates';
|
|
438
444
|
}
|
|
439
445
|
// Enable/disable lives ONLY under [a] Accounts now (with rename/delete/login) —
|
|
440
446
|
// one home for every account mutation, instead of a duplicate top-level toggle.
|
|
441
447
|
}
|
|
442
448
|
|
|
449
|
+
// Automatic updates are "on" only when the whole chain is enabled: check npm →
|
|
450
|
+
// install to disk → seamlessly reload. Any one off means a release won't land hands-free.
|
|
451
|
+
_autoUpdateOn() {
|
|
452
|
+
const c = this.config || {};
|
|
453
|
+
return c.updateCheck !== false && c.autoUpdate === true && c.autoApply === true;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
_keyUpdates(k) {
|
|
457
|
+
if (k === 'c') {
|
|
458
|
+
// Check & apply now — the dance-killer: pull the latest + seamless-reload in place,
|
|
459
|
+
// no quit/relaunch. index.js wires this.checkNow (applies regardless of autoApply).
|
|
460
|
+
this.mode = 'normal';
|
|
461
|
+
if (this.checkNow) this.checkNow();
|
|
462
|
+
else this._addLog('Update check unavailable on this worker');
|
|
463
|
+
} else if (k === 't') {
|
|
464
|
+
this._toggleAutoUpdate();
|
|
465
|
+
} else if (k === 'esc' || k === 'q') {
|
|
466
|
+
this.mode = 'normal';
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async _toggleAutoUpdate() {
|
|
471
|
+
const turnOn = !this._autoUpdateOn();
|
|
472
|
+
// ON = the full hands-free chain; OFF = keep checking (banner still shows) but never
|
|
473
|
+
// install/reload without the user. Mutating this.config (=== the object index.js's
|
|
474
|
+
// update timer reads) takes effect live; saveConfig persists it (index.js writes these
|
|
475
|
+
// three keys) so it survives a restart.
|
|
476
|
+
this.config.updateCheck = true;
|
|
477
|
+
this.config.autoUpdate = turnOn;
|
|
478
|
+
this.config.autoApply = turnOn;
|
|
479
|
+
try {
|
|
480
|
+
await this.saveConfig(this.config);
|
|
481
|
+
this._addLog(`Automatic updates ${turnOn ? 'on' : 'off'}`);
|
|
482
|
+
} catch (error) {
|
|
483
|
+
this._addLog(`Could not save update setting: ${error.message}`);
|
|
484
|
+
}
|
|
485
|
+
this.mode = 'normal';
|
|
486
|
+
}
|
|
487
|
+
|
|
443
488
|
_keyAccounts(k) {
|
|
444
489
|
if (k === 'k') {
|
|
445
490
|
this.mode = 'input';
|
|
@@ -984,7 +1029,12 @@ export class TUI {
|
|
|
984
1029
|
// ── Header
|
|
985
1030
|
const v = this.am.versionInfo;
|
|
986
1031
|
const verStr = v?.current ? ` ${dim('v' + v.current)}` : '';
|
|
987
|
-
|
|
1032
|
+
// Auto-update state next to the version, so it's obvious whether a published release
|
|
1033
|
+
// lands hands-free (green "auto-update on") or needs a manual 'u' (dim "off").
|
|
1034
|
+
const auto = this._autoUpdateOn()
|
|
1035
|
+
? green('· auto-update on')
|
|
1036
|
+
: dim('· auto-update off');
|
|
1037
|
+
const left = bold(' Maxpool') + verStr + ' ' + auto;
|
|
988
1038
|
const port = this.config.proxy?.port || 3456;
|
|
989
1039
|
const right = `Port ${port} ${green('▲')} `;
|
|
990
1040
|
lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
|
|
@@ -1009,8 +1059,10 @@ export class TUI {
|
|
|
1009
1059
|
// blank line). The persistent banner IS the reminder; a long-lived session's
|
|
1010
1060
|
// periodic re-check keeps it current (index.js updateTimer refreshes versionInfo).
|
|
1011
1061
|
if (v?.hasUpdate && v?.latest) {
|
|
1012
|
-
|
|
1013
|
-
|
|
1062
|
+
// No more "run npm i -g, then press r" — that manual dance is what the Updates menu
|
|
1063
|
+
// kills. Auto-update ON: it applies itself; either way 'u' pulls + reloads in place.
|
|
1064
|
+
const how = this._autoUpdateOn() ? 'applying automatically · or press u now' : 'press u to update now';
|
|
1065
|
+
lines.push(' ' + yellow(`↑ Update available: v${v.current} → v${v.latest}`) + dim(` · ${how}`));
|
|
1014
1066
|
}
|
|
1015
1067
|
const routing = this.am.routingMode === 'preferred'
|
|
1016
1068
|
? `Manual preference: ${this.am.preferredAccountName} (automatic failover)`
|
|
@@ -1062,11 +1114,11 @@ export class TUI {
|
|
|
1062
1114
|
// misaligned second header).
|
|
1063
1115
|
lines.push(dimUnderline(acctHeader(W)));
|
|
1064
1116
|
const showBoth = W >= 70;
|
|
1065
|
-
//
|
|
1066
|
-
// gaps); grew by +4 with the wider
|
|
1117
|
+
// 65/54 = the fixed pre-bar column span (prefix + Account + Provider + Status +
|
|
1118
|
+
// gaps); grew by +4 with the wider 20-col Account column (was 61/50 at NAME_W 16).
|
|
1067
1119
|
const bw = showBoth
|
|
1068
|
-
? Math.max(5, Math.min(20, Math.floor((W -
|
|
1069
|
-
: Math.max(5, Math.min(20, W -
|
|
1120
|
+
? Math.max(5, Math.min(20, Math.floor((W - 65) / 2)))
|
|
1121
|
+
: Math.max(5, Math.min(20, W - 54));
|
|
1070
1122
|
|
|
1071
1123
|
// Claude/OAuth accounts first, providers (GLM/Kimi fallback) last — a stable
|
|
1072
1124
|
// display order over the canonical am.accounts array (which stays untouched so
|
|
@@ -1242,7 +1294,7 @@ export class TUI {
|
|
|
1242
1294
|
// still usable, "Fable maxed" (red) ONLY at genuine exhaustion. This is the
|
|
1243
1295
|
// SAME predicate the router benches on (_scopedExhausted), so "maxed" renders
|
|
1244
1296
|
// iff the model is actually benched — 90%/critical is no longer mislabelled.
|
|
1245
|
-
const exhaustedFloor = this.am.scheduler?.weeklyExhaustedThreshold ?? 0.
|
|
1297
|
+
const exhaustedFloor = this.am.scheduler?.weeklyExhaustedThreshold ?? 0.999;
|
|
1246
1298
|
const reserveFloor = this.am.scheduler?.weeklyReserveThreshold ?? 0.85;
|
|
1247
1299
|
const scopedTags = [];
|
|
1248
1300
|
for (const [fam, e] of Object.entries(q.scopedWeekly || {})) {
|
|
@@ -1355,7 +1407,11 @@ export class TUI {
|
|
|
1355
1407
|
_renderFooter() {
|
|
1356
1408
|
switch (this.mode) {
|
|
1357
1409
|
case 'normal':
|
|
1358
|
-
return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('s')} Sync ${bold('r')} Restart ${bold('q')} Stop`;
|
|
1410
|
+
return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('s')} Sync ${bold('u')} Updates ${bold('r')} Restart ${bold('q')} Stop`;
|
|
1411
|
+
case 'updates': {
|
|
1412
|
+
const state = this._autoUpdateOn() ? green('on') : dim('off');
|
|
1413
|
+
return ` ${bold('c')} Check & apply now ${bold('t')} Automatic updates: ${state} ↻ ${bold('Esc')} Back`;
|
|
1414
|
+
}
|
|
1359
1415
|
case 'accounts':
|
|
1360
1416
|
return ` ${bold('l')} Login/re-auth (browser) ${bold('k')} API key ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
|
|
1361
1417
|
case 'routing': {
|