maxpool 1.19.7 → 1.20.0
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 +26 -1
- package/src/server.js +153 -4
- package/src/tui.js +23 -1
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -147,6 +147,13 @@ const DEFAULT_SCHEDULER = {
|
|
|
147
147
|
capPenaltyWeight: 10, // steep penalty per unit of in-flight depth past D (throttle safety floor)
|
|
148
148
|
paceCostWeight: 1.5, // soft de-preference of accounts burning ahead of pace (was the ×6 term)
|
|
149
149
|
utilizationWeight: 3, // RAW utilization cost — drives load balancing in the mid-range
|
|
150
|
+
// WEEKLY-AWARE SCORING (2026-09-16): when true, _rawUtilization also folds in the
|
|
151
|
+
// WEEKLY utilization (unified7d / providerWk), not just the 5h session. Before this,
|
|
152
|
+
// a Claude account at 89% weekly with a freshly-reset 5h window scored as CHEAP —
|
|
153
|
+
// weekly-burning accounts kept winning the lease all day (backtest: 62%→35% mean
|
|
154
|
+
// weekly burn once weekly-aware). Boolean; default ON was chosen because the
|
|
155
|
+
// pre-flag behavior is the bug this fixes.
|
|
156
|
+
weeklyAwareScoring: true,
|
|
150
157
|
scarcityWeight: 6, // legacy; superseded by paceCostWeight (kept so old configs don't error)
|
|
151
158
|
// Reserve-account OVERFLOW model. A weekly-RESERVE account (util 0.85-0.95) used to
|
|
152
159
|
// sit idle behind a healthy-only first pass; now it's eligible in the first pass but
|
|
@@ -2692,7 +2699,7 @@ export class AccountManager {
|
|
|
2692
2699
|
* _accountScarcity but WITHOUT the elapsed-fraction discount. This is the signal
|
|
2693
2700
|
* the load balancer needs: an account at 80% is more expensive than one at 10%,
|
|
2694
2701
|
* full stop. */
|
|
2695
|
-
_rawUtilization(account) {
|
|
2702
|
+
_rawUtilization(account, now = Date.now()) {
|
|
2696
2703
|
const q = account?.quota;
|
|
2697
2704
|
if (!q) return 0;
|
|
2698
2705
|
// SESSION windows use raw utilization — headroom is consumed immediately and an
|
|
@@ -2708,6 +2715,24 @@ export class AccountManager {
|
|
|
2708
2715
|
if (q.tokensLimit != null && q.tokensLimit > 0 && q.tokensRemaining != null) {
|
|
2709
2716
|
util = Math.max(util, 1 - q.tokensRemaining / q.tokensLimit);
|
|
2710
2717
|
}
|
|
2718
|
+
// WEEKLY-AWARE (2026-09-16): fold the WEEKLY number in too, behind its own
|
|
2719
|
+
// flag — via _windowScarcity (reset-aware), NEVER raw. v1 of this block used
|
|
2720
|
+
// raw max(), which fixed the 89%-weekly-wins-all-day bug but broke the
|
|
2721
|
+
// near-reset contracts that predate it: the use-it-or-lose-it pin, the
|
|
2722
|
+
// preReset drain (X2/X5), and S10's flag-off parity. Pace-adjusted, a
|
|
2723
|
+
// 60%-weekly account mid-window adds (0.60 − elapsedFrac) here at weight 3
|
|
2724
|
+
// ON TOP of the paceCost's weight-1.5 copy — doubling the weekly steering
|
|
2725
|
+
// signal (the actual fix) while capacity dying at reset stays free (the
|
|
2726
|
+
// invariant the older tests pin). Unknown/absent reset → face value, same as
|
|
2727
|
+
// _accountScarcity. Turn the flag off to restore session-only exactly.
|
|
2728
|
+
if (this.scheduler.weeklyAwareScoring !== false) {
|
|
2729
|
+
if (q.unified7d != null) {
|
|
2730
|
+
util = Math.max(util, this._windowScarcity(q.unified7d, q.unified7dReset, WEEK_MS, now));
|
|
2731
|
+
}
|
|
2732
|
+
if (q.providerWk != null) {
|
|
2733
|
+
util = Math.max(util, this._windowScarcity(q.providerWk, q.providerWkReset, WEEK_MS, now));
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2711
2736
|
return util;
|
|
2712
2737
|
}
|
|
2713
2738
|
|
package/src/server.js
CHANGED
|
@@ -48,6 +48,12 @@ const QUEUE_KEEPALIVE = 'event: ping\ndata: {}\n\n';
|
|
|
48
48
|
const STREAM_IDLE_MS = Math.max(30_000, Number(process.env.MAXPOOL_STREAM_IDLE_MS) || 240_000); // max gap BETWEEN streamed chunks (reset per chunk)
|
|
49
49
|
const UPSTREAM_BODY_MS = Math.max(30_000, Number(process.env.MAXPOOL_BODY_MS) || 300_000); // non-streaming body read
|
|
50
50
|
const CLIENT_DRAIN_MS = Math.max(5_000, Number(process.env.MAXPOOL_DRAIN_MS) || 60_000); // max wait for a backpressured client to drain (half-open client → free the lease)
|
|
51
|
+
// SAFEGUARD-REFUSAL REROUTE caps: how long streamResponse may hold the FIRST events
|
|
52
|
+
// unsent while deciding refusal-vs-content. Both fail OPEN (release + stream verbatim)
|
|
53
|
+
// so a pathological upstream can only cost a missed reroute, never a stall. Real
|
|
54
|
+
// refusals resolve in the first 2-3 events (<4KB, <5s — measured on 9 captures).
|
|
55
|
+
const REFUSAL_HOLD_MAX_BYTES = 64 * 1024;
|
|
56
|
+
const REFUSAL_HOLD_MAX_MS = 30_000;
|
|
51
57
|
// A provider 403 is (unlike a 401) almost always transient QUOTA/PLAN exhaustion — cool
|
|
52
58
|
// the provider down RECOVERABLY for this window, then re-probe, instead of permanently
|
|
53
59
|
// disabling it. Short (not reset-length) so a provider-pinned session's hold window
|
|
@@ -516,6 +522,14 @@ async function forwardRequest(
|
|
|
516
522
|
ctx.account = account.name;
|
|
517
523
|
hooks.onRequestRouted?.(reqId, { account: account.name });
|
|
518
524
|
|
|
525
|
+
// SAFEGUARD-REFUSAL REROUTE (2026-09-14): arm the stream-side hold only for Anthropic
|
|
526
|
+
// accounts on a streaming turn we could still fail over (nothing written yet). A
|
|
527
|
+
// provider has its own classifier and never emits this stop_reason, so holding its
|
|
528
|
+
// first events would be pure latency. See streamResponse + classifyHeldStreamPrefix.
|
|
529
|
+
requestInfo._refusalRerouteEligible = account.type !== 'provider'
|
|
530
|
+
&& canRetryBufferedBody
|
|
531
|
+
&& !res.headersSent;
|
|
532
|
+
|
|
519
533
|
// Refresh OAuth token if needed
|
|
520
534
|
const tokenReady = await accountManager.ensureTokenFresh(account.index);
|
|
521
535
|
if (clientGone.signal.aborted) { releaseOnClientGone(); return; }
|
|
@@ -1370,8 +1384,7 @@ async function forwardRequest(
|
|
|
1370
1384
|
logSections.push(`=== RESPONSE BODY (streamed) ===\n${streamLog.join('')}`);
|
|
1371
1385
|
writeRequestLog(logDir, reqId, logSections);
|
|
1372
1386
|
}
|
|
1373
|
-
} else {
|
|
1374
|
-
// Bound the non-streaming body read so a mid-body upstream stall can't hang
|
|
1387
|
+
} else { // Bound the non-streaming body read so a mid-body upstream stall can't hang
|
|
1375
1388
|
// the request forever and leak the lease (same class as the streaming idle
|
|
1376
1389
|
// guard). On timeout → UPSTREAM_BODY → the caller frees the lease.
|
|
1377
1390
|
const bodyP = upstreamRes.arrayBuffer();
|
|
@@ -1459,6 +1472,35 @@ async function forwardRequest(
|
|
|
1459
1472
|
console.error(`[Maxpool] Upstream error (account "${account.name}"):`, err.message
|
|
1460
1473
|
+ (rootCause && !String(err.message).includes(rootCause) ? ` (cause: ${rootCause})` : ''));
|
|
1461
1474
|
|
|
1475
|
+
// SAFEGUARD-REFUSAL REROUTE (2026-09-14): streamResponse detected a
|
|
1476
|
+
// stop_reason:"refusal" while still holding the first events — nothing was ever
|
|
1477
|
+
// written to the client (usage is 0/0 on these; no model output exists). Every
|
|
1478
|
+
// Anthropic account shares the classifier, so retrying another Anthropic account
|
|
1479
|
+
// is guaranteed re-refusal: exclude ALL Anthropic accounts and re-dispatch onto a
|
|
1480
|
+
// provider. If no provider is available,
|
|
1481
|
+
// fall through to the normal error path — the client sees the same message it
|
|
1482
|
+
// would have seen before this feature existed.
|
|
1483
|
+
if (err.code === 'REFUSAL_RETRY' && canRetryBufferedBody && !res.headersSent) {
|
|
1484
|
+
const category = err.refusalCategory || 'unknown';
|
|
1485
|
+
const anthropicIndexes = accountManager.accounts
|
|
1486
|
+
.filter(a => a.type !== 'provider')
|
|
1487
|
+
.map(a => a.index);
|
|
1488
|
+
anthropicIndexes.forEach(i => excludedIndexes.add(i));
|
|
1489
|
+
const providerAvailable = accountManager.accounts.some(a => a.type === 'provider' && !excludedIndexes.has(a.index));
|
|
1490
|
+
// No re-reroute latch: once rerouted, the turn is served by a PROVIDER, which never
|
|
1491
|
+
// arms the hold (above), so it cannot refuse-and-reroute again. Mutation-tested
|
|
1492
|
+
// 2026-09-14: removing a latch changed nothing on any constructible path.
|
|
1493
|
+
if (providerAvailable && retryCount + 1 <= maxAttempts) {
|
|
1494
|
+
console.log(`[Maxpool] Anthropic safeguard refusal (${category}) on "${account.name}" — rerouting turn to a provider account`);
|
|
1495
|
+
accountManager.releaseAccount(lease, { success: true, status: 200, refusal: category });
|
|
1496
|
+
return forwardRequest(
|
|
1497
|
+
req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir,
|
|
1498
|
+
retryConfig, queueConfig, requestInfo, canRetryBufferedBody, canQueueBufferedBody, excludedIndexes,
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
console.log(`[Maxpool] Anthropic safeguard refusal (${category}) — no provider route available; surfacing error`);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1462
1504
|
if (logDir) {
|
|
1463
1505
|
logSections.push(`=== ERROR ===\n${err.stack || err.message}`);
|
|
1464
1506
|
writeRequestLog(logDir, reqId, logSections);
|
|
@@ -1757,7 +1799,7 @@ function isContextLengthError(errorBody) {
|
|
|
1757
1799
|
return /exceeded model token limit|maximum context length|context length exceeded|context window (?:size )?(?:exceeded|too)|prompt is too long|input is too long|reduce the length of|too many (?:input )?tokens|request too large/i.test(errorBody);
|
|
1758
1800
|
}
|
|
1759
1801
|
|
|
1760
|
-
export const __serverTest = { rewriteBodyForAccount, sanitizeBlocksForProvider, reanchorOrphanedSystemMessages, unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, classifyEffortRejection, repairEffort, isCapacitySignalStatus, isStrippableThinkingBlock, stripForeignThinkingBlocks, parseRejectedBlockPath, stripRejectedBlockClass, peekRejectedBlockType, describeRejectedBlock, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, commitStreamGraceHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, isContextLengthError, isProviderParamRejection, describeBodyShape, streamResponse, startIdleRequestReaper, normalizeModelEcho };
|
|
1802
|
+
export const __serverTest = { rewriteBodyForAccount, sanitizeBlocksForProvider, reanchorOrphanedSystemMessages, unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, classifyEffortRejection, repairEffort, isCapacitySignalStatus, isStrippableThinkingBlock, stripForeignThinkingBlocks, parseRejectedBlockPath, stripRejectedBlockClass, peekRejectedBlockType, describeRejectedBlock, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, commitStreamGraceHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, isContextLengthError, isProviderParamRejection, describeBodyShape, streamResponse, startIdleRequestReaper, normalizeModelEcho, classifyHeldStreamPrefix };
|
|
1761
1803
|
|
|
1762
1804
|
async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
|
|
1763
1805
|
if (!upstreamRes.body) return '';
|
|
@@ -3119,6 +3161,25 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
|
|
|
3119
3161
|
let modelEchoPending = true;
|
|
3120
3162
|
let modelEchoBuffer = null;
|
|
3121
3163
|
|
|
3164
|
+
// SAFEGUARD-REFUSAL REROUTE (2026-09-14). Anthropic's server-side classifier can
|
|
3165
|
+
// terminate a turn with HTTP 200 + `stop_reason:"refusal"` + stop_details.category
|
|
3166
|
+
// (reasoning_extraction / cyber / bio / frontier_llm) and usage 0/0 — no model output
|
|
3167
|
+
// is produced, and the CLI renders "API Error: … safeguards flagged this message",
|
|
3168
|
+
// killing the turn. Every Anthropic account shares that classifier, so account
|
|
3169
|
+
// failover cannot help; only a PROVIDER hop escapes it.
|
|
3170
|
+
//
|
|
3171
|
+
// Because usage is 0/0 the client has seen no model text, so the turn is safely
|
|
3172
|
+
// retryable — but only while nothing has been WRITTEN yet. This rides the model-echo
|
|
3173
|
+
// hold above: we keep buffering until a DECISIVE event (real content → flush and
|
|
3174
|
+
// stream normally; refusal → throw REFUSAL_RETRY so the caller re-dispatches on a
|
|
3175
|
+
// provider). `ping` is not decisive. Fail-open on either cap, exactly as the
|
|
3176
|
+
// model-echo hold does — a missed reroute is a bad turn, a stall is a dead session.
|
|
3177
|
+
// Task: work/tooling/**/task-2026-09-14-maxpool-reroute-anthropic-refusals-to-provider
|
|
3178
|
+
let refusalRerouting = false;
|
|
3179
|
+
const refusalWatch = requestInfo?._refusalRerouteEligible === true;
|
|
3180
|
+
let refusalHoldBuffer = refusalWatch ? [] : null;
|
|
3181
|
+
const refusalHoldStart = Date.now();
|
|
3182
|
+
|
|
3122
3183
|
try {
|
|
3123
3184
|
while (true) {
|
|
3124
3185
|
// Idle guard: a half-open upstream (headers, then silence, never closes) would
|
|
@@ -3143,12 +3204,37 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
|
|
|
3143
3204
|
} finally {
|
|
3144
3205
|
clearTimeout(idleTimer); // single live timer at a time — no per-chunk timer accumulation
|
|
3145
3206
|
}
|
|
3146
|
-
const { done, value } = result;
|
|
3207
|
+
const { done, value: chunkValue } = result;
|
|
3147
3208
|
if (done) break;
|
|
3209
|
+
let value = chunkValue;
|
|
3148
3210
|
|
|
3149
3211
|
// Client disconnected — stop reading from upstream
|
|
3150
3212
|
if (res.destroyed) break;
|
|
3151
3213
|
|
|
3214
|
+
// SAFEGUARD-REFUSAL HOLD (see the state block above). Runs BEFORE writeHead so a
|
|
3215
|
+
// rerouted turn leaves the client socket completely untouched — the caller can
|
|
3216
|
+
// then re-dispatch onto a provider as if the Anthropic attempt never happened.
|
|
3217
|
+
if (refusalHoldBuffer) {
|
|
3218
|
+
refusalHoldBuffer.push(value);
|
|
3219
|
+
const held = decoder.decode(concatUint8(refusalHoldBuffer));
|
|
3220
|
+
const verdict = classifyHeldStreamPrefix(held);
|
|
3221
|
+
if (verdict.decision === 'refusal') {
|
|
3222
|
+
throw Object.assign(
|
|
3223
|
+
new Error(`anthropic safeguard refusal (${verdict.category || 'unknown'})`),
|
|
3224
|
+
{ code: 'REFUSAL_RETRY', refusalCategory: verdict.category || 'unknown' },
|
|
3225
|
+
);
|
|
3226
|
+
}
|
|
3227
|
+
if (verdict.decision === 'hold'
|
|
3228
|
+
&& totalLen(refusalHoldBuffer) <= REFUSAL_HOLD_MAX_BYTES
|
|
3229
|
+
&& Date.now() - refusalHoldStart <= REFUSAL_HOLD_MAX_MS) {
|
|
3230
|
+
continue; // nothing decisive yet — keep holding, write nothing
|
|
3231
|
+
}
|
|
3232
|
+
// Decisive content, or either cap hit → fail open: release the held bytes as
|
|
3233
|
+
// this chunk and never hold again on this stream.
|
|
3234
|
+
value = concatUint8(refusalHoldBuffer);
|
|
3235
|
+
refusalHoldBuffer = null;
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3152
3238
|
if (!committed) {
|
|
3153
3239
|
res.writeHead(status, responseHeaders);
|
|
3154
3240
|
committed = true;
|
|
@@ -3245,8 +3331,40 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
|
|
|
3245
3331
|
}
|
|
3246
3332
|
} catch (err) {
|
|
3247
3333
|
readFailed = true;
|
|
3334
|
+
if (err?.code === 'REFUSAL_RETRY') refusalRerouting = true;
|
|
3248
3335
|
throw err;
|
|
3249
3336
|
} finally {
|
|
3337
|
+
// SAFEGUARD-REFUSAL HOLD last-resort flush. A stream can end while the hold is
|
|
3338
|
+
// still undecided — a `usage`-only stream (message_delta with no content blocks
|
|
3339
|
+
// and no message_stop) reaches `done` having never produced a decisive event, and
|
|
3340
|
+
// an upstream that DIES mid-flight throws with bytes still held. Both must still
|
|
3341
|
+
// deliver and account for what arrived; only the refusal reroute discards, because
|
|
3342
|
+
// that turn is being re-run on another account and its usage will be counted there.
|
|
3343
|
+
// Caught by server.test.js "streaming response is not committed until first upstream
|
|
3344
|
+
// chunk" and capacity-integration H4 on the first full-suite run — the held bytes
|
|
3345
|
+
// were being dropped, truncating good responses and losing delivered tokens.
|
|
3346
|
+
if (refusalHoldBuffer) {
|
|
3347
|
+
const heldChunks = refusalHoldBuffer;
|
|
3348
|
+
refusalHoldBuffer = null;
|
|
3349
|
+
if (!refusalRerouting) {
|
|
3350
|
+
const held = Buffer.from(concatUint8(heldChunks));
|
|
3351
|
+
const heldText = held.toString('utf8');
|
|
3352
|
+
// Account for delivered tokens even when the write is impossible (dead socket):
|
|
3353
|
+
// capacity is about what the UPSTREAM produced, not what the client received.
|
|
3354
|
+
if (streamLog) streamLog.push(heldText);
|
|
3355
|
+
sseBuffer += heldText;
|
|
3356
|
+
for (const ev of sseBuffer.split('\n\n')) {
|
|
3357
|
+
if (ev.trim()) parseSSEEvent(ev, accountIndex, accountManager, requestInfo);
|
|
3358
|
+
}
|
|
3359
|
+
sseBuffer = '';
|
|
3360
|
+
if (!readFailed && !res.destroyed) {
|
|
3361
|
+
try {
|
|
3362
|
+
if (!committed && !res.headersSent) { res.writeHead(status, responseHeaders); committed = true; }
|
|
3363
|
+
res.write(held);
|
|
3364
|
+
} catch { /* client already gone */ }
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3250
3368
|
// MODEL ECHO NORMALIZATION last-resort flush: the read loop can exit via done,
|
|
3251
3369
|
// error, or client disconnect while chunks are still HELD for the rewrite.
|
|
3252
3370
|
// Whatever the exit path, deliver the held bytes and accrue their usage — a
|
|
@@ -3323,6 +3441,37 @@ function sseEventContainsThinking(data) {
|
|
|
3323
3441
|
|| data?.delta?.type === 'signature_delta';
|
|
3324
3442
|
}
|
|
3325
3443
|
|
|
3444
|
+
// SAFEGUARD-REFUSAL REROUTE — classify the held stream prefix (see streamResponse).
|
|
3445
|
+
// Decisive events, on the first COMPLETE SSE event boundary:
|
|
3446
|
+
// - `stop_reason:"refusal"` (+ optional stop_details.category) → { decision: 'refusal', category }
|
|
3447
|
+
// - any content_block_start/content_block_delta → { decision: 'content' }
|
|
3448
|
+
// - message_stop without a prior refusal → { decision: 'content' } (normal end)
|
|
3449
|
+
// Non-decisive: message_start, ping, partial/binary garbage → { decision: 'hold' }.
|
|
3450
|
+
// Works on the RAW prefix (not parsed events) so it can run per-chunk; a refusal
|
|
3451
|
+
// `message_delta` always lands after `message_start` and before any content, and no
|
|
3452
|
+
// provider-echo variant matters here because the hold is only armed on Anthropic
|
|
3453
|
+
// accounts (requestInfo._refusalRerouteEligible).
|
|
3454
|
+
function classifyHeldStreamPrefix(prefix) {
|
|
3455
|
+
for (const event of prefix.split('\n\n')) {
|
|
3456
|
+
const dataLine = event.split('\n').find(l => l.startsWith('data: '));
|
|
3457
|
+
if (!dataLine) continue; // ping/comment — not decisive
|
|
3458
|
+
let data;
|
|
3459
|
+
try { data = JSON.parse(dataLine.slice(6)); } catch { continue; }
|
|
3460
|
+
if (data?.delta?.stop_reason === 'refusal'
|
|
3461
|
+
|| (data?.type === 'message' && data?.stop_reason === 'refusal')) {
|
|
3462
|
+
return { decision: 'refusal', category: data?.stop_details?.category || data?.delta?.stop_details?.category };
|
|
3463
|
+
}
|
|
3464
|
+
if (data?.type === 'content_block_start' || data?.type === 'content_block_delta') {
|
|
3465
|
+
return { decision: 'content' };
|
|
3466
|
+
}
|
|
3467
|
+
if (data?.type === 'message_stop') {
|
|
3468
|
+
// Terminal without refusal and without content blocks (usage-only edge) — release.
|
|
3469
|
+
return { decision: 'content' };
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
return { decision: 'hold' };
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3326
3475
|
function extractUsageFromBody(buffer, accountIndex, accountManager, requestInfo = {}) {
|
|
3327
3476
|
try {
|
|
3328
3477
|
const json = JSON.parse(buffer.toString());
|
package/src/tui.js
CHANGED
|
@@ -977,6 +977,11 @@ export class TUI {
|
|
|
977
977
|
// mode — under balance/prefer-* the mode itself controls eligibility. Still
|
|
978
978
|
// safe to set (it'll apply if you switch back to sticky).
|
|
979
979
|
this._cycleProviderClaudeFallback(k === 'g' ? 'zai' : 'kimi');
|
|
980
|
+
} else if (k === 'w' || k === 'W') {
|
|
981
|
+
// WEEKLY-AWARE SCORING (2026-09-16): fold weekly utilization into the routing
|
|
982
|
+
// score, not just the 5h session. Without it, an account at 89% weekly with a
|
|
983
|
+
// fresh session window scores as cheap and keeps winning all day.
|
|
984
|
+
this._toggleWeeklyAware();
|
|
980
985
|
} else if (k === 'esc' || k === 'q') {
|
|
981
986
|
this.mode = 'normal';
|
|
982
987
|
}
|
|
@@ -1066,6 +1071,22 @@ export class TUI {
|
|
|
1066
1071
|
: `Peak cap: bench a GLM account once it passes ${Math.round(next * 100)}% of its weekly quota`);
|
|
1067
1072
|
}
|
|
1068
1073
|
|
|
1074
|
+
/** Toggle weekly-aware routing for the whole fleet. One scheduler flag — when OFF the
|
|
1075
|
+
* score sees only the 5h session (the pre-2026-09-16 behavior); when ON the weekly
|
|
1076
|
+
* number is folded in, so a nearly-exhausted account loses to a fresh one at ALL
|
|
1077
|
+
* hours, not just after its session window resets. */
|
|
1078
|
+
async _toggleWeeklyAware() {
|
|
1079
|
+
const next = this.am.scheduler.weeklyAwareScoring === false;
|
|
1080
|
+
this.am.scheduler.weeklyAwareScoring = next;
|
|
1081
|
+
const sched = { ...(this.config.scheduler || {}) };
|
|
1082
|
+
sched.weeklyAwareScoring = next;
|
|
1083
|
+
this.config.scheduler = sched;
|
|
1084
|
+
await this.saveConfig(this.config);
|
|
1085
|
+
this._addLog(next
|
|
1086
|
+
? 'Weekly-aware routing: ON — accounts near their weekly limit rank last, all day'
|
|
1087
|
+
: 'Weekly-aware routing: OFF — score sees only the 5h session window again');
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1069
1090
|
async _cycleRoutingMode() {
|
|
1070
1091
|
const modes = TUI.ROUTING_MODES;
|
|
1071
1092
|
const cur = this.am.scheduler?.routingMode || 'sticky';
|
|
@@ -2369,7 +2390,8 @@ export class TUI {
|
|
|
2369
2390
|
const now = st?.inPeak ? red(' NOW') : '';
|
|
2370
2391
|
const dep = ps.depreference ? yellow('GLM last') : cyan('normal');
|
|
2371
2392
|
const cap = ps.cap >= 1 ? 'off' : ps.cap === 0 ? 'never' : `${Math.round(ps.cap * 100)}%`;
|
|
2372
|
-
|
|
2393
|
+
const wk = this.am.scheduler.weeklyAwareScoring === false ? yellow('5h-only') : cyan('weekly');
|
|
2394
|
+
peakPart = ` ${dim('│')} ${bold(' d ')}Peak${now}: ${dep} ${bold(' c ')}cap ${cyan(cap)} ${bold(' w ')}score:${wk}`;
|
|
2373
2395
|
}
|
|
2374
2396
|
return ` ${bold('f')} Routing: ${cyan(mode.label)} ↻${provPart}${peakPart} ${bold('p')} Preference ${bold('Esc')} Back`;
|
|
2375
2397
|
}
|