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 +1 -1
- package/src/account-manager.js +36 -9
- package/src/restart-controller.js +29 -2
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -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
|
-
|
|
952
|
-
|
|
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":
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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({
|
|
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
|
}
|