fix(auth): auto-switch profile on genuine exhaustion (regression from #374's false-cascade fix)#391
Merged
andrei-hasna merged 2 commits intoJul 25, 2026
Conversation
andrei-hasna
force-pushed
the
fix/auth-profile-autoswitch-on-real-exhaustion
branch
2 times, most recently
from
July 24, 2026 18:37
64f934f to
ac1ca2c
Compare
Contributor
Author
|
Rebased onto Blacksmith testbox gate: PASS — run 30117653681,
Results:
Not merging in this phase. |
andrei-hasna
force-pushed
the
fix/auth-profile-autoswitch-on-real-exhaustion
branch
2 times, most recently
from
July 25, 2026 05:04
c6c4c1c to
d8e272f
Compare
…374's false-cascade fix) account/rateLimits/updated snapshots display-only and gating all exhaustion bookkeeping + auto-switch to the account-verified AccountUsage read. That over- corrected: the rolling snapshot was also what drove auto-switch on GENUINE mid-turn exhaustion (it populated auth_profile_auto_switch_snapshots_by_limit_id and called maybe_auto_switch_auth_profile_for_rate_limit). After #374, when the current profile is really exhausted the switch map is empty (the ~60s authoritative heartbeat has not re-read yet and is then suppressed), and the usage-limit turn-error path only switched when a manual reset was active — so with auto-reset unavailable the failed turn just parked on a self-heal retry until the (days-away) reset instead of switching. Fix: drive auto-switch from the current profile's OWN authoritative usage-limit / 429 turn error (turn_runtime.rs on_rate_limit_error and the reset-unavailable fallback in usage_limit_reset/automatic.rs). This is authoritative for the current profile and immune to the #374 misattribution: a sibling agent's identity-less rolling snapshot never fails THIS profile's own turn. It prefers a cached account-verified exhausted window and otherwise synthesizes the trigger window from the operator's enabled auto-switch config (weekly preferred, else 5h), reusing the existing candidate selection that cycles to an Unknown-but- untried profile and bails (no loop) when every alternate is exhausted. Rolling snapshots remain strictly display-only and never seed the switch map, so Tests (tui/src/chatwidget/tests/status_and_layout.rs): - genuine_usage_limit_error_auto_switches_without_a_cached_snapshot (the regression) - rolling_snapshot_alone_never_switches_and_never_seeds_the_switch_map (#374 preserved) - genuine_usage_limit_error_does_not_loop_when_all_profiles_exhausted (graceful)
…indow Follow-up to the genuine-exhaustion auto-switch: two defects found in review. 1) The synthetic auto-switch could fire at most ONCE per session when the reset instant is unparseable. `auto_switch_trigger_key` is `limit_id:label:resets_at`, `resets_at` renders as the literal `unknown` when `None`, and the key carried no profile. `last_auth_profile_auto_switch_trigger` is never cleared (only the sibling `pending_...` latch is, on profile change), so a SECOND genuine exhaustion computed a byte-identical key, was rejected by the guard, and stranded the session on an exhausted profile while untried profiles remained. The reset-unavailable fallback was worse: it hardcoded `error_message: None`, so it could never parse a reset instant at all. Triggers now carry an optional `scope` (`AutoSwitchTrigger`); the synthetic path scopes its key with the exhausted profile plus a monotonic per-switch epoch, so each genuine exhaustion is its own trigger while repeats of the same exhaustion still dedupe. The reset-unavailable fallback replays the usage-limit turn error (`last_usage_limit_error_message`) instead of passing `None`; it is cleared when the selected profile changes. 2) The synthetic window was chosen by flag precedence (weekly first) and both flags default to true, so EVERY synthetic switch was labelled `weekly`, including 5-hour exhaustions (the common case). That surfaced the wrong window to the user and made `HighestAvailable` rank candidates by weekly headroom instead of 5h headroom. The label is now derived from the window that actually blocked the turn: an authoritative cached exhausted window first, else the advertised reset instant (inside the rolling 5h horizon can only be the 5h window, a weekly cap resets days out), else 5h. A window the operator opted out of is never switched on. Tests (all verified failing before this change): - repeated_genuine_usage_limit_errors_keep_switching_to_untried_profiles - synthetic_auto_switch_window_follows_the_exhausted_window_not_the_enabled_flags - synthetic_auto_switch_does_not_fire_for_a_window_the_operator_opted_out_of - reset_unavailable_fallback_replays_the_turn_error_to_identify_the_exhausted_window Existing genuine-exhaustion tests switched to relative reset instants so their weekly assertions stay correct as the calendar moves.
andrei-hasna
force-pushed
the
fix/auth-profile-autoswitch-on-real-exhaustion
branch
from
July 25, 2026 05:36
d8e272f to
f1bd734
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The
/config"Auth profile auto-switch" toggle no longer switches profiles when the current profile is genuinely exhausted (real usage-limit / 429, e.g. "You've hit your usage limit, try again Jul 28"). The turn just parks on a self-heal retry until the (days-away) reset. This restores the legitimate switch without reviving the cross-agent false-cascade that #374 fixed.Root cause (regression from #374)
#374 (
a31719336) stopped a false weekly-limit cascade by making rollingaccount/rateLimits/updatedsnapshots display-only and gating all exhaustion bookkeeping + auto-switch behind the account-verifiedRateLimitSnapshotSource::AccountUsageread (codex-rs/tui/src/chatwidget/rate_limits.rs:314-339).That over-corrected. Before #374 the rolling snapshot was also the trigger that drove auto-switch on genuine mid-turn exhaustion: it populated
auth_profile_auto_switch_snapshots_by_limit_idand calledmaybe_auto_switch_auth_profile_for_rate_limit. After #374, on genuine exhaustion:auth_profile_auto_switch_snapshots_by_limit_id) is empty — the limit is crossed mid-turn, before the ~60s authoritative heartbeat re-reads it, and the heartbeat is then suppressed for the exhausted profile (auth_profile_popups.rs:585-591).try_auth_profile_switch_after_reset_unavailable(usage_limit_reset/automatic.rs:225-240) andmaybe_auto_switch_auth_profile_before_user_turn(rate_limits.rs:409).manual_usage_limit_reset_is_active()was true (turn_runtime.rson_rate_limit_error), so with auto-reset unavailable it just scheduled a self-heal retry for the reset instant — "it just stops."#374's own PR body flagged the follow-ups this closes ("consider verifying target capacity before switching";
choose_profile_for_auto_switchpickingUnknownblindly).Fix
Drive auto-switch from the current profile's own, authoritative usage-limit / 429 turn failure, which is authoritative for the current profile (the app server rejected this profile's own turn) and cannot be produced by a sibling agent's identity-less rolling snapshot:
try_auth_profile_switch_for_usage_limit(rate_limits.rs): prefers a cached account-verified exhausted window; when none is cached, synthesizes the trigger window from the operator's enabled auto-switch config (weekly preferred, else 5h;Nonewhen both opted out so a disabled window is never switched on) and reuses the existing candidate selection (cycles to an Unknown-but-untried profile; bails — no loop — when every alternate is exhausted). The reset instant is parsed from the error text so repeated genuine failures get distinct trigger keys and can cycle across profiles.turn_runtime.rson_rate_limit_error: the reset-unavailable branch now calls it (gated to genuineUsageLimit, not transient 429/overload), dropping the too-narrowmanual_usage_limit_reset_is_active()requirement.usage_limit_reset/automatic.rsfallback_auth_profile_switch_after_reset_unavailable: also routes through it, so the auto-reset path recovers even when the authoritative read hasn't cached the exhausted window yet.Preserves #374's no-false-cascade invariant
Rolling snapshots stay strictly display-only and never seed the switch map. The new switch is reachable only from
on_rate_limit_error/ the post-error reset fallback — never fromon_rolling_rate_limit_snapshot— so a sibling agent's foreign snapshot still cannot trigger a switch.Tests (
tui/src/chatwidget/tests/status_and_layout.rs)genuine_usage_limit_error_auto_switches_without_a_cached_snapshot— the regression: exhausted current profile + auto-switch on + another profile configured + empty map -> switches.rolling_snapshot_alone_never_switches_and_never_seeds_the_switch_map— fix(auth): stop false-positive weekly-limit cascade across auth profiles #374 preserved: rolling 100% snapshot never switches and never seeds the switch map.genuine_usage_limit_error_does_not_loop_when_all_profiles_exhausted— graceful: all alternates exhausted -> no switch, no loop (twice).Verification (Blacksmith, 16 vCPU)
codex-tuinextest suite: 3478 passed, 4 skipped, 0 failed.cargo fmt --all -- --config imports_granularity=Item --check: clean (exit 0).cargo clippy -p codex-tui --tests --all-targets -- -D warnings: clean (exit 0; only a pre-existing transitive-depproc-macro-error2future-incompat note, unrelated).Do not merge/tag yet — hand-off for 0.1.78.
Review round 2 — two confirmed defects fixed (
d8e272fd0)1. The synthetic auto-switch could fire at most ONCE per session (BLOCKING).
auto_switch_trigger_keywaslimit_id:label:resets_at,resets_atrenders as the literalunknownwhenNone, and the key carried no profile.last_auth_profile_auto_switch_triggeris never cleared (only the sibling
pending_...latch is, on profile change), so a SECONDgenuine exhaustion computed a byte-identical key, was rejected by the guard, and stranded the
session on an exhausted profile while untried profiles remained.
fallback_auth_profile_switch_after_reset_unavailablewas worse: it hardcoded
error_message: None, so it could never parse a reset instant at all.Fix: triggers now carry an optional
scope(AutoSwitchTrigger); the synthetic path scopes itskey with the exhausted profile plus a monotonic per-switch epoch, so each genuine exhaustion is
its own trigger while repeats of the same exhaustion (same profile, no switch emitted in between)
still dedupe. The reset-unavailable fallback replays the usage-limit turn error
(
last_usage_limit_error_message, cleared when the selected profile changes) instead ofNone.2. Every synthetic switch was labelled
weekly(MEDIUM).The window was chosen by flag precedence (weekly first) and both flags default to
true, so 5-hourexhaustions — the common case — surfaced the wrong
AutoRateLimitwindow to the user and madeusage_health_for_snapshots/HighestAvailablerank candidates by weekly headroom instead of 5h.Fix: the label is derived from the window that actually blocked the turn — (1) an authoritative,
account-verified cached exhausted window, else (2) the advertised reset instant (a reset inside the
rolling 5h horizon can only be the 5h window; a weekly cap resets days out), else (3) 5h. A window
the operator opted out of is still never switched on.
New regression tests (all verified FAILING on the pre-fix source, passing after)
repeated_genuine_usage_limit_errors_keep_switching_to_untried_profiles— 3 profiles,auto_reset_enabled=false, unparseable reset ("try again in 3 hours"): pre-fix the secondexhaustion emitted
[], post-fix it switches tobackup.synthetic_auto_switch_window_follows_the_exhausted_window_not_the_enabled_flags— 3h reset ->5h,4d reset ->
weekly, unknown ->5h, with both flags enabled.synthetic_auto_switch_does_not_fire_for_a_window_the_operator_opted_out_ofreset_unavailable_fallback_replays_the_turn_error_to_identify_the_exhausted_windowExisting genuine-exhaustion tests now use relative reset instants so their weekly assertions stay
correct as the calendar moves. Rebased onto current
origin/main.