Repository navigation
fix(subscriptions): a rate-limited turn completes on another subscription (#2638) - #2645
Conversation
…tion (#2638) A Workspace message to an agent whose Claude subscription was rate-limited failed outright, the message was lost to a FAILED execution, and the client was told the failure was not retryable. Every SUB-003 mechanism was working — switch on the first 429 (#441), re-issue once (#792), rank by headroom (#2409). Four gaps between them left the turn failing anyway. **1+2 — the 2h skip-list is now overridable, per candidate, on evidence.** `list_viable_alternative_subscriptions` drops any subscription with ANY failure event in a flat 2h window, so on a two-subscription install one stale event means "no viable alternative" while an alternative the provider would serve sits there — which was #2320's own evidence. `recovery_verdict` readmits on positive evidence ONLY: a FRESH reading saying the provider is not refusing (`serving_now` — #447's rule that a probe beats an inference from past failures), or a blocked window's own reset instant ELAPSED **and predating the failure** (`window_reset`). That ordering is load-bearing: without it, a subscription that 429'd a minute AFTER its rollover would be readmitted on a reset it had already consumed. Instants come from an AGED snapshot on the asymmetry this codebase already states — a utilisation number decays, an instant does not. Three properties keep #444's ping-pong closed: absence of evidence readmits nothing (that loop was caused by FORGETTING a failure); a fresh refusal does not fall through to the weaker instant arm; and the fail-open ranking branch readmits nobody by construction, because it is precisely where the evidence could not be read. One interaction found by the end-to-end test rather than by reading: a `window_reset` candidate's reading carries a `blocked` flag describing the window that just rolled over, and `rank_subscriptions` drops a blocked candidate as refused — so the readmission was inert in exactly the case it exists for. Those readings are handed to the ranker as UNKNOWN. **3 — the switch can happen BEFORE the first dispatch.** SUB-003 was purely reactive, so the first message after a wall always burned a failed attempt — on the Workspace, a person watching their message fail. `ensure_serviceable_subscription` moves the agent when its subscription is already known-refused (a fresh provider refusal, or a 429 in the platform's own 2h window — the 429-only DISPLAY predicate, deliberately, since an auth failure is a credential problem another subscription may share). It never raises, records NO failure event (nothing failed, and a synthetic one would poison the skip-list it feeds), dispatches anyway when there is no alternative, and performs the SAME `_perform_auto_switch` so the activity, notification and hot-reload happen whichever path fired. **1 last resort — the platform API key.** When the switcher declines, `fallback_to_api_key` clears the assignment, sets `use_platform_api_key` and restarts rather than hot-reloading (the reload endpoint pushes an OAuth token; this needs the opposite change, which lifecycle already derives from DB state). Setting `subscription_api_key_fallback`, default ON, fail-OPEN on a read error, with `key_configured` on the read — a toggle reading only "on" with no key stored describes a remedy that cannot run. **4 — the client is told what changed.** `TaskExecutionResult. subscription_switch` carries the switch; the portal answers 503 `auth_switched` retryable=True naming the new subscription instead of #2320's `retryable=False`, which was true only while nothing changed underneath. With no switch it still refuses, but names the earliest reset the sampler already caches instead of "try again later". Gap 5 (pull-dispatched terminals never trigger SUB-003) is filed as #2643 per AC #7 — a different blast radius, and inert until an agent is piloted. Tests: `tests/unit/test_2638_subscription_switch_on_turn.py` (37) — the verdict as a pure table, readmission through the real selector, the pre-dispatch contracts, the fallback's setting semantics, and three end-to-end turns through the real `execute_task` + real switcher: completes on a never-failed alternative, completes on a READMITTED one, and the honest negative. `test_2409` (87), `test_447`, `test_792`, `test_2352` and the ping-pong suite are unchanged in substance and green. Related to #2638 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
…ck log `logger.warning(..., API_KEY_FALLBACK_SETTING, ...)` trips `py/clear-text-logging-sensitive-data`: the rule flags any `*_KEY`-shaped name reaching a log call, and this one is a hard-coded settings key NAME, not a secret. Removed the interpolation rather than dismissing the alert. The constant is one line above the log, so the message loses nothing an operator wanted, and a dismissal would leave every future PR touching this file re-litigating the same finding. Related to #2638 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
…k toggle `rawColorRatchet.spec.js` fails: `SubscriptionsPanel.vue raw_gray 165 -> 173`. The +8 is the API-key fallback toggle row #2638 adds — the label, the help text and the toggle's off-state. Not paid down, because there is nothing to pay it down TO: `gray` is the sanctioned chrome family and `tailwind.config.js` defines no semantic neutral (status-* / state-* / brand-* / accent-* / action-* all carry meaning). The eight classes are copied verbatim from the three identical toggles immediately above this one; inventing a one-off token for the fourth would make it the odd one out while leaving its siblings unconverted. Re-frozen in its OWN commit, as the guard's failure message prescribes, and scoped to that ONE entry by hand rather than regenerated — a full regeneration would silently absorb any unrelated drift that has landed on dev since. Related to #2638 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
CodeQL flags `py/clear-text-logging-sensitive-data` in the switch+retry block: a subscription NAME read off the switch result is tainted from `subscription_credentials`, whose row carries an encrypted token, so the whole record reads as a credential. The destination is dropped from the pre-dispatch line rather than dismissed. `_perform_auto_switch` already logs "Auto-switching agent 'X' from 'A' to 'B'" one frame down, so the interpolation duplicated the frame below it and was not worth a standing false positive on the hot path. The sibling alert on `platform_audit_service.py:391` is untouched by this PR — it has been open on `dev` since 2026-06-04 and is attributed here only by the diff-scan. Related to #2638 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
…to internal `regression diff` caught a real defect, not a stale test. `test_every_declared_category_is_actually_raised_somewhere` keeps `PORTAL_FAILURE_CATEGORIES` closed in BOTH directions, and the new `category="auth_switched"` raise site was not in it. That is not bookkeeping: `record_turn_outcome` coerces an undeclared category to `internal` — silently. So the switch outcome would have been recorded as an uncategorised crash, NOT retryable, with the fixed internal copy in place of the sentence naming the new subscription. The gap-4 fix would have shipped inert while its raise site read as correct. Declared as a TENTH token rather than folded into `auth`, because the two disagree about the only thing the client acts on: `auth` means retrying re-fails, which holds exactly while nothing changed underneath, and a switch is something changing underneath. It needs no client branch — `cancelled` and `invalid_model` are the only categories the client branches on; everything else renders its message and its `retryable` flag. Also drops the destination name from #792's switch log, the second CodeQL `py/clear-text-logging-sensitive-data` sink on this path: a name read off the switch result is tainted from `subscription_credentials`, whose row carries an encrypted token. `_perform_auto_switch` already logs "Auto-switching agent 'X' from 'A' to 'B'" one frame down and the audit row still carries `new_subscription`, so no operator loses anything — only a duplicated interpolation goes. The sibling alert on `platform_audit_service.py:391` is untouched by this PR and has been open on `dev` since 2026-06-04. Related to #2638 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
|
merge-train 2026-09-09: not on this train. AC 1 — the turn completing on another subscription — is genuinely fixed and E2E-tested. Two findings on the rest, both verified against the source. 1. The client-facing half is inert for the 429 path that produced the bug — error_code = None
if agent_status_code == 503:
error_code = TaskExecutionErrorCode.AUTHA Claude subscription usage limit surfaces from the agent as 429, not 503. Workspace turns are So AC 4/5 — "the client is told what changed", which is what the title advertises — does not fire for the symptom in the title. It fires only when the failure happens to surface as 503. 2. The switch-off predicate re-opens the A fresh reading saying not refusing does not stop the fall-through to Smaller things worth folding in: The CodeQL alert is a false positive and you handled it correctly by fixing rather than dismissing — alert #351 is Rides the next train once fixed. |
…state, one remediation per turn (#2638) Four review findings. 1. **The client-facing half was inert for the 429 path that produced the bug.** A Claude usage limit surfaces from the agent as 429; `_handle_http_error` classified only 503, so `error_code` stayed None. `TaskExecutionErrorCode. BILLING` had NO assignment site anywhere in `src/backend` — enum definition, comments, and the portal's gate tuple, never set. A Workspace turn is `triggered_by="public"`, which is not async-eligible, so it takes exactly that sync path: AC 4/5 fired only when a failure happened to arrive as 503. A 429 now sets `BILLING`. Downstream is safe by construction — the dispatch breaker counts `auth` only (#526 D10); the #1085 governor does count `billing`, which is what it was written for and which has never been reachable from the sync path, behind a default-OFF flag. 2. **`_assigned_subscription_is_refused` re-opened the #447 OR.** A fresh reading now ends the question in both directions — refusing → switch, not refusing → dispatch — and only the absence of a usable reading falls through to the 2h event predicate. As written, a subscription the provider was demonstrably serving was readmitted by `recovery_verdict` and evacuated by this on every dispatch: a hot-reload and a high-priority notification per turn, and with two such subscriptions, a flap. An unreadable snapshot still falls through rather than clearing, so a Redis blip cannot silently disable the arm. 3. **The pre-dispatch path now spends the turn's one remediation.** It set `subscription_switch`, not `subscription_switch_attempted`, so a turn moved before its first attempt and refused again switched a second time and re-issued — the cascade that flag exists to stop. 4. **`_with_switch` at every terminal return.** `BackendAgentCallBudgetExhausted` and the generic `except Exception` were unwrapped, and both are reachable after a pre-dispatch switch, so the portal would say "not retryable" while the agent sat on a fresh subscription. Tests: `TestTheRefusalPredicateIsThreeState` (five cases, including the two doors agreeing on ONE reading rather than being checked in isolation, and the fail-closed unreadable snapshot); `TestEveryTerminalCarriesTheSwitch`, an AST guard over `execute_task`'s return sites with the two pre-dispatch returns named so a later addition has to be justified; the E2E negative now asserts `error_code` is BILLING — by `.value`/`.name`, since #1085's fieldless-dataclass quirk makes `BILLING == AUTH` True — which is the one assertion that would have caught (1); and a new E2E turn proving a pre-dispatch switch does not switch twice. Each of the four fails against the pre-fix source, verified by reverting them one at a time. 1008 passed across every subscription / task-execution / portal / headroom test. Fixes #2638 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
|
All four addressed in 1. The 429 path. You were right and it was worse than "inert": 2. The #447 OR. 3. One remediation per turn. The pre-dispatch arm sets 4. Each fix fails its test against the pre-fix source — verified by reverting them one at a time, not by inspection.
|
…ss, not the selection bound (#2638) Found re-reviewing my own #2638 fix. Making `_assigned_subscription_is_refused` three-state was right, but the reading it trusts came from `cached_headroom_readings` with no `max_age_seconds` — i.e. the SELECTION bound, `MAX_READING_AGE_SECONDS` (>= 2h). That bound is calibrated for RANKING candidates, where a stale reading beats none. This call does a different job: it decides whether a provider verdict may OVERRULE the 2h event predicate. A reading as old as the window it overrules cannot — so a two-hour-old "serving" snapshot could suppress a five-minute-old 429 and pin an agent on a subscription that is refusing it right now. The #447 rule is "a probe is ground truth about NOW", and this was applying it to a probe that is no longer about now. It now asks for `FRESHNESS_SECONDS` (30 min) — the same bound `_headroom_indicates_healthy` uses for the same judgement one module over, and the one this file already declares for the mirror case (`REFUSAL_FRESHNESS_SECONDS = FRESHNESS_SECONDS`, "a refusal is trusted exactly as long as the LIMIT badge trusts one"). It tightens the refusing arm too, which is deliberate and safe: a stale refusal falls through to the event predicate rather than evacuating on its own. Tests: the fixture now models the AGE GATE rather than only the lookup (a fake that ignores `max_age_seconds` makes the distinction untestable — the trap the E2E harness already documents for the readmission path), plus three cases — a stale serving verdict falling through to the event predicate, a display-fresh one still winning, and the bound asserted as the ARGUMENT, since omitting it is the bug and a behavioural test alone would pass again the day the default moves. Both new cases red against the unbounded call. 49 passed. Fixes #2638 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
|
Self re-review — found one defect in my own fix, now fixed in Making The reading came from Concretely: a two-hour-old "serving" snapshot could suppress a five-minute-old 429 event and pin an agent on a subscription that is refusing it right now. #447's rule is "a probe is ground truth about now"; I applied it to a probe that is no longer about now. The bound it should have asked for was already stated twice in the file it reads from:
So On the tests, because the first version could not have caught this.
|
|
Second note from the same pass — this PR no longer merges cleanly, and one of its stated boundaries is now false. The conflicting change is That matters beyond the textual conflict, because this PR's Not covered section says:
That was true when written and is not true now. Rebasing has to do two things, and only the first is mechanical:
Not a defect in the change itself — but it is exactly the kind of stale-boundary claim the flow docs are read for, so it should not land as written. |
|
Resolve by running |
Two conflicts, both ADD/ADD at the same point in the file, and both resolved by keeping BOTH sides — neither is a competing version of the other. `services/subscription_auto_switch.py`: this branch (#2638) adds the pre-dispatch switch and the API-key fallback — `_assigned_subscription_is_refused`, `ensure_serviceable_subscription`, `is_api_key_fallback_enabled`, `earliest_known_reset`, `fallback_to_api_key`. dev (#2643) adds the pull-sink hook — `_inflight_switch_tasks`, `spawn_subscription_failure`, `_guarded_switch`. They landed at the same offset and share no name; nothing calls into the other. Both blocks kept. `docs/memory/feature-flows/subscription-auto-switch.md`: two independent history sections, both dated 2026-09-09. The file lists dated sections oldest-first (#471, #2352, #2409, …), and #2643 merged after #2638, so the order is #2638 then #2643. The two features are complementary rather than overlapping, which is worth stating because the merge could look like duplication: #2643 gives the PULL terminal writer the SUB-003 hook the push path already had, and #2638 makes the switch happen before the first dispatch rather than after a burnt attempt. Nothing in either reads the other's state. Subscription/headroom/auto-switch tests green on the result: 497 passed, 2 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
|
Resolve by merging |
Resolve src/frontend/raw-color-baseline.json: keep the #2638 SubscriptionsPanel note alongside the #2662 notes that landed on dev. Ratchet test passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
|
merge-train 2026-09-10 (evening run): not on this train. Everything the 09-09 ejection asked for is verified fixed by execution, not by reading the description: 429 → ❌ The blocker — readmission re-opens the ping-pong the PR says it keeps closed
fresh = headroom.cached_headroom_readings(ids) # no max_age → MAX_READING_AGE_SECONDS (≥ 2h)
...
if fresh is not None and not fresh.refusing:
return RECOVERY_SERVING_NOW # no "reading postdates the failure" check
Case 2 is the default configuration and the scenario that motivated the PR: two subscriptions both at the wall, each with a pre-wall "ok" snapshot (the normal state for up to one refresh interval after a wall). Pre-PR: A 429 → switch to B → B 429 → honest FAILED, no churn. Post-PR: every user turn readmits the other subscription on its pre-failure reading → hot-reload + notification + failure event + FAILED + a Fix needs your intent, which is why it is not a train patch: bound Why CI is green
Mechanical, ride the same push
Non-blocking, for your judgement: Rides the next train once the readmission bound is decided. |
…e, and reads at the display bound (#2638) The 09-10 merge-train ejection: `recovery_verdict`'s serving_now arm readmitted a skip-listed subscription on ANY non-refusing reading inside the ≥2h selection bound, with none of the ordering the window_reset arm has. A reading taken before the 429 is exactly what a subscription at the wall carries for up to one refresh interval after hitting it, so on a two-subscription install both at the wall every user turn readmitted the other on its pre-wall "ok", switched, failed, and flapped A<->B until a probe recorded rate_limited — #444's ping-pong re-opened from the other side. - `recovery_verdict`: serving_now readmits only when the reading POSTDATES `last_failure_at` (reading instant = now - age_seconds); no failure instant, or an unparseable one, readmits nothing on that arm (fail closed, like the instant arm). A pre-failure "ok" falls through to the window_reset arm, which orders itself. - `_readmit_recovered`: the fresh read is bounded at FRESHNESS_SECONDS — the same bound the evacuation door uses — not the selection bound. - `test_subscription_auto_switch_pingpong.py`: the headroom stub gains the names the merged selector reads (FRESHNESS_SECONDS, RECOVERY_*, recovery_verdict) plus a name-parity assertion, so the suite fails instead of taking the fail-open branch and going inert — the ejection's second finding. - Regression tests for both repro cases (age 600 s / 429 two minutes ago; the unorderable failure) at the pure verdict and through the selector; the "doors agree" invariant restated with the failure predating the reading, plus the one permitted disagreement pinned (both doors shut is not a flap). - Docs: subscription-auto-switch.md "Not covered" and requirements/security.md no longer claim pull terminals never trigger SUB-003 (#2643 gave the sink the hook). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
Resolve src/frontend/raw-color-baseline.json: notes block only (dev's _2616_note kept beside this branch's 2638 note). Ratchet test passes on the merged tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
|
Addressed the 2026-09-10 ejection — pushed Blocker — readmission ordering. Took option 1 (keep
Inert suite. Mechanical. Left as-is (judgement items): 332 subscription-suite tests green on the merged tree. |
|
merge-train 2026-09-11: on this train. The 2026-09-10 blocker is fixed and I proved it by mutation rather than by reading: replacing One same-class finding, non-blocking — it's copy-only and a fix wants its own test, so it's yours as a follow-up rather than a fourth ejection:
Smaller notes: |
A Workspace message to an agent whose Claude subscription was rate-limited failed outright — "The agent has reached its usage limit and can't respond right now. Please try again later." — the message was lost to a FAILED execution, and the client was told the failure was not retryable.
Every SUB-003 mechanism was working: switch on the first 429 (#441), re-issue once (#792), rank by cached headroom (#2409). This closes the four gaps between them.
The four gaps
1+2 — the 2h skip-list is now overridable, per candidate, on evidence
list_viable_alternative_subscriptionsdrops any subscription with any failure event in a flat 2h window. That window is a proxy for "the provider is still refusing it", and on a two-subscription install a single stale event is the difference between a completed turn and a user watching their message die. #2320's own evidence was exactly this: "no viable alternative — the whole pool was exhausted", with an alternative sitting right there.subscription_headroom_service.recovery_verdictoverrides the exclusion on positive evidence only:serving_nowrank_subscriptionsalready trusts in the other direction when it drops a refusing candidatewindow_resetNoneThree properties keep #444's ping-pong closed:
Instants are read from an aged snapshot on the asymmetry this codebase already states (#447/#2396): a utilisation number decays, an instant does not.
The fail-open ranking branch readmits nobody by construction — it is precisely the branch where the evidence could not be read, so the skip-list stands.
3 — the switch can happen BEFORE the first dispatch
SUB-003 was purely reactive, so the first message after a subscription hits its wall always burned a failed attempt — and on the Workspace that attempt is a person watching their message fail. Everything needed to avoid it was already known at dispatch time; nothing was reading it.
ensure_serviceable_subscriptionswitches when the assigned subscription is already known not to serve: a fresh provider refusal, or a 429 in the platform's own 2h window. It uses the 429-only DISPLAY predicate (#2352) deliberately — an auth failure is a credential problem a different subscription may share, and quota exhaustion is the case a switch actually fixes.Contracts: it never raises and never blocks; it records no failure event (nothing failed, and a synthetic one would poison the very skip-list that decides where the agent may move next); with no alternative it dispatches anyway (the provider's answer is better evidence than ours); and it performs the same
_perform_auto_switch, so one activity, one notification and one hot-reload happen whichever path fired — AC #6.1 last resort — the platform API key
When the switcher declines and a key is configured,
fallback_to_api_keyclears the subscription assignment, setsuse_platform_api_keyand restarts (the reload endpoint pushes an OAuth token; the change needed here is the opposite one, whichlifecycle's auth block already derives from DB state — including #2114's shadowing guard). It clears rather than remembering-and-restoring: a hidden "go back at reset" would be a second invisible scheduler competing with the operator's own assignment.Setting
subscription_api_key_fallback, default ON,GET/PUT /api/subscriptions/settings/api-key-fallback, rendered in Settings → Subscriptions. The GET also returnskey_configured: with the setting on and no key stored the fallback is enabled and inert, and a control showing only "on" would describe a remedy that cannot run. The read fails open — the failure it guards is a user's turn dying with a usable key sitting in settings.4 — the client is told what changed
TaskExecutionResult.subscription_switchcarries the switch (in-memory, likedispatched_async), stamped atexecute_task's return sites. The portal's AUTH/BILLING branch consults it before refusing:503,category="auth_switched",retryable=True, naming the new subscription. bug(workspace): failed turns are invisible to the client — fast failures misreported as "lost track", backend error discarded, Retry suppressed where it is safe #2320'sretryable=Falserested on "re-sending re-fails", which holds only while nothing changed underneath — and a switch is exactly something changing underneath.502, but the body names the earliest reset instant the sampler already caches. "Please try again later" is true and nearly useless: the person cannot tell whether later means ten minutes or two days. Degrades to the original sentence when the instant is unknown — a fabricated time would be worse than a vague one.One defect the tests found in the fix itself
Worth calling out because reading the diff would not have caught it. A
window_resetcandidate's reading carries ablockedflag describing the window that just rolled over, andrank_subscriptionsdrops a blocked candidate asrefused— so the readmission was inert in exactly the case it exists for. The end-to-end test failed, not the unit table. Those readings are now handed to the ranker as UNKNOWN: the number is stale and the flag is about a quota that no longer exists.Acceptance criteria
test_subscription_auto_switch_pingpong.py, 30 passed, unchanged).key_configured); otherwise the error names the earliest known reset._perform_auto_switch).apply_task_result#2643.Verification
tests/unit/test_2638_subscription_switch_on_turn.py— 37 passed. The verdict as a pure table (including the failure-after-reset ordering and the fail-closed unreadable-instant cases), readmission through the real selector, the fail-open path readmitting nobody, the pre-dispatch contracts,earliest_known_reset, the fallback's setting semantics, and three end-to-end turns through the realexecute_task+ real switcher: 429 → completes on a never-failed alternative, 429 → completes on a readmitted one, and the honest negative (nothing to switch to ⇒ still FAILED).test_2409_headroom_ranked_switch.py87, plustest_447/test_792/test_2352/ ping-pong — 296 passed, byte-identical todevacross fourpytest-randomlyseeds.Not an integration test against a live instance: a real 429 cannot be provoked from a provider on demand, so the seam actually under test — refusal in, completed turn plus switch out — is exercised where it can be deterministic.
Two things worth a reviewer's eye
test_2409'stest_only_survivors_are_ever_readwas renamed totest_only_survivors_are_rankedand re-pointed atrank_subscriptions. The selector now also reads the skip-list's complement to decide readmission, so the old name asserted something that is deliberately no longer true; the property it protected (only survivors reach the ranker) is asserted directly. Its fixture also defaults the two new db reads to empty — a bareMagicMockis not iterable, and the resultingTypeErrorwould have degraded the whole ranker to the load-balance fallback while every test still read as if it were exercising it.SubscriptionsPanel.vueis not inraw-color-baseline.json, so nothing gates it — stating it rather than leaving it to be found.Fixes #2638
Related to #2643
🤖 Generated with Claude Code
https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP