Skip to content

fix(auth): auto-switch profile on genuine exhaustion (regression from #374's false-cascade fix)#391

Merged
andrei-hasna merged 2 commits into
mainfrom
fix/auth-profile-autoswitch-on-real-exhaustion
Jul 25, 2026
Merged

fix(auth): auto-switch profile on genuine exhaustion (regression from #374's false-cascade fix)#391
andrei-hasna merged 2 commits into
mainfrom
fix/auth-profile-autoswitch-on-real-exhaustion

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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 rolling account/rateLimits/updated snapshots display-only and gating all exhaustion bookkeeping + auto-switch behind the account-verified RateLimitSnapshotSource::AccountUsage read (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_id and called maybe_auto_switch_auth_profile_for_rate_limit. After #374, on genuine exhaustion:

  • The switch map (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).
  • Both genuine-exhaustion fallbacks read that empty map and bail: try_auth_profile_switch_after_reset_unavailable (usage_limit_reset/automatic.rs:225-240) and maybe_auto_switch_auth_profile_before_user_turn (rate_limits.rs:409).
  • The usage-limit turn-error path only switched when manual_usage_limit_reset_is_active() was true (turn_runtime.rs on_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_switch picking Unknown blindly).

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:

  • New 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; None when 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.rs on_rate_limit_error: the reset-unavailable branch now calls it (gated to genuine UsageLimit, not transient 429/overload), dropping the too-narrow manual_usage_limit_reset_is_active() requirement.
  • usage_limit_reset/automatic.rs fallback_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 from on_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_mapfix(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)

  • Full codex-tui nextest suite: 3478 passed, 4 skipped, 0 failed.
  • 3 new tests: PASS.
  • 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-dep proc-macro-error2 future-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_key was 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. fallback_auth_profile_switch_after_reset_unavailable
was 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 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 (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 of None.

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-hour
exhaustions — the common case — surfaced the wrong AutoRateLimit window to the user and made
usage_health_for_snapshots/HighestAvailable rank 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 second
    exhaustion emitted [], post-fix it switches to backup.
  • 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_of
  • reset_unavailable_fallback_replays_the_turn_error_to_identify_the_exhausted_window

Existing genuine-exhaustion tests now use relative reset instants so their weekly assertions stay
correct as the calendar moves. Rebased onto current origin/main.

@andrei-hasna
andrei-hasna force-pushed the fix/auth-profile-autoswitch-on-real-exhaustion branch 2 times, most recently from 64f934f to ac1ca2c Compare July 24, 2026 18:37
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

Rebased onto origin/main (now on top of 86b7e78eb — includes the blacksmith-testbox.yml workflow) and force-pushed; head is now ac1ca2c6d. No conflicts; diff unchanged and still scoped to 5 files (+232/-4).

Blacksmith testbox gate: PASS — run 30117653681, blacksmith-16vcpu-ubuntu-2404, conclusion success.

build_command:

just clippy -p codex-tui --all-targets -- -D warnings && cd codex-rs && cargo nextest run -p codex-tui --no-fail-fast

Results:

  • cargo clippy --tests -p codex-tui --all-targets -- -D warnings: clean (0 warnings).
  • cargo nextest run -p codex-tui: 3488 tests run, 3488 passed, 4 skipped, 0 failed (14.4s).
  • The three new tests all PASS:
    • genuine_usage_limit_error_auto_switches_without_a_cached_snapshot
    • rolling_snapshot_alone_never_switches_and_never_seeds_the_switch_map
    • genuine_usage_limit_error_does_not_loop_when_all_profiles_exhausted
  • cargo fmt --all -- --config imports_granularity=Item --check: clean (verified locally on the rebased tree).

Not merging in this phase.

@andrei-hasna
andrei-hasna force-pushed the fix/auth-profile-autoswitch-on-real-exhaustion branch 2 times, most recently from c6c4c1c to d8e272f Compare July 25, 2026 05:04
…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
andrei-hasna force-pushed the fix/auth-profile-autoswitch-on-real-exhaustion branch from d8e272f to f1bd734 Compare July 25, 2026 05:36
@andrei-hasna
andrei-hasna merged commit 991a6b3 into main Jul 25, 2026
28 of 30 checks passed
@andrei-hasna
andrei-hasna deleted the fix/auth-profile-autoswitch-on-real-exhaustion branch July 25, 2026 06:37
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant