maxpool 1.5.43 → 1.5.45

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.5.43",
3
+ "version": "1.5.45",
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",
@@ -159,6 +159,10 @@ const DEFAULT_SCHEDULER = {
159
159
  providerCrossFallback: true,
160
160
  };
161
161
  const LOAD_EVENT_MAX_AGE_MS = 60 * 60 * 1000;
162
+ // Consecutive failed recovery probes before we stop trusting the SHARED breaker and
163
+ // fall back to per-account handling. Bounds any future "poisoned probe" from becoming
164
+ // an indefinite fleet-wide outage (2026-07-25 incident).
165
+ const MAX_FAILED_PROBES = 4;
162
166
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
163
167
  const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
164
168
 
@@ -734,6 +738,7 @@ export class AccountManager {
734
738
  this.upstreamThrottle.until = Math.max(this.upstreamThrottle.until || 0, until);
735
739
  this.upstreamThrottle.reason = reason;
736
740
  this.upstreamThrottle.probeInFlight = false;
741
+ this.upstreamThrottle.failedProbes = 0; // fresh breaker → fresh probe budget
737
742
  this.upstreamThrottle.count++;
738
743
  this.upstreamThrottle.lastAt = Date.now();
739
744
  console.log(`[Maxpool] Anthropic upstream temporarily limiting requests for ${retryAfter}s; pausing Claude routes`);
@@ -744,6 +749,7 @@ export class AccountManager {
744
749
  this.upstreamThrottle.until = null;
745
750
  this.upstreamThrottle.reason = null;
746
751
  this.upstreamThrottle.probeInFlight = false;
752
+ this.upstreamThrottle.failedProbes = 0;
747
753
  this.queueState.rampUntil = Date.now() + 5000;
748
754
  this.queueState.lastAdmissionAt = Date.now();
749
755
  console.log(`[Maxpool] Anthropic upstream throttle cleared (${reason})`);
@@ -755,14 +761,36 @@ export class AccountManager {
755
761
  lease.upstreamThrottleProbe = false;
756
762
  }
757
763
 
764
+ /** Hand the recovery probe back UNUSED — the request never got an upstream answer
765
+ * (client disconnected, token refresh failed), so it is no evidence either way.
766
+ * Leaves the window open so the very next request can claim the probe, instead of
767
+ * scoring a failure that would re-arm the fleet-wide throttle. */
768
+ relinquishUpstreamProbe(lease) {
769
+ if (!lease?.upstreamThrottleProbe) return;
770
+ lease.upstreamThrottleProbe = false;
771
+ this.upstreamThrottle.probeInFlight = false;
772
+ }
773
+
758
774
  deferUpstreamThrottleProbe(retryAfterSeconds = 5, reason = 'probe_failed') {
759
775
  if (!this.upstreamThrottle.until && !this.upstreamThrottle.probeInFlight) return;
760
- const retryAfter = clampRetryAfterSeconds(retryAfterSeconds);
776
+ // PROBE BUDGET. A flat 5s retry with unbounded repetition is what let a single
777
+ // poisoned request hold the whole fleet down forever. Escalate the backoff, and
778
+ // after MAX_FAILED_PROBES consecutive failures give up on the shared breaker and
779
+ // fall back to per-account handling — the fleet then retries for real, and if
780
+ // Anthropic genuinely is throttling, shouldPromoteUpstreamFailure re-arms it.
781
+ // (This is a circuit breaker's half-open state, paced by the backoff.)
782
+ this.upstreamThrottle.failedProbes = (this.upstreamThrottle.failedProbes || 0) + 1;
783
+ if (this.upstreamThrottle.failedProbes >= MAX_FAILED_PROBES) {
784
+ this.clearUpstreamThrottle(`probe budget exhausted after ${this.upstreamThrottle.failedProbes} failures (${reason}) — deferring to per-account handling`);
785
+ return;
786
+ }
787
+ const backoff = Math.min(60, retryAfterSeconds * 2 ** (this.upstreamThrottle.failedProbes - 1));
788
+ const retryAfter = clampRetryAfterSeconds(backoff);
761
789
  this.upstreamThrottle.until = Date.now() + retryAfter * 1000;
762
790
  this.upstreamThrottle.reason = reason;
763
791
  this.upstreamThrottle.probeInFlight = false;
764
792
  this.upstreamThrottle.lastAt = Date.now();
765
- console.log(`[Maxpool] Anthropic recovery probe failed; retrying in ${retryAfter}s (${reason})`);
793
+ console.log(`[Maxpool] Anthropic recovery probe failed (${this.upstreamThrottle.failedProbes}/${MAX_FAILED_PROBES}); retrying in ${retryAfter}s (${reason})`);
766
794
  }
767
795
 
768
796
  noteAmbiguousRateLimit(accountIndex, fingerprint, _retryAfterSeconds) {
package/src/index.js CHANGED
@@ -12,7 +12,7 @@ import { loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon, toke
12
12
  import { TUI } from './tui.js';
13
13
  import { RestartController } from './restart-controller.js';
14
14
  import { resolveAccounts } from './account-config.js';
15
- import { maybeCheckForUpdate, getCurrentVersion, markApplied } from './updater.js';
15
+ import { maybeCheckForUpdate, getCurrentVersion, markApplied, clearQuarantine } from './updater.js';
16
16
  import {
17
17
  runReloadBaton,
18
18
  RELOAD_SWAPPED, RELOAD_ROLLED_BACK,
@@ -23,10 +23,20 @@ import {
23
23
  const args = process.argv.slice(2);
24
24
  const command = args[0];
25
25
  const SERVER_RESTART_EXIT_CODE = 75;
26
+ // Reload readiness handshake budget. On a loaded machine (Bitdefender, many node procs,
27
+ // a local LLM) a fresh worker can need well over 10s to boot + signal ready; too tight →
28
+ // the seamless reload rolls back and the update/restart silently never lands. Generous +
29
+ // env-tunable; covers BOTH the ready and the takeover waits in runReloadBaton.
30
+ const RELOAD_READY_MS = Math.max(10_000, Number(process.env.MAXPOOL_RELOAD_READY_MS) || 30_000);
26
31
  // If a seamless reload doesn't release us (→ take over) within this window, the new
27
- // worker rolled back — self-heal admission so we don't 503 forever. > the baton
28
- // readiness timeout (10s) + margin.
29
- const RELOAD_ROLLBACK_SELFHEAL_MS = Math.max(15_000, Number(process.env.MAXPOOL_RELOAD_SELFHEAL_MS) || 30_000);
32
+ // worker rolled back — self-heal admission so we don't 503 forever. Must sit ABOVE the
33
+ // baton readiness budget + margin so it never fires WHILE the baton is still handshaking.
34
+ // Default (RELOAD_READY_MS + 20s = 50s) sits comfortably above the readiness budget, so
35
+ // the self-heal never fires mid-handshake. An explicit MAXPOOL_RELOAD_SELFHEAL_MS override
36
+ // is honored down to a 15s floor (used by the reload integration test) — a sub-ready
37
+ // override only risks a spurious "rolled back" log + brief admission flap (no corruption:
38
+ // the new worker holds no lease until takeover), never a false double-writer.
39
+ const RELOAD_ROLLBACK_SELFHEAL_MS = Math.max(15_000, Number(process.env.MAXPOOL_RELOAD_SELFHEAL_MS) || RELOAD_READY_MS + 20_000);
30
40
  // Seamless-reload drain cap. On a seamless reload the NEW worker already serves
31
41
  // ALL new traffic while the OLD worker only finishes its own in-flight requests,
32
42
  // so a long old-worker drain has zero request-facing cost — let a long streaming
@@ -326,6 +336,10 @@ async function supervisorCommand() {
326
336
  // listener to cover the cutover gap, then hand THAT live handle to the
327
337
  // new worker. The new worker's MSG_PRIMARY then closes it again.
328
338
  prepareHandle: async () => { await relistenMaster(); return masterServer; },
339
+ // Generous, env-tunable readiness/takeover budgets — the 10s default rolled back
340
+ // on this user's loaded Mac (the update never landed). See RELOAD_READY_MS.
341
+ readyTimeoutMs: RELOAD_READY_MS,
342
+ takeoverTimeoutMs: RELOAD_READY_MS,
329
343
  log: msg => console.log(`[Maxpool] ${msg}`),
330
344
  });
331
345
 
@@ -1083,6 +1097,10 @@ async function serverWorkerCommand() {
1083
1097
  if (updateInFlight) { notifyUpdate('Update check already running'); return; }
1084
1098
  if (!hasLease) { notifyUpdate('Updates run on the primary worker only'); return; }
1085
1099
  if (config?.updateCheck === false) { notifyUpdate('Update checks are disabled in config'); return; }
1100
+ // An EXPLICIT manual apply always re-attempts — clear any quarantine a prior auto-reload
1101
+ // left (e.g. a rollback from a too-tight readiness timeout), so 'u'→'c' can't dead-end on
1102
+ // "already attempted — will retry only a newer release".
1103
+ clearQuarantine();
1086
1104
  notifyUpdate('Checking for updates…');
1087
1105
  const r = await runUpdateCheck({ announce: true, apply: applyNow, forceInstall: true });
1088
1106
  if (r && !r.hasUpdate) notifyUpdate('Already on the latest version');
package/src/server.js CHANGED
@@ -402,6 +402,11 @@ async function forwardRequest(
402
402
  res.once('close', onClientClose);
403
403
  const releaseOnClientGone = () => {
404
404
  res.off('close', onClientClose);
405
+ // The client vanished — we never got an upstream answer, so this carries ZERO
406
+ // evidence about Anthropic's health. Hand the recovery probe back instead of
407
+ // letting releaseAccount score it as a FAILED probe (which would re-arm the
408
+ // fleet-wide throttle for 5s on every disconnect — a second deadlock amplifier).
409
+ accountManager.relinquishUpstreamProbe?.(lease);
405
410
  accountManager.releaseAccount(lease);
406
411
  clearQueueHeartbeat(requestInfo);
407
412
  accountManager.removeQueuedRequest?.(requestInfo);
@@ -421,6 +426,9 @@ async function forwardRequest(
421
426
  // (MaxListenersExceededWarning + leak); the recursive/resumed frame registers
422
427
  // its own.
423
428
  res.off('close', onClientClose);
429
+ // Token refresh failed — the request never reached Anthropic, so this is no
430
+ // evidence about upstream health. Relinquish rather than fail the probe.
431
+ accountManager.relinquishUpstreamProbe?.(lease);
424
432
  accountManager.releaseAccount(lease);
425
433
  excludedIndexes.add(account.index);
426
434
  if (
@@ -563,6 +571,18 @@ async function forwardRequest(
563
571
  }
564
572
  accountManager.updateQuota(account.index, rateLimitHeaders);
565
573
 
574
+ // SETTLE THE SHARED BREAKER AT THE HEADER BOUNDARY. Anthropic ANSWERED, so unless
575
+ // the status is itself a capacity signal, the upstream is provably reachable and
576
+ // serving — a per-request verdict (400 bad transcript, 401, 404, 413, 422) says
577
+ // NOTHING about capacity and must never keep the fleet-wide throttle armed.
578
+ // Without this, a poisoned request (e.g. a provider-authored thinking block that
579
+ // Anthropic 400s) claimed the recovery probe, "failed" it, re-armed the shared
580
+ // throttle every 5s, and benched EVERY healthy account indefinitely — a hard
581
+ // production-down deadlock (2026-07-25: 6 of 13 re-arms were client-side 400s
582
+ // while accounts sat at 2%/11%/25% weekly). confirmUpstreamProbe also clears
583
+ // lease.upstreamThrottleProbe, so the releaseAccount probe branch below no-ops.
584
+ if (!isCapacitySignalStatus(upstreamRes.status)) accountManager.confirmUpstreamProbe?.(lease);
585
+
566
586
  // Retry/failover can only happen before response bytes are sent. Once a
567
587
  // streaming response starts, rerouting would corrupt Claude Code's stream.
568
588
  if (upstreamRes.status === 429) {
@@ -872,6 +892,12 @@ async function forwardRequest(
872
892
  // 262144". Detect it ONLY on a provider (a Claude account's context-length 400 is
873
893
  // terminal — nothing bigger to fall to) so we can pin the session to Claude.
874
894
  const providerTooSmall = account.type === 'provider' && isContextLengthError(errorBody);
895
+ // DETERMINISTIC signature rejection (exact Anthropic wording) — the only trigger
896
+ // for the strip-and-recover retry below. Deliberately NOT the fuzzy
897
+ // isAnthropicIncompatBody heuristic, so a merely malformed request can never cause
898
+ // us to rewrite a user's transcript.
899
+ const isSignatureRejection = account.type !== 'provider'
900
+ && /invalid `signature` in `thinking`/i.test(errorBody);
875
901
  const errorType = errorBody.includes('Invalid `signature` in `thinking` block')
876
902
  ? 'invalid_thinking_signature'
877
903
  : anthropicIncompat ? 'anthropic_incompatible_transcript'
@@ -906,6 +932,26 @@ async function forwardRequest(
906
932
  }
907
933
  }
908
934
 
935
+ // RECOVER-ON-CLAUDE (preferred over the provider pin below): the transcript
936
+ // carries provider-authored thinking blocks whose signature Anthropic rejects.
937
+ // Strip exactly those blocks and retry on Claude, so a session that took even one
938
+ // GLM/Kimi fallback turn is NOT bricked (or exiled to a provider) for the rest of
939
+ // its life. Verified against the real API — the stripped history returns 200, with
940
+ // text + tool_use preserved. Tried once per request (thinkingStripped guard); if it
941
+ // still fails, the provider pin below is the fallback.
942
+ if (isSignatureRejection && !requestInfo.thinkingStripped
943
+ && canRetryBufferedBody && retryCount + 1 < maxAttempts && !res.headersSent) {
944
+ const { body: cleanBody, removed } = stripForeignThinkingBlocks(body);
945
+ if (cleanBody) {
946
+ console.log(`[Maxpool] Recovering session on Claude: stripped ${removed} provider-authored thinking block(s) Anthropic rejected`);
947
+ return forwardRequest(
948
+ req, res, cleanBody, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir,
949
+ retryConfig, queueConfig, { ...requestInfo, thinkingStripped: true },
950
+ canRetryBufferedBody, canQueueBufferedBody, excludedIndexes,
951
+ );
952
+ }
953
+ }
954
+
909
955
  // React-and-heal: this transcript can't run on Claude (foreign server_tool_use
910
956
  // id / thinking Anthropic can't validate). The 400 is pre-stream and the body
911
957
  // is buffered, so latch the session Anthropic-incompatible (sticky → once per
@@ -951,13 +997,44 @@ async function forwardRequest(
951
997
  );
952
998
  }
953
999
 
1000
+ // Nothing could heal it — replace the cryptic upstream 400 with the real cause and
1001
+ // the actual way out. This is what the user saw for hours as a bare
1002
+ // "400 messages.51.content.8: Invalid `signature` in `thinking` block".
1003
+ // Trigger on the DETERMINISTIC signature rejection only — never the fuzzy
1004
+ // isAnthropicIncompatBody heuristic, so an unrelated malformed request keeps its
1005
+ // own upstream error instead of being mislabelled a provider-contamination.
1006
+ if (isSignatureRejection && !res.headersSent) {
1007
+ const provs = (accountManager.accounts || []).filter(a => a.type === 'provider');
1008
+ // Say only what ACTUALLY happened. `thinkingStripped` is set only when the strip
1009
+ // ran; without it we found nothing provider-shaped, so claiming we stripped —
1010
+ // or blaming GLM/Kimi — would misdirect the user.
1011
+ const what = requestInfo.thinkingStripped
1012
+ ? 'This session ran on GLM/Kimi earlier and Anthropic rejects the thinking blocks they wrote. Maxpool removed them and retried on Claude, but Anthropic still rejected the history.'
1013
+ : "Anthropic rejected a thinking block in this session's history, and maxpool could not identify it as provider-authored, so it could not be repaired automatically.";
1014
+ const hint = provs.length === 0
1015
+ ? ''
1016
+ : provs.every(a => a.enabled === false)
1017
+ ? ' If this session previously ran on GLM/Kimi, re-enabling that provider in the maxpool TUI (a → t) lets it continue there.'
1018
+ : '';
1019
+ ctx.status = 400;
1020
+ sendErrorResponse(res, requestInfo, 400, {
1021
+ type: 'error',
1022
+ error: {
1023
+ type: 'invalid_request_error',
1024
+ message: `Start a new session to keep working — this one cannot continue. ${what}${hint}`,
1025
+ },
1026
+ });
1027
+ return;
1028
+ }
1029
+
954
1030
  ctx.status = upstreamRes.status;
955
1031
  sendErrorBody(res, requestInfo, upstreamRes.status, errorBody, upstreamRes.headers);
956
1032
  return;
957
1033
  }
958
1034
 
959
1035
  if (upstreamRes.status < 400) {
960
- accountManager.confirmUpstreamProbe?.(lease);
1036
+ // (the probe was already settled at the header boundary above — this only
1037
+ // records the account-level "upstream accepted us" signal)
961
1038
  accountManager.markUpstreamAccepted?.(account.index);
962
1039
  }
963
1040
 
@@ -1257,7 +1334,7 @@ function isContextLengthError(errorBody) {
1257
1334
  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);
1258
1335
  }
1259
1336
 
1260
- export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, commitStreamGraceHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, isContextLengthError, streamResponse, startIdleRequestReaper };
1337
+ export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, isCapacitySignalStatus, isForeignThinkingSignature, stripForeignThinkingBlocks, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, commitStreamGraceHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, isContextLengthError, streamResponse, startIdleRequestReaper };
1261
1338
 
1262
1339
  async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
1263
1340
  if (!upstreamRes.body) return '';
@@ -1433,6 +1510,80 @@ function secondsUntilParsedTime(value) {
1433
1510
  return null;
1434
1511
  }
1435
1512
 
1513
+ // An Anthropic thinking `signature` is a long base64 protobuf blob (200+ chars, e.g.
1514
+ // "Eo8DCpQBCBAYAipAqutr+oAB…"). GLM's Anthropic-compatible endpoint emits a SHORT hex
1515
+ // digest instead (verified 2026-07-25: "8f8840affff743118e1f569d", 24 hex chars) — which
1516
+ // Anthropic rejects with `Invalid \`signature\` in \`thinking\` block`, permanently
1517
+ // bricking any session that took even ONE provider turn. Detect the foreign shape
1518
+ // conservatively: only a missing / short / pure-hex signature counts, so a genuine
1519
+ // Anthropic block is never stripped.
1520
+ function isForeignThinkingSignature(sig) {
1521
+ if (typeof sig !== 'string' || sig.length === 0) return true;
1522
+ if (sig.length < 60) return true;
1523
+ return /^[0-9a-f]+$/i.test(sig);
1524
+ }
1525
+
1526
+ /**
1527
+ * Recovery for a provider-contaminated transcript: drop the assistant `thinking` /
1528
+ * `redacted_thinking` blocks whose signature Anthropic can't validate, so the session
1529
+ * can CONTINUE ON CLAUDE instead of being pinned to a provider forever.
1530
+ *
1531
+ * Empirically verified 2026-07-25 against the real API: replaying a GLM-authored
1532
+ * thinking block to Anthropic returns 400; the SAME history with those blocks removed
1533
+ * returns 200 — including an assistant turn carrying `tool_use` followed by a
1534
+ * `tool_result` (thinking blocks are not required on replay for a new turn). Text and
1535
+ * tool_use blocks are preserved, so no conversation content or tool wiring is lost.
1536
+ *
1537
+ * Returns { body, removed } — `body` is null when nothing needed stripping.
1538
+ */
1539
+ function stripForeignThinkingBlocks(body) {
1540
+ try {
1541
+ const json = JSON.parse(Buffer.isBuffer(body) ? body.toString('utf8') : String(body));
1542
+ if (!Array.isArray(json?.messages)) return { body: null, removed: 0 };
1543
+ let removed = 0;
1544
+ const messages = [];
1545
+ for (const msg of json.messages) {
1546
+ if (msg?.role !== 'assistant' || !Array.isArray(msg.content)) { messages.push(msg); continue; }
1547
+ let localRemoved = 0;
1548
+ const kept = msg.content.filter(block => {
1549
+ // ONLY `thinking`. NEVER `redacted_thinking`: a genuine one carries `data` and
1550
+ // legitimately has NO signature, so signature-based judging would strip 100% of
1551
+ // them (verified against the live API). GLM's contamination is always a
1552
+ // `thinking` block with a hex signature.
1553
+ if (block?.type !== 'thinking') return true;
1554
+ if (!isForeignThinkingSignature(block.signature)) return true;
1555
+ localRemoved++;
1556
+ return false;
1557
+ });
1558
+ removed += localRemoved;
1559
+ if (!localRemoved) { messages.push(msg); continue; }
1560
+ // A turn that stripping empties is DROPPED, not left with its poisoned block:
1561
+ // leaving it would resend the exact body that just 400'd while reporting success
1562
+ // and burning the single recovery attempt. Verified against the live API — the
1563
+ // resulting consecutive user messages are accepted (200 OK).
1564
+ if (kept.length === 0) continue;
1565
+ messages.push({ ...msg, content: kept });
1566
+ }
1567
+ if (!removed) return { body: null, removed: 0 };
1568
+ json.messages = messages;
1569
+ return { body: Buffer.from(JSON.stringify(json)), removed };
1570
+ } catch {
1571
+ return { body: null, removed: 0 }; // non-JSON / unparseable → no rewrite
1572
+ }
1573
+ }
1574
+
1575
+ /**
1576
+ * Is this status a CAPACITY signal (i.e. evidence the upstream can't serve us right
1577
+ * now), as opposed to a per-request verdict? Only these may keep the shared Anthropic
1578
+ * throttle armed. 403 is included deliberately: on these plans a 403 is almost always
1579
+ * quota/plan exhaustion, NOT bad credentials (see the provider-auth handler). 408 is a
1580
+ * latency/capacity signal. Everything else in 4xx (400/401/404/413/422…) means Anthropic
1581
+ * answered a specific request — the upstream is alive.
1582
+ */
1583
+ function isCapacitySignalStatus(status) {
1584
+ return status === 429 || status === 403 || status === 408 || status >= 500;
1585
+ }
1586
+
1436
1587
  function isRetriableUpstreamStatus(status) {
1437
1588
  // 500 included: Anthropic 500s are transient server errors (same class as
1438
1589
  // 502/503/504). Without this they were passed straight through to the client
package/src/updater.js CHANGED
@@ -95,6 +95,14 @@ export function markApplied(version) {
95
95
  }
96
96
  }
97
97
 
98
+ /** Clear the applied-version quarantine floor. The AUTO path quarantines a version it
99
+ * ATTEMPTED (markApplied, before the reload) so a genuinely boot-broken release can't
100
+ * reload-loop — but a reload that rolled back for a NON-version reason (e.g. a slow
101
+ * readiness handshake on a loaded machine) then wrongly strands a perfectly-good version
102
+ * ("already attempted — will retry only a newer release"). An EXPLICIT manual apply calls
103
+ * this first so the user can always re-attempt the current latest. */
104
+ export function clearQuarantine() { _lastAttemptedTarget = null; }
105
+
98
106
  /**
99
107
  * Check for an update; with `config.autoUpdate` also self-install; with
100
108
  * `config.autoApply` SIGNAL that the caller should seamlessly reload to APPLY it