Skip to content

fix(subscriptions): a rate-limited turn completes on another subscription (#2638) - #2645

Merged
vybe merged 12 commits into
devfrom
fix/2638-subscription-switch-on-turn
Sep 11, 2026
Merged

vybe merged 12 commits into
devfrom
fix/2638-subscription-switch-on-turn

Conversation

@dolho

@dolho dolho commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

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_subscriptions drops 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_verdict overrides the exclusion on positive evidence only:

verdict evidence
serving_now a FRESH reading says the provider is not refusing this token — ground truth about now beats an inference from past failures (#447's rule), and it is the same evidence rank_subscriptions already trusts in the other direction when it drops a refusing candidate
window_reset no fresh reading, but a BLOCKED window's own reset instant has elapsed and predates the failure
None everything else

Three 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_subscription switches 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_key clears the subscription assignment, sets use_platform_api_key and restarts (the reload endpoint pushes an OAuth token; the change needed here is the opposite one, which lifecycle'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 returns key_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_switch carries the switch (in-memory, like dispatched_async), stamped at execute_task's return sites. The portal's AUTH/BILLING branch consults it before refusing:

One defect the tests found in the fix itself

Worth calling out because reading the diff would not have caught it. 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. 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

  • A Workspace message on a rate-limited subscription completes on another subscription when any registered one can serve.
  • Alternative selection uses the cached provider reset times instead of the flat 2h skip-list; the ping-pong regressions still hold (test_subscription_auto_switch_pingpong.py, 30 passed, unchanged).
  • A known-refused assigned subscription is switched before the first dispatch — no failed attempt recorded.
  • No subscription can serve → API-key fallback behind a Settings toggle (default on, honest key_configured); otherwise the error names the earliest known reset.
  • The outcome carried to the Workspace includes the switch, and a turn that failed after one is reported retryable with the new subscription named.
  • Every switch path writes the same activity + notification (all three converge on _perform_auto_switch).
  • Gap 5 filed as bug(subscriptions): a pull-dispatched terminal never triggers SUB-003 — the switch hook is missing on 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 real execute_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.py 87, plus test_447 / test_792 / test_2352 / ping-pong — 296 passed, byte-identical to dev across four pytest-randomly seeds.
  • Frontend — 2385 passed (106 files).

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's test_only_survivors_are_ever_read was renamed to test_only_survivors_are_ranked and re-pointed at rank_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 bare MagicMock is not iterable, and the resulting TypeError would have degraded the whole ranker to the load-balance fallback while every test still read as if it were exercising it.
  • The new Settings toggle copies the file's existing raw-gray toggle classes rather than semantic tokens, for visual consistency with the three toggles beside it. SubscriptionsPanel.vue is not in raw-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

…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
Comment thread src/backend/services/subscription_auto_switch.py Fixed
dolho and others added 5 commits September 9, 2026 12:45
…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
@dolho
dolho requested a review from vybe September 9, 2026 13:22
@vybe

vybe commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

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 — src/backend/services/task_execution_service.py:2260-2263

error_code = None
if agent_status_code == 503:
    error_code = TaskExecutionErrorCode.AUTH

A Claude subscription usage limit surfaces from the agent as 429, not 503. Workspace turns are triggered_by="public", which is not async-eligible, so they take the sync path into _handle_http_error, where a 429 leaves error_code = None. TaskExecutionErrorCode.BILLING is never assigned anywhere in src/backend — I grepped every .py file at head; it appears only in the enum definition, in comments, and in the portal's gate tuple. The gate at client_portal/service.py:2722 is if code in ("AUTH", "BILLING"): with no substring fallback, so neither the auth_switched arm nor _usage_limit_detail runs. The turn still lands as generic agent_error.

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 #447 OR — src/backend/services/subscription_auto_switch.py::_assigned_subscription_is_refused

A fresh reading saying not refusing does not stop the fall-through to is_subscription_rate_limited, a raw COUNT(*) > 0 over 2h. That is fresh_refusing OR db_predicate, the shape resolve_rate_limited_now exists to replace. The readmit side (recovery_verdict) gives the fresh reading priority, so the two directions now disagree: an agent whose subscription has a ≤2h-old 429 event but which the provider is demonstrably serving gets evacuated on every dispatch, with a hot-reload and a high-priority notification each time. With two such subscriptions it flaps turn after turn. The PR's "absence of evidence readmits nothing" defence guards the readmit door only.

Smaller things worth folding in: _with_switch is missing at two of six execute_task return sites (BackendAgentCallBudgetExhausted and the generic except Exception, both reachable after a pre-dispatch switch); a turn can now perform two switches because the pre-dispatch path never sets subscription_switch_attempted; and the E2E negative asserts status == FAILED but never error_code, which is the one assertion that would have caught (1).

The CodeQL alert is a false positive and you handled it correctly by fixing rather than dismissing — alert #351 is fixed on the merge ref. The +8 raw-gray baseline growth is argued in its own commit and is acceptable as written. Body says Related to #2638 — please make it Fixes #2638.

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
@dolho

dolho commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

All four addressed in 16aa6037. Body now says Fixes #2638.

1. The 429 path. You were right and it was worse than "inert": TaskExecutionErrorCode.BILLING had no assignment site anywhere in the backend — enum, comments, the portal's own gate tuple, never set. _handle_http_error now classifies 429 → BILLING, so the auth_switched arm and _usage_limit_detail fire on exactly the path a Workspace turn takes. Downstream checked rather than assumed: the dispatch breaker counts auth only (#526 D10) so a quota 429 still cannot trip it, and the #1085 governor does count billing — which is what it was written for ("a fleet-wide Claude-API 429 storm"), has never been reachable from the sync path until now, and is behind a default-OFF flag.

2. The #447 OR. _assigned_subscription_is_refused is three-state now: a fresh reading ends the question in both directions, and only the absence of a usable reading falls through to the 2h event. An unreadable snapshot still falls through rather than clearing, so a Redis blip cannot silently disable the arm. The test I care most about is test_it_agrees_with_recovery_verdict_on_the_same_reading — it feeds one reading to both doors and asserts they agree, because checking each in isolation is how the two came apart.

3. One remediation per turn. The pre-dispatch arm sets subscription_switch_attempted. Worth noting explicitly since it is a second-order effect: the except handler reads the same flag, so after a pre-dispatch switch a subsequent 429 also records no second failure event — which is the rule already stated at that flag's other read site ("a second switch would burn another rate-limit event and churn to a third never-used subscription"), now actually true for both paths.

4. _with_switch. Both missing sites wrapped. Guarded by an AST test over execute_task's return nodes rather than a behavioural test of today's six, since the failure mode is a return site added later; the two legitimate bare returns (admission_denied, breaker_denied, both before any dispatch) are named in the allowlist so a third has to be argued for.

Each fix fails its test against the pre-fix source — verified by reverting them one at a time, not by inspection. 1008 passed, 2 skipped across every subscription / task-execution / portal / headroom test in the suite.

subscription-auto-switch.md records all four, including why BILLING never existed and why the two doors must be read from one predicate.

…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
@dolho

dolho commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Self re-review — found one defect in my own fix, now fixed in b1d0ca5f.

Making _assigned_subscription_is_refused three-state was the right correction, but I got the bound wrong, and the bound matters as much as the direction here.

The reading came from cached_headroom_readings([subscription_id]) with no max_age_seconds — so the default, which is the selection bound MAX_READING_AGE_SECONDS (max(2*3600, 2*SAMPLE_INTERVAL), so ≥ 2h). That default 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 — and a reading as old as the window it overrules cannot.

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:

  • _headroom_indicates_healthy — the display-side twin making the identical judgement — gates on age > FRESHNESS_SECONDS (30 min);
  • REFUSAL_FRESHNESS_SECONDS = FRESHNESS_SECONDS, whose comment is exactly this rule for the mirror case: "a refusal is trusted exactly as long as the LIMIT badge trusts one, then it is merely unknown."

So max_age_seconds=FRESHNESS_SECONDS. This tightens the refusing arm as well — deliberate and safe: a stale refusal now falls through to the event predicate rather than evacuating on its own.

On the tests, because the first version could not have caught this. _with_reading returned the reading regardless of what bound the caller asked for, which makes the distinction structurally untestable — the same trap the E2E harness in this file already documents for the readmission path ("a fake that ignores max_age_seconds hands the selector a fresh refusal"). The fixture now models the age gate, and there are 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 — because omitting it is the bug, and a behavioural test alone would go green again the day the default changes. Both new cases red against the unbounded call; verified by reverting the one argument.

49 passed. The rest of the earlier review (429 → BILLING, the one-remediation budget, _with_switch at every return site) re-read clean.

@dolho

dolho commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Second note from the same pass — this PR no longer merges cleanly, and one of its stated boundaries is now false.

$ git merge origin/dev
CONFLICT (content): src/backend/services/subscription_auto_switch.py
CONFLICT (content): docs/memory/feature-flows/subscription-auto-switch.md

The conflicting change is 294782b4 — #2651 (fixing #2643), already on dev: "a pull-dispatched terminal triggers SUB-003". It adds a spawned-switch path to the same module, with its own strong-ref set and a no-running-loop branch.

That matters beyond the textual conflict, because this PR's Not covered section says:

Pull-dispatched terminals still never trigger SUB-003 — pull_coordination_service.apply_task_result has no switch hook … Filed as #2643; inert today because no agent is piloted onto the pull path.

That was true when written and is not true now. Rebasing has to do two things, and only the first is mechanical:

  1. resolve the code conflict, and check the interaction rather than assuming there is none — dev's pull path now calls into the switcher, and this PR changed the switcher's refusal predicate and the one-remediation budget. Specifically: the pull sink's spawned switch does not carry an _AttemptState, so subscription_switch_attempted cannot bound it, and the pre-dispatch arm this PR added is on the push path only. Worth stating explicitly in the doc which budget applies on which path rather than leaving it to be inferred;
  2. delete the Not covered claim, or the merged doc asserts a gap that dev already closed.

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.

@github-actions

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

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
@github-actions

Copy link
Copy Markdown

⚠️ Live-instance suite skipped — merge conflict against dev.

Resolve by merging dev locally and pushing the result; the next nightly re-tests.

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
@vybe

vybe commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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 → BILLING on the sync path a Workspace turn takes (test_with_nothing_to_switch_to_the_turn_still_fails drives the real execute_task), the portal auth_switched arm fires for both the switched and the API-key-fallback case, the breaker still counts auth only, the governor's billing count stays behind its default-OFF flag, and the #2651 pull-sink hook is coherent with the changed predicate. Credit for all of that.

❌ The blocker — readmission re-opens the ping-pong the PR says it keeps closed

src/backend/services/subscription_auto_switch.py _readmit_recovered, and subscription_headroom_service.py recovery_verdict.

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

b1d0ca5f bounded the evacuation door at FRESHNESS_SECONDS with the rationale "a reading as old as the window it overrules cannot overrule it". The readmission door still reads at the selection bound, and its serving_now arm has none of the ordering the window_reset arm right below it has. Reproduced by feeding one snapshot to both doors on the merged tree:

reading age=6000s, 429 event 5 min ago
  readmission → 'serving_now'   evacuation on the SAME subscription → 'recent_rate_limit'
reading age=600s, 429 event 2 min ago
  readmission → 'serving_now'   evacuation → None

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 retryable=True "send that again" that cannot succeed, flapping A↔B until a probe records rate_limited.

Fix needs your intent, which is why it is not a train patch: bound fresh at FRESHNESS_SECONDS and require the reading to postdate last_failure_at for serving_now (mirroring window_reset), or drop serving_now readmission and keep window_reset only.

Why CI is green

tests/unit/test_subscription_auto_switch_pingpong.py installs a headroom_stub ModuleType without RECOVERY_INSTANT_MAX_AGE_SECONDS / recovery_verdict. On the merged tree all four service-level selector calls log [#2409] headroom ranking unavailable (AttributeError …) and take the fail-open branch, so "the ping-pong regressions still hold, 30 passed" never reaches ranking or readmission. It is the same silent inertness you fixed in test_2409's fixture. Please give this suite the same treatment; it is the one that should have caught the above.

Mechanical, ride the same push

  • docs/memory/feature-flows/subscription-auto-switch.md:340-342 still says pull-dispatched terminals never trigger SUB-003, four lines above the #2643 section that documents the hook; docs/memory/requirements/security.md:171 repeats it.
  • src/frontend/raw-color-baseline.json conflicts with dev only in the notes block (_2662_* vs "2638"); keep both. SubscriptionsPanel.vue 165 → 173 is named and matches the scanner on the merged tree.

Non-blocking, for your judgement: fallback_to_api_key runs with auto_switch_subscriptions OFF (the pre-dispatch arm honours the setting, the more invasive remediation does not); and after a fallback restart the #792 retry most likely lands as a transport error, so the API-key case reaches the user as a generic 502 rather than the auth_switched copy.

Rides the next train once the readmission bound is decided.

dolho and others added 2 commits September 11, 2026 13:51
…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
@dolho

dolho commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the 2026-09-10 ejection — pushed 3ca6a48 + a dev merge (1e0176f).

Blocker — readmission ordering. Took option 1 (keep serving_now, make it honest):

  • recovery_verdict: the serving arm readmits only when the reading postdates last_failure_at (reading instant = now − age_seconds), mirroring the window_reset arm; no failure instant, or an unparseable one, readmits nothing on that arm (fail closed). A pre-failure "ok" falls through to the instant arm, which orders itself.
  • _readmit_recovered: the fresh read is bounded at FRESHNESS_SECONDS — the same bound the evacuation door uses — not the ≥2h selection bound.
  • Both repro cases pinned at the pure verdict and through the selector (test_a_serving_reading_taken_BEFORE_the_failure_readmits_nothing, test_a_pre_wall_reading_does_not_readmit_at_the_selector), plus the unorderable-failure case. The "doors agree" invariant is restated with the failure predating the reading, and the one permitted disagreement is pinned in the direction that cannot flap (both doors shut).

Inert suite. test_subscription_auto_switch_pingpong.py's headroom stub now carries FRESHNESS_SECONDS, RECOVERY_*, recovery_verdict, and a name-parity assertion over every headroom.<name> the selector reads — so it fails rather than taking the fail-open branch. Verified: 0 [#2409] headroom ranking unavailable lines in its run now.

Mechanical. subscription-auto-switch.md "Not covered" + requirements/security.md:171 no longer claim pull terminals never trigger SUB-003; raw-color-baseline.json merged with both notes blocks kept (ratchet green on the merged tree).

Left as-is (judgement items): fallback_to_api_key still runs with auto_switch_subscriptions OFF, and the API-key case still reaches the user as a 502 rather than the auth_switched copy — both real, both separate from the readmission bug; happy to file them if you want them tracked.

332 subscription-suite tests green on the merged tree.

@vybe

vybe commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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 if read_at > failed_at with if True in recovery_verdict reds four tests (test_a_serving_reading_taken_BEFORE_the_failure_readmits_nothing, test_a_pre_failure_serving_reading_still_falls_through_to_the_reset_arm, TestReadmission::test_a_pre_wall_reading_does_not_readmit_at_the_selector, test_a_pre_failure_reading_closes_the_readmit_door_without_opening_evacuation); failing open on a missing last_failure_at reds one more; and 429 → BILLING is driven through the real execute_task. 171/171 in the PR's suites, no schema change, the subscription_api_key_fallback key passes the ent#435 sink guard. I added the ui label (frontend touched; e2e already ran green by path).

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:

earliest_known_reset can render an already-passed reset instant. src/backend/services/subscription_auto_switch.py:561-590 takes min() over blocked-window resets_at from a snapshot up to 7 days old with no check that the instant is still in the future, and client_portal/service.py:484-520 _usage_limit_detail renders it. Verified by execution: a 4 h-old snapshot with a reset 3 h ago renders "Its quota resets at 13:06 UTC on 11 Sep" at 16:06 UTC — on exactly the branch that fires when the subscription is refusing now. A parse_iso_timestamp(i) > now filter with the "try again later" fallback closes it. Realistic mainly with ambient refresh off or a sampler outage.

Smaller notes: tests/unit/test_2320_portal_failed_turn_visibility.py:327 is the closed-set guard whose docstring says its job is to name a new retryable category for a turn that ran — this PR adds exactly that (auth_switched, retryable=True) and the guard stays green only because _Result never carries subscription_switch; a TERMINAL_SITES row + the expected set would make it bite. The cancelled-branch comment in service.py ("only retryable verdicts are the ones where nothing reached the agent") is now stale. And the flow doc still doesn't state that the #2643 pull-sink switch carries no _AttemptState — the item your own 09-10 note said to state.

@vybe vybe added the ui PR touches the frontend UI — triggers Playwright e2e tests label Sep 11, 2026

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge-train: batch validated on train/20260911-1635 (#2729)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants