Skip to content

fix(usage): converge multi-machine 429 deadlock on a shared usage token - #152

Merged
realiti4 merged 6 commits into
realiti4:mainfrom
codeslake:fix/multimachine-429-deadlock
Jul 21, 2026
Merged

fix(usage): converge multi-machine 429 deadlock on a shared usage token#152
realiti4 merged 6 commits into
realiti4:mainfrom
codeslake:fix/multimachine-429-deadlock

Conversation

@codeslake

Copy link
Copy Markdown
Contributor

Problem

When several machines poll the usage endpoint for the same account, they can deadlock each other into a permanent 429 state.

The usage endpoint budgets usage requests per token over a rolling ~1h window. That budget is shared by every machine polling that account, but no machine can see the others: there is no cross-machine coordination, and the response carries no remaining-request count — only a Retry-After once you are already blocked.

Three things then compound:

  1. A fixed post-429 floor cannot converge. POST_429_MIN_INTERVAL_S puts every machine at the same interval, so N machines sum to N x the rate. If that exceeds the budget, they stay blocked indefinitely.
  2. RETRY_AFTER_FLOOR_CAP_S = 900 re-probed mid-window. The server's Retry-After counts down the full ~1h window accurately; capping the wait to 15 min meant probing again while still inside the window, and each probe re-arms the block, so the token never drains.
  3. Trust expiry turned a healthy account into an unusable target. A 429 is a polling throttle — it does not move the account's real 5h/7d windows. But TRUST_MAX_AGE_S flipped a throttled account's last_good to "unknown" while it was plainly fine (e.g. 21% used), which drove failover flapping and "all accounts exhausted" sleeps. The flapping then re-polled the throttled token and re-armed the block.

Fix

AIMD on a contended token (poll_policy.py). While 429s recur, each successful poll grows the interval multiplicatively (POST_429_BACKOFF_MULT = 1.5) toward a wider ceiling (POST_429_MAX_INTERVAL_S = 1800), so machines sharing a token each retreat until their combined rate fits the budget. Movement decays it back down. This is TCP-style congestion control: fair-sharing by reaction alone, with no machine count and no shared state to configure.

Honor hour-scale Retry-After (usage_store.py). RETRY_AFTER_FLOOR_CAP_S 900 -> 3600, so the wait spans the endpoint's actual window instead of re-arming it. Still bounded to one window, so a pathological header can never park an account for hours.

Trust 429-stale data until its window resets (usage_store.py). Usage rises monotonically within a window, so a frozen last_good is a valid lower bound on true usage right up to that window's reset. Trust it until then — data-driven, not a fixed clock. Once every window has reset the value is obsolete (usage was zeroed) and reads unknown. Rows with no resets_at fall back to a bounded RATE_LIMIT_TRUST_MAX_AGE_S. Non-429 failures are unchanged: a timeout or network error is no evidence last_good still holds.

This last part mirrors Claude Code 2.1.208, which shows last-known usage bars when /usage is rate-limited rather than erroring.

Scope

  • src/claude_swap/poll_policy.py +21/-1
  • src/claude_swap/usage_store.py +75/-4
  • tests/ +123

No new configuration, no new dependencies, no platform-specific code. The constants are derived from the endpoint's observed behavior (the ~1h rolling window), not from any particular setup.

Testing

  • 7 new tests covering AIMD growth, the ceiling, the reset-driven trust window, and the no-resets_at fallback.
  • tests/test_poll_policy.py + tests/test_usage_store.py: 83 passed.
  • Full suite: 1420 passed, same 17 pre-existing failures as main at 1964097 (all in test_switcher.py, ANSI-color assertions unrelated to this change) — no regressions.

Observed in practice across two machines sharing one account: the 429 oscillation stops and the pollers settle at staggered intervals.


🤖 PR description and upstream rebase prepared with Claude Code; the fix itself was authored and field-tested by the submitter.

…scale Retry-After

A usage-endpoint 429 is a polling throttle, not a quota change: a throttled
candidate's windows don't move while blocked, so its last_good stays accurate.
Flipping it to unknown at TRUST_MAX_AGE_S made it an unusable switch target and
drove failover flapping (which re-polled the throttled token and re-armed the
block). Trust 429-stale last_good up to a wider, still client-bounded ceiling
(RATE_LIMIT_TRUST_MAX_AGE_S); non-429 failures keep TRUST_MAX_AGE_S.

Also raise RETRY_AFTER_FLOOR_CAP_S 900->3600: the server's Retry-After counts
down the endpoint's ~1h rolling window accurately, and capping it to minutes
meant re-probing mid-window, which re-arms the block and never lets the token
drain. Still bounded to one window.
…vergence)

The usage endpoint's per-token budget is shared across every machine polling
the same account, with no cross-machine coordination and no remaining-count in
the response (only a Retry-After once blocked). A fixed POST_429 floor can't
converge: N machines at the floor sum to N x the rate. While 429s recur, grow
the interval multiplicatively (POST_429_BACKOFF_MULT) toward a wider ceiling
(POST_429_MAX_INTERVAL_S) so machines sharing a token each retreat until their
combined rate fits the budget -- TCP-style congestion control, no config.
…ixed clock

The 2h RATE_LIMIT_TRUST_MAX_AGE_S ceiling flipped a rate-limited active
account to "unknown" while it was plainly fine (e.g. 21% used), which drove
failover flapping and "all accounts exhausted" sleeps — exactly the deadlock
this branch targets, just relocated. Replace the fixed clock with the natural,
data-driven bound: usage is monotone within a window, so a frozen last_good is
a valid lower bound until that window resets. Trust 429-stale last_good until
the last of its window resets is reached; once every window has reset the value
is obsolete (usage was zeroed) and reads unknown. Rows with no resets_at fall
back to the bounded RATE_LIMIT_TRUST_MAX_AGE_S. Non-429 failures unchanged.

Mirrors Claude Code 2.1.208 (/usage shows last-known bars when the endpoint is
rate-limited instead of erroring).
@realiti4

realiti4 commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Hey, thanks for this — and for field-testing it before submitting. Interesting timing: there have been similar hour-scale Retry-After regime while investigating #146 last week (it appears to have changed server-side around July 16), so your report usefully corroborates that observation.

I'd like to get parts of this in, but I found a few things while testing that need adjusting first:

The Retry-After cap raise (900 → 3600) — happy to take this as-is, with one correction to the comments: our measurements show probing does not re-arm the block. We have three probes from one episode whose Retry-After values all count down to the identical wall-clock deadline. The raise is still worth it (it stops wasting ~3 probes per block), but the "re-arming" rationale would enshrine a wrong model of the limiter, so could you reword those comments?

The AIMD backoff — there's an integration problem, and it's caused by the cap raise itself: recent_429 is checked strictly against RECENT_429_WINDOW_S (3600s), and last429At only gets stamped on failed attempts. Once a 3600s Retry-After is honored, there's a single stamp and then no attempts for exactly 3600s — so the first post-block success sees recent_429=False and the growth never engages. It actually loses the existing 360s floor too (on main, the mid-block re-probes kept re-stamping last429At). The unit tests pass recent_429=True directly, which is why they don't catch it. I think the fix is to make the 429 memory survive the honored backoff (e.g. stamp at backoff expiry, or compare against it), plus one test that goes through the store:

429 with Retry-After: 3600
→ advance clock
→ successful fetch
→ assert the floor/growth apply

The trust extension — the flapping problem you're fixing is real, but trusting until max(reset_ts) means a frozen snapshot can drive decisions until the 7-day reset, and there's no client-side ceiling at all — a malformed far-future resets_at would control trust indefinitely. There's also a hole with partial metadata: if 5h has no resets_at but 7d does, the 7200s fallback never applies — and 5h-without-resets_at is a shape the server actually sends right now (came up in #146). Could you clamp it to the earliest future reset with a bounded ceiling (the existing RATE_LIMIT_TRUST_MAX_AGE_S would do), and pass the configured models into relevant_windows? FWIW I made agent scan Claude Code 2.1.215 binary — the stale-fallback behavior you cite is real, but it's bounded at exactly 1h there too.

One question out of curiosity, because it would settle something we've been trying to pin down: do your two machines use the same exported/imported credentials, or did each log in separately? If separate logins were contending on one account, that tells us the endpoint's budget is no longer strictly per-token, which changes our cadence math.

The AIMD floor/growth keys on recent_429, computed in
switcher._persist_poll_plans as (now - last429At) < RECENT_429_WINDOW_S.
last429At is stamped only on a failed attempt. Once the Retry-After cap was
raised to 3600s (so an hour-scale block is honored as one backoff), the block
produces exactly one stamp and then no attempt runs until it lifts at
t0+3600 — and RECENT_429_WINDOW_S is also 3600, so the first post-block
success sees (3600 < 3600) == False. Both the AIMD multiplicative growth and
the POST_429 floor silently stop engaging: the interval can never exceed the
narrow candidate ceiling (600s), so N machines sharing a token jam there and
never converge under the budget. On main this was masked because the 900s cap
re-probed mid-window and each 429 re-stamped last429At.

Fix: anchor recency on when the 429's backoff *lifts*, not on the 429 stamp.
UsageEntry.recent_429(now) returns now < max(last429At, backoffUntil) +
RECENT_429_WINDOW_S, so the first post-block success still counts as recent
(AIMD/floor engage) while a short Retry-After: 0 block still expires normally.
switcher._persist_poll_plans now calls before.recent_429(now).

Tests: store-through reproduction (429 RA=3600 → advance to backoff expiry →
success → floor engages), the short-block-still-expires case, and a
deterministic convergence trajectory proving the interval grows to
POST_429_MAX_INTERVAL_S with recency and is capped at CANDIDATE_MAX_INTERVAL_S
without it (the deadlock the fix breaks). The old unit tests passed
recent_429 directly and so never exercised this timing.
_rate_limited_trust_ok trusted a 429-stale last_good until max(reset_ts) with
no client-side ceiling, so: (a) a frozen snapshot could drive decisions until
the 7-day reset; (b) a malformed/far-future resets_at controlled trust
indefinitely; (c) with partial metadata (5h has no resets_at but 7d does — a
shape the server actually sends) the bounded fallback never applied; and
(d) scoped per-model windows were invisible because models was not passed to
relevant_windows.

Fix (per review): trust only until now < min(earliest relevant-window reset,
age-ceiling), where the ceiling is RATE_LIMIT_TRUST_MAX_AGE_S past last_good.
The earliest reset is used (any window rolling over invalidates the snapshot);
a window missing resets_at contributes no timestamp so it can only tighten the
bound; and the ceiling always applies, so a far-future/malformed reset can
never grant unbounded trust. entries() now threads the configured models into
the trust check (and thus relevant_windows) from the two decision call sites.

Tests: far-future reset clamped to the ceiling; trust ends at the earliest of
two resets; partial metadata still ceiling-bounded; ceiling wins when the
reset is beyond it.
The comment on RETRY_AFTER_FLOOR_CAP_S (and the mirroring test comment) claimed
that re-probing mid-window "re-arms the block and prevents the token from ever
draining." Measurements show the opposite: the server's Retry-After counts down
to a fixed wall-clock deadline and is not extended by probing (three probes in
one episode all reported the same deadline). Reword to the correct model: the
old 900s cap simply wasted two or three probe requests inside a block that was
going to last the full window anyway; honoring the whole Retry-After spends one
request per block instead. No behavior change.

@codeslake codeslake left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the thorough review — all on point. Pushed three commits (on top of the current head), green (NO_COLOR=1 pytest, 1450 passed / 3 skipped). In order:

1. Retry-After cap comment (7d682a0) — reworded to the measured model: the server's Retry-After counts down to a fixed wall-clock deadline and is not re-armed by probing; the old 900s cap just wasted ~3 probes per block. No behavior change.

2. AIMD integration bug (ded82e7) — your diagnosis was exactly right. Fix: recency is now anchored on when the 429 backoff lifts, not the stamp, via UsageEntry.recent_429(now) — so the first post-block success still sees recent_429=True and the growth/floor engage. A last_error == "http-429" guard keeps an unrelated timeout from re-arming it (caught in self-review; regressioned). Store-level tests, as you asked: test_recent_429_true_at_first_success_after_hour_block, test_floor_engages_at_first_post_block_success (+ a test_legacy_recency_would_drop_the_floor documenting the old defect), test_repeated_429_episodes_converge_to_the_wide_ceiling, test_unrelated_timeout_does_not_re_arm_recency.

3. Trust bound (dfb47a5) — now now < min(earliest relevant-window reset, fetched_at + RATE_LIMIT_TRUST_MAX_AGE_S): earliest reset not max; the ceiling always applies (kills far-future/malformed resets_at); a window missing resets_at can only tighten, never loosen (the partial-metadata shape you flagged); and models is now threaded into relevant_windows. Tests: test_far_future_reset_is_clamped_to_the_ceiling, test_trust_keys_on_earliest_future_reset, test_partial_metadata_still_bounded_by_ceiling, test_ceiling_wins_when_reset_is_beyond_it.

4. Same vs separate tokens — I fingerprint-compared the two machines. On the account that deadlocked the refresh tokens are different (separate logins), and a control account where I do sync came back byte-identical, so it's a real case of two distinct tokens on one account → the budget is not strictly per-token. You were right.

I deliberately kept the fix agnostic to this, though, since the answer isn't stable (sync vs separate login can change over time). plan_after_fetch never inspects token identity — it reacts only to recent_429, and AIMD converges for any pollers sharing any throttled counter, per-token or per-account alike. What does change with per-account is the constant tuning (the ~28-30/hr numbers are single-machine), not the mechanism; POST_429_MAX_INTERVAL_S is the knob to widen for larger fleets. TestBudgetInvariants is left as-is — the N>1 margin is reasoned, not measured.


Separate follow-up (out of scope here): CC 2.1.216 no longer polls /api/oauth/usage for its own numbers — it reads anthropic-ratelimit-unified-{5h,7d}-utilization/-reset off the /v1/messages response headers. cswap can't do that directly (it sends no messages), but a forward-proxy in front of CC already captures those headers to disk (e.g. the claude-code-cache-fix usage-log extension, #251, appends them to ~/.claude/usage.jsonl). So cswap could prefer that header-sourced usage and poll only as fallback — pushing the endpoint rate toward zero.

Big caveat, which is why it's not this PR: the headers only ever describe the account whose session is sending messages. The moment cswap switches away, that account stops emitting /v1/messages, so its header stream goes stale — and the candidate accounts cswap most needs fresh numbers for (to pick the next target) never send messages at all. So header-sourcing can cover the active account but the candidates still need polling; it shrinks the 429 surface rather than eliminating it, and it needs a stable path/schema contract so cswap doesn't couple to a proxy's internals. Happy to open an issue if it's of interest.


Both machines are running these commits now; I'll report back if the deadlock recurs.

🤖 Generated with Claude Code

@realiti4
realiti4 merged commit 75520e4 into realiti4:main Jul 21, 2026
3 checks passed
@codeslake
codeslake deleted the fix/multimachine-429-deadlock branch July 21, 2026 17:29
@realiti4

realiti4 commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Thanks a lot for the adjustments, it looks good and I've merged it.

And thanks for the token fingerprint comparison, so it looks like budget is per-account now.

Thanks for the header-sourcing pointer too. I'll be looking into that side myself as well. While poking at recent binaries it looks like Claude Code also persists a last-known usage snapshot client-side (in ~/.claude.json), but it's only written when usage is actually viewed via command etc., so it's free opportunistic reading for the active account rather than something to rely on; agreed either way that candidate accounts will still need polling.

I'm curious to see how it behaves across both machines too, so please do.

@codeslake

Copy link
Copy Markdown
Contributor Author

Both machines are on the merged build now (0.23.0b1, identical usage_store), and I've armed a watch for the next hour-scale 429 episode. None since deploy, so nothing to report yet, but I'll post what the AIMD floor/growth actually does the first time it fires across both.

On the ~/.claude.json snapshot: agree it's opportunistic-only — active account, refreshed just on an explicit usage view, so candidates still fall back to polling either way. And since cswap sends no /v1/messages of its own, header-sourcing doesn't help the candidate side for us either. Polling stays the floor.

Will circle back with the cross-machine numbers once it fires — thanks for the quick review and merge.

@codeslake

Copy link
Copy Markdown
Contributor Author

Field report from the two machines, as promised. Merged build (0.23.0b1, identical usage_store) on a Linux server and a Mac since 07-20, ~32h of continuous state sampling (cache/usage.json every 60s on both, ~3.5k samples) plus token-fingerprint sampling every 300s.

1. The integration bug you flagged is fixed in practice

The case you predicted — a 3600s Retry-After honored, then the first post-block success sees recent_429=False so growth never engages — engages now. One episode, verbatim from the sampler:

02:08  429 #1, Retry-After 3600 → honored to 03:08
03:12  first retry after the block → 429 #2
04:16  success → interval 600 → 900     ← past CANDIDATE_MAX, growth engaged
04:34  429 #3
05:35  429 #4
06:41  success → interval 900 → 1350    ← growth continued
07:04  recency window lapsed → back to 600

Pre-fix this account would have sat at 600 and re-probed on the old cadence. Floor/growth survives the honored block exactly as the store-level test asserts.

Equally important: no 429 storm and no flapping through four consecutive blocks. Each block was honored to the second, exactly one probe per block, and the peer machine kept polling the same account normally throughout. The deadlock this PR set out to fix did not reappear.

Provenance caveat: those 429s were self-inflicted. I was debugging an unrelated proxy on the Mac and its test processes hammered the usage endpoint with that account's token. Useful as a stimulus, not evidence about the endpoint's natural 429 rate. The Linux box, polling 180-600s and never loaded that way, saw zero 429s in 32h.

2. Two token-scope observations

Usage-poll limit looks per-token, not per-account. During the episode above, at 03:12:09 the Mac got a 429 for account A while 8 seconds later, at 03:12:17, the Linux box fetched the same account A successfully. Same account, both machines, tokens already diverged. So the poll budget appears token-scoped while the message budget is account-scoped (the fingerprint comparison from last time) — different layers, probably worth separating in the cadence model.

Lineage diverges on its own. Refresh rotates the refresh token, so two machines starting from one exported credential stop sharing a lineage at the first refresh (three generations over 32h, ~8h apart). A re-login is distinguishable from a routine rotation by the refresh-token expiry: rotation preserves it, re-login resets it ~4 weeks out.

3. Two credential experiments

A — does a re-login revoke the peer's grant? No. Ran /login on Linux at 13:37, minting a fresh lineage; the Mac kept polling the same account successfully on its old grant six minutes later, authDeadStrikes=0. Grants are independent, so re-authing one box does not knock its peer offline. (Distinct from the reuse case in #164, where presenting a superseded token from the same lineage is what appears to revoke it.)

B — same credential live on two machines. Synced the Linux credential into the Mac keychain so both held one lineage, then watched. What surfaced was not a rotation race but a stall, and it is worth reporting precisely:

14:03  sync — both machines hold lineage L; cswap's own backup for that slot still holds L-1
21:37  access token expires
       cswap: an owner (a live Claude Code) is present → defers, does not refresh
       Claude Code: idle, issues no API request → does not refresh
       daemon: "headless daemon cannot complete OAuth", reschedules ~4h out
       → nobody refreshes; the access token stays dead
21:51  autoswitch: usage unknown ×3 → failover to the other account
       log: "Backed up account 2 (lineage differs from the stored backup and
             ownership could not be verified — pre-fix backup)"
00:56  I manually switch back to that account
00:57  cswap switches away again, 56 seconds later, same reason

The chain is: a dead access token makes fetch_oauth_profile return 401, so the identity oracle cannot resolve ownership, so _classify_outgoing_credential falls to unresolved, so the switch takes the conservative pre-fix backup — and the next tick repeats it. The account was healthy the whole time; its refresh token was never dead. cswap recover (#165/#166) addresses the manual side of this, and deliberately leaves auto/watch alone, so the automatic path still oscillates.

Note the divergence in the first line: the lineage mismatch is not exotic. Any cross-machine credential sync produces it — my own sync tooling writes the live keychain item and leaves cswap's backup to "resync on the next switch", and #164's export/import path reaches the same state from the other direction. Once cswap's backup and the live credential disagree, the only thing that can re-attribute them is the profile endpoint, which needs a live access token — which is exactly what nobody refreshed.

I am not proposing anything here; #164 already covers the export/import side thoroughly and #166 covers the manual recovery. Flagging the runtime piece in case it is useful context for either.

4. network / timeout never re-arms 429 recency

The Mac hit 8 consecutive network failures over ~40 minutes (its uplink, not yours). Throughout, last429At stayed untouched — 102h old on one account, None on the other — and the interval never moved to the post-429 floor. test_unrelated_timeout_does_not_re_arm_recency holding under real sustained failure rather than a fixture.

5. Unreported, will file separately

claude_locks.py targets ~/.claude.lock with a 10s staleness. Claude Code 2.1.218 guards its OAuth refresh with two locks — a new <config-home>/.oauth_refresh.lock plus the legacy ~/.claude.lock it still takes for compatibility — both at stale: 60000, update: 5000, with a dedicated tengu_oauth_refresh_legacy_lock_contended counter on the legacy path. Mutual exclusion holds today only because of that shim, and the 10s-vs-60s gap means cswap can take over a lock Claude Code still considers held if its toucher stalls. Opening that separately with the binary evidence rather than burying it here.

Samplers stay running; will report anything organic.

@realiti4

Copy link
Copy Markdown
Owner

Thanks a lot, the report looks good. I didn’t want to hold up the fixes, so I also released v0.23.0.

I’ll be away from my computer for a couple of days, but I’ll dig into it properly when I get back.

codeslake added a commit to codeslake/claude-swap that referenced this pull request Jul 27, 2026
Design doc only — implementation follows. Grew out of the PR realiti4#152 field
measurements: idle-expiry stall, the 56s unresolved-classifier bounce, the
destroyed slot backup, and the CC 2.1.218 lock-protocol findings
(.oauth_refresh.lock + legacy lock, stale 60s; race-resolved adoption of
external rotations).

Co-Authored-By: Claude <noreply@anthropic.com>
codeslake added a commit to codeslake/claude-swap that referenced this pull request Jul 29, 2026
Follow-up observation on realiti4#152, from its own post-merge data.

realiti4#152 raised RETRY_AFTER_FLOOR_CAP_S 900 -> 3600 so we stop re-probing
inside a block that lasts the full window. That was right, and this
keeps it. What it left is the block BOUNDARY: honoring Retry-After
exactly puts the retry on the deadline itself, where the server is not
reliably ready.

Measured over one machine's whole log (511 http-429 lines -> 39 deadline
blocks; 37 of 39 opened at exactly Retry-After 3600, so the penalty is a
fixed hour armed at violation, not capacity aging out). Of the 19
post-fix block lapses that were followed by another 429, 10 re-blocked
within 900s of their own deadline:

  +2s +3s +3s +71s +79s +101s +337s +346s +714s +716s

and each earned a fresh full-hour penalty. The next one after that is
+3853s, so the distribution is bimodal with an empty band between.
2026-07-28 was an 11th instance: backoffUntil 22:30:16, next 429 at
22:30:19, deadline +3s. cswap had made one request in the trailing hour
at that point, so this is not a request-volume problem and no local rate
counter would have refused it.

Wait Retry-After * (1 + RETRY_AFTER_MARGIN_FRAC) so the retry lands in
that empty band. Proportional rather than a flat 900s because the
re-block evidence is entirely hour-scale, while short blocks were
measured separately (2026-07-06) as accurate: a flat margin would
inflate a 300s block 4x on no evidence, and would also let a 90s server
ask overtake our own saturated failure curve. 0.25 * 3600 reproduces the
measured 900s without extrapolating onto short blocks.

Costs one wait per block, not per probe, since the deadline is fixed and
not re-armed by probing.

Also correct the documented budget scope. poll_policy said
"per-access-token"; on 2026-07-28 a freshly minted token was blocked
135s after issue, which a per-token counter cannot produce. The scope is
the account/org, so re-authenticating does not clear a block and two
machines holding different tokens for one account still compete - which
is what realiti4#152's AIMD is for. Nothing here depends on the scope: the wait
comes from the server's own deadline.

Co-Authored-By: Claude <noreply@anthropic.com>
codeslake added a commit to codeslake/claude-swap that referenced this pull request Jul 29, 2026
Follow-up observation on realiti4#152, from its own post-merge data.

realiti4#152 raised RETRY_AFTER_FLOOR_CAP_S 900 -> 3600 so we stop re-probing
inside a block that lasts the full window. That was right, and this
keeps it. What it left is the block BOUNDARY: honoring Retry-After
exactly puts the retry on the deadline itself, where the server is not
reliably ready.

Measured over one machine's whole log (511 http-429 lines -> 39 deadline
blocks; 37 of 39 opened at exactly Retry-After 3600, so the penalty is a
fixed hour armed at violation, not capacity aging out). Of the 19
post-fix block lapses that were followed by another 429, 10 re-blocked
within 900s of their own deadline:

  +2s +3s +3s +71s +79s +101s +337s +346s +714s +716s

and each earned a fresh full-hour penalty. The next one after that is
+3853s, so the distribution is bimodal with an empty band between.
2026-07-28 was an 11th instance: backoffUntil 22:30:16, next 429 at
22:30:19, deadline +3s. cswap had made one request in the trailing hour
at that point, so this is not a request-volume problem and no local rate
counter would have refused it.

Wait Retry-After * (1 + RETRY_AFTER_MARGIN_FRAC) so the retry lands in
that empty band. Proportional rather than a flat 900s because the
re-block evidence is entirely hour-scale, while short blocks were
measured separately (2026-07-06) as accurate: a flat margin would
inflate a 300s block 4x on no evidence, and would also let a 90s server
ask overtake our own saturated failure curve. 0.25 * 3600 reproduces the
measured 900s without extrapolating onto short blocks.

Costs one wait per block, not per probe, since the deadline is fixed and
not re-armed by probing.

Also correct the documented budget scope. poll_policy said
"per-access-token"; on 2026-07-28 a freshly minted token was blocked
135s after issue, which a per-token counter cannot produce. The scope is
the account/org, so re-authenticating does not clear a block and two
machines holding different tokens for one account still compete - which
is what realiti4#152's AIMD is for. Nothing here depends on the scope: the wait
comes from the server's own deadline.

Co-Authored-By: Claude <noreply@anthropic.com>
codeslake added a commit to codeslake/claude-swap that referenced this pull request Jul 29, 2026
Follow-up observation on realiti4#152, from its own post-merge data.

realiti4#152 raised RETRY_AFTER_FLOOR_CAP_S 900 -> 3600 so we stop re-probing
inside a block that lasts the full window. That was right, and this
keeps it. What it left is the block BOUNDARY: honoring Retry-After
exactly puts the retry on the deadline itself, where the server is not
reliably ready.

Measured over one machine's whole log (511 http-429 lines -> 39 deadline
blocks; 37 of 39 opened at exactly Retry-After 3600, so the penalty is a
fixed hour armed at violation, not capacity aging out). Of the 19
post-fix block lapses that were followed by another 429, 10 re-blocked
within 900s of their own deadline:

  +2s +3s +3s +71s +79s +101s +337s +346s +714s +716s

and each earned a fresh full-hour penalty. The next one after that is
+3853s, so the distribution is bimodal with an empty band between.
2026-07-28 was an 11th instance: backoffUntil 22:30:16, next 429 at
22:30:19, deadline +3s. cswap had made one request in the trailing hour
at that point, so this is not a request-volume problem and no local rate
counter would have refused it.

Wait Retry-After * (1 + RETRY_AFTER_MARGIN_FRAC) so the retry lands in
that empty band. Proportional rather than a flat 900s because the
re-block evidence is entirely hour-scale, while short blocks were
measured separately (2026-07-06) as accurate: a flat margin would
inflate a 300s block 4x on no evidence, and would also let a 90s server
ask overtake our own saturated failure curve. 0.25 * 3600 reproduces the
measured 900s without extrapolating onto short blocks.

Costs one wait per block, not per probe, since the deadline is fixed and
not re-armed by probing.

Also correct the documented budget scope. poll_policy said
"per-access-token"; on 2026-07-28 a freshly minted token was blocked
135s after issue, which a per-token counter cannot produce. The scope is
the account/org, so re-authenticating does not clear a block and two
machines holding different tokens for one account still compete - which
is what realiti4#152's AIMD is for. Nothing here depends on the scope: the wait
comes from the server's own deadline.

Co-Authored-By: Claude <noreply@anthropic.com>
codeslake added a commit to codeslake/claude-swap that referenced this pull request Jul 30, 2026
Follow-up observation on realiti4#152, from its own post-merge data.

realiti4#152 raised RETRY_AFTER_FLOOR_CAP_S 900 -> 3600 so we stop re-probing
inside a block that lasts the full window. That was right, and this
keeps it. What it left is the block BOUNDARY: honoring Retry-After
exactly puts the retry on the deadline itself, where the server is not
reliably ready.

Measured over one machine's whole log (511 http-429 lines -> 39 deadline
blocks; 37 of 39 opened at exactly Retry-After 3600, so the penalty is a
fixed hour armed at violation, not capacity aging out). Of the 19
post-fix block lapses that were followed by another 429, 10 re-blocked
within 900s of their own deadline:

  +2s +3s +3s +71s +79s +101s +337s +346s +714s +716s

and each earned a fresh full-hour penalty. The next one after that is
+3853s, so the distribution is bimodal with an empty band between.
2026-07-28 was an 11th instance: backoffUntil 22:30:16, next 429 at
22:30:19, deadline +3s. cswap had made one request in the trailing hour
at that point, so this is not a request-volume problem and no local rate
counter would have refused it.

Wait Retry-After * (1 + RETRY_AFTER_MARGIN_FRAC) so the retry lands in
that empty band. Proportional rather than a flat 900s because the
re-block evidence is entirely hour-scale, while short blocks were
measured separately (2026-07-06) as accurate: a flat margin would
inflate a 300s block 4x on no evidence, and would also let a 90s server
ask overtake our own saturated failure curve. 0.25 * 3600 reproduces the
measured 900s without extrapolating onto short blocks.

Costs one wait per block, not per probe, since the deadline is fixed and
not re-armed by probing.

Also correct the documented budget scope. poll_policy said
"per-access-token"; on 2026-07-28 a freshly minted token was blocked
135s after issue, which a per-token counter cannot produce. The scope is
the account/org, so re-authenticating does not clear a block and two
machines holding different tokens for one account still compete - which
is what realiti4#152's AIMD is for. Nothing here depends on the scope: the wait
comes from the server's own deadline.

Co-Authored-By: Claude <noreply@anthropic.com>
codeslake added a commit to codeslake/claude-swap that referenced this pull request Jul 30, 2026
Follow-up observation on realiti4#152, from its own post-merge data.

realiti4#152 raised RETRY_AFTER_FLOOR_CAP_S 900 -> 3600 so we stop re-probing
inside a block that lasts the full window. That was right, and this
keeps it. What it left is the block BOUNDARY: honoring Retry-After
exactly puts the retry on the deadline itself, where the server is not
reliably ready.

Measured over one machine's whole log (511 http-429 lines -> 39 deadline
blocks; 37 of 39 opened at exactly Retry-After 3600, so the penalty is a
fixed hour armed at violation, not capacity aging out). Of the 19
post-fix block lapses that were followed by another 429, 10 re-blocked
within 900s of their own deadline:

  +2s +3s +3s +71s +79s +101s +337s +346s +714s +716s

and each earned a fresh full-hour penalty. The next one after that is
+3853s, so the distribution is bimodal with an empty band between.
2026-07-28 was an 11th instance: backoffUntil 22:30:16, next 429 at
22:30:19, deadline +3s. cswap had made one request in the trailing hour
at that point, so this is not a request-volume problem and no local rate
counter would have refused it.

Wait Retry-After * (1 + RETRY_AFTER_MARGIN_FRAC) so the retry lands in
that empty band. Proportional rather than a flat 900s because the
re-block evidence is entirely hour-scale, while short blocks were
measured separately (2026-07-06) as accurate: a flat margin would
inflate a 300s block 4x on no evidence, and would also let a 90s server
ask overtake our own saturated failure curve. 0.25 * 3600 reproduces the
measured 900s without extrapolating onto short blocks.

Costs one wait per block, not per probe, since the deadline is fixed and
not re-armed by probing.

Also correct the documented budget scope. poll_policy said
"per-access-token"; on 2026-07-28 a freshly minted token was blocked
135s after issue, which a per-token counter cannot produce. The scope is
the account/org, so re-authenticating does not clear a block and two
machines holding different tokens for one account still compete - which
is what realiti4#152's AIMD is for. Nothing here depends on the scope: the wait
comes from the server's own deadline.

Co-Authored-By: Claude <noreply@anthropic.com>
codeslake added a commit to codeslake/claude-swap that referenced this pull request Jul 30, 2026
Follow-up observation on realiti4#152, from its own post-merge data.

realiti4#152 raised RETRY_AFTER_FLOOR_CAP_S 900 -> 3600 so we stop re-probing
inside a block that lasts the full window. That was right, and this
keeps it. What it left is the block BOUNDARY: honoring Retry-After
exactly puts the retry on the deadline itself, where the server is not
reliably ready.

Measured over one machine's whole log (511 http-429 lines -> 39 deadline
blocks; 37 of 39 opened at exactly Retry-After 3600, so the penalty is a
fixed hour armed at violation, not capacity aging out). Of the 19
post-fix block lapses that were followed by another 429, 10 re-blocked
within 900s of their own deadline:

  +2s +3s +3s +71s +79s +101s +337s +346s +714s +716s

and each earned a fresh full-hour penalty. The next one after that is
+3853s, so the distribution is bimodal with an empty band between.
2026-07-28 was an 11th instance: backoffUntil 22:30:16, next 429 at
22:30:19, deadline +3s. cswap had made one request in the trailing hour
at that point, so this is not a request-volume problem and no local rate
counter would have refused it.

Wait Retry-After * (1 + RETRY_AFTER_MARGIN_FRAC) so the retry lands in
that empty band. Proportional rather than a flat 900s because the
re-block evidence is entirely hour-scale, while short blocks were
measured separately (2026-07-06) as accurate: a flat margin would
inflate a 300s block 4x on no evidence, and would also let a 90s server
ask overtake our own saturated failure curve. 0.25 * 3600 reproduces the
measured 900s without extrapolating onto short blocks.

Costs one wait per block, not per probe, since the deadline is fixed and
not re-armed by probing.

Also correct the documented budget scope. poll_policy said
"per-access-token"; on 2026-07-28 a freshly minted token was blocked
135s after issue, which a per-token counter cannot produce. The scope is
the account/org, so re-authenticating does not clear a block and two
machines holding different tokens for one account still compete - which
is what realiti4#152's AIMD is for. Nothing here depends on the scope: the wait
comes from the server's own deadline.

Co-Authored-By: Claude <noreply@anthropic.com>
codeslake added a commit to codeslake/claude-swap that referenced this pull request Jul 30, 2026
Follow-up observation on realiti4#152, from its own post-merge data.

realiti4#152 raised RETRY_AFTER_FLOOR_CAP_S 900 -> 3600 so we stop re-probing
inside a block that lasts the full window. That was right, and this
keeps it. What it left is the block BOUNDARY: honoring Retry-After
exactly puts the retry on the deadline itself, where the server is not
reliably ready.

Measured over one machine's whole log (511 http-429 lines -> 39 deadline
blocks; 37 of 39 opened at exactly Retry-After 3600, so the penalty is a
fixed hour armed at violation, not capacity aging out). Of the 19
post-fix block lapses that were followed by another 429, 10 re-blocked
within 900s of their own deadline:

  +2s +3s +3s +71s +79s +101s +337s +346s +714s +716s

and each earned a fresh full-hour penalty. The next one after that is
+3853s, so the distribution is bimodal with an empty band between.
2026-07-28 was an 11th instance: backoffUntil 22:30:16, next 429 at
22:30:19, deadline +3s. cswap had made one request in the trailing hour
at that point, so this is not a request-volume problem and no local rate
counter would have refused it.

Wait Retry-After * (1 + RETRY_AFTER_MARGIN_FRAC) so the retry lands in
that empty band. Proportional rather than a flat 900s because the
re-block evidence is entirely hour-scale, while short blocks were
measured separately (2026-07-06) as accurate: a flat margin would
inflate a 300s block 4x on no evidence, and would also let a 90s server
ask overtake our own saturated failure curve. 0.25 * 3600 reproduces the
measured 900s without extrapolating onto short blocks.

Costs one wait per block, not per probe, since the deadline is fixed and
not re-armed by probing.

Also correct the documented budget scope. poll_policy said
"per-access-token"; on 2026-07-28 a freshly minted token was blocked
135s after issue, which a per-token counter cannot produce. The scope is
the account/org, so re-authenticating does not clear a block and two
machines holding different tokens for one account still compete - which
is what realiti4#152's AIMD is for. Nothing here depends on the scope: the wait
comes from the server's own deadline.

Co-Authored-By: Claude <noreply@anthropic.com>
codeslake added a commit to codeslake/claude-swap that referenced this pull request Aug 1, 2026
Follow-up observation on realiti4#152, from its own post-merge data.

realiti4#152 raised RETRY_AFTER_FLOOR_CAP_S 900 -> 3600 so we stop re-probing
inside a block that lasts the full window. That was right, and this
keeps it. What it left is the block BOUNDARY: honoring Retry-After
exactly puts the retry on the deadline itself, where the server is not
reliably ready.

Measured over one machine's whole log (511 http-429 lines -> 39 deadline
blocks; 37 of 39 opened at exactly Retry-After 3600, so the penalty is a
fixed hour armed at violation, not capacity aging out). Of the 19
post-fix block lapses that were followed by another 429, 10 re-blocked
within 900s of their own deadline:

  +2s +3s +3s +71s +79s +101s +337s +346s +714s +716s

and each earned a fresh full-hour penalty. The next one after that is
+3853s, so the distribution is bimodal with an empty band between.
2026-07-28 was an 11th instance: backoffUntil 22:30:16, next 429 at
22:30:19, deadline +3s. cswap had made one request in the trailing hour
at that point, so this is not a request-volume problem and no local rate
counter would have refused it.

Wait Retry-After * (1 + RETRY_AFTER_MARGIN_FRAC) so the retry lands in
that empty band. Proportional rather than a flat 900s because the
re-block evidence is entirely hour-scale, while short blocks were
measured separately (2026-07-06) as accurate: a flat margin would
inflate a 300s block 4x on no evidence, and would also let a 90s server
ask overtake our own saturated failure curve. 0.25 * 3600 reproduces the
measured 900s without extrapolating onto short blocks.

Costs one wait per block, not per probe, since the deadline is fixed and
not re-armed by probing.

Also correct the documented budget scope. poll_policy said
"per-access-token"; on 2026-07-28 a freshly minted token was blocked
135s after issue, which a per-token counter cannot produce. The scope is
the account/org, so re-authenticating does not clear a block and two
machines holding different tokens for one account still compete - which
is what realiti4#152's AIMD is for. Nothing here depends on the scope: the wait
comes from the server's own deadline.

Co-Authored-By: Claude <noreply@anthropic.com>
codeslake added a commit to codeslake/claude-swap that referenced this pull request Aug 4, 2026
Follow-up observation on realiti4#152, from its own post-merge data.

realiti4#152 raised RETRY_AFTER_FLOOR_CAP_S 900 -> 3600 so we stop re-probing
inside a block that lasts the full window. That was right, and this
keeps it. What it left is the block BOUNDARY: honoring Retry-After
exactly puts the retry on the deadline itself, where the server is not
reliably ready.

Measured over one machine's whole log (511 http-429 lines -> 39 deadline
blocks; 37 of 39 opened at exactly Retry-After 3600, so the penalty is a
fixed hour armed at violation, not capacity aging out). Of the 19
post-fix block lapses that were followed by another 429, 10 re-blocked
within 900s of their own deadline:

  +2s +3s +3s +71s +79s +101s +337s +346s +714s +716s

and each earned a fresh full-hour penalty. The next one after that is
+3853s, so the distribution is bimodal with an empty band between.
2026-07-28 was an 11th instance: backoffUntil 22:30:16, next 429 at
22:30:19, deadline +3s. cswap had made one request in the trailing hour
at that point, so this is not a request-volume problem and no local rate
counter would have refused it.

Wait Retry-After * (1 + RETRY_AFTER_MARGIN_FRAC) so the retry lands in
that empty band. Proportional rather than a flat 900s because the
re-block evidence is entirely hour-scale, while short blocks were
measured separately (2026-07-06) as accurate: a flat margin would
inflate a 300s block 4x on no evidence, and would also let a 90s server
ask overtake our own saturated failure curve. 0.25 * 3600 reproduces the
measured 900s without extrapolating onto short blocks.

Costs one wait per block, not per probe, since the deadline is fixed and
not re-armed by probing.

Also correct the documented budget scope. poll_policy said
"per-access-token"; on 2026-07-28 a freshly minted token was blocked
135s after issue, which a per-token counter cannot produce. The scope is
the account/org, so re-authenticating does not clear a block and two
machines holding different tokens for one account still compete - which
is what realiti4#152's AIMD is for. Nothing here depends on the scope: the wait
comes from the server's own deadline.

Co-Authored-By: Claude <noreply@anthropic.com>
yjuyjuy added a commit to yjuyjuy/claude-swap that referenced this pull request Aug 13, 2026
* fix(windows): retry atomic file replace past transient sharing failures

os.replace is the final step of every atomic write in cswap (credentials,
settings, mappings, migrations, the session share manifest). On Windows it is
not reliably available: antivirus and the search indexer open freshly-created
files opportunistically, so a replace onto a just-written target fails with
ERROR_ACCESS_DENIED or ERROR_SHARING_VIOLATION for a few milliseconds.

Measured on an affected Windows 11 machine, 1779 of 4000 os.replace calls into
a Defender-scanned temp directory failed (~44%). The user-visible effect is an
intermittent "Failed to write credentials file: [WinError 5] Access is denied"
followed by "Switch failed ... attempting rollback", plus silently dropped
usage-store writes that made the autoswitch scheduler behave erratically. POSIX
rename has no such failure mode, which is why this never reproduced on Linux or
macOS CI.

Add fsutil.replace_with_retry(): os.replace retried with exponential backoff
(2ms doubling to a 250ms cap, 10 attempts) on ERROR_ACCESS_DENIED (5),
ERROR_SHARING_VIOLATION (32) and ERROR_LOCK_VIOLATION (33), on win32 only.
Genuine errors -- missing source, cross-device link -- still surface on the
first attempt instead of being retried into a delay.

fsutil is a new dependency-free leaf module: settings, credentials, mappings and
session all need the helper, and they sit on both sides of the existing
paths -> models -> usage_store -> settings import cycle.

The directory renames in switcher.py are deliberately left alone. They are
plausibly vulnerable to the same locking, but no failures were observed there
and a directory rename may warrant different handling.

* fix(add): capture the CLAUDE_CONFIG_DIR profile's own credential

add_account fills one slot from two reads. The identity comes from
.claude.json through get_global_config_path, which honours
CLAUDE_CONFIG_DIR. The credential comes from the active store, whose file
backend honours it too (get_claude_config_home) but whose macOS Keychain
backend is pinned to the unsuffixed CLAUDE_CODE_KEYCHAIN_SERVICE.

So on macOS the two reads can land in different profiles, and the slot
records one account's email against another account's token. It succeeds
quietly: cswap list and claude auth status both report recorded metadata,
not the token's real owner. The slot then serves the wrong account's quota,
and the bootstrap refresh rotates a token family the other profile is still
using, logging out the sessions there.

Resolve the capture the way Claude Code resolves its own credential for the
same environment: the profile's hashed Keychain entry, then its
.credentials.json, then — only when the env var names the default profile —
the active store, and finally that profile's own primaryApiKey so the
managed-key guard still answers. Writing is untouched; cswap does not write
claude's hashed entry, for the reason session.py's module docstring gives.

read_config_dir_credentials takes the raw env-var string, not a Path: claude
hashes the exported value verbatim, and a Path round-trip would drop a
trailing slash and silently fall back to the pre-rotation seed.

* fix(transfer): retry exported-file replace past Windows sharing failures (#158 follow-up)

transfer._atomic_write_file gained its os.replace in 54905ed, after #158
was cut, so the retry helper missed it. Also: validate attempts >= 1
(attempts=0 silently skipped the replace), temper the fsutil comments
(ERROR_ACCESS_DENIED can be a persistent ACL/read-only condition, not
only AV contention — it now documents the bounded-delay tradeoff), sort
the new imports, and move the retry tests to tests/test_fsutil.py.

* fix(settings): atomic_write_json writes THROUGH a symlink, never over it

A rename swaps a directory ENTRY and does not follow links, so renaming
the temp file onto a symlinked path DETACHES the link. The write
succeeds and the content is correct — but the link target stops
receiving updates, silently.

That matters wherever these files are managed by a dotfiles tool that
links them into a config repo: every subsequent write lands on a
now-plain local file, and the next deploy restores the tracked copy,
discarding everything written since. Measured in the field across three
machines: a settings section written by one feature disappeared while
the runtime state it configured kept running, so nothing looked broken
until the feature silently stopped applying.

Same failure shape as #192/#193, which fixed session.py's own writer;
atomic_write_json was never touched and is the shared writer behind
eight call sites (settings x3, autoswitch state x2, usage store,
credential stash, session config), so any of them can detach a managed
file today.

Fix: resolve the path first and rename onto the resolved target, so the
link survives and its target is updated. A dangling link still writes
where it points — linking it is the request. The temp file is created
beside the resolved target, keeping the rename same-filesystem and
therefore atomic.

3 regression tests: link survives + target updated, dangling link
writes through, plain-file path unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(settings): keep the 0700 hardening on the directory cswap owns

Adversarial review of the first revision (40 agents, every finding
reproduced or dropped: 35 raised, 4 confirmed) found that redirecting
path -> target also moved the 0700 chmod onto the RESOLVED parent — a
directory cswap does not own.

Two measured consequences, both regressions of this PR:
  - PermissionError when that parent cannot be chmod'ed. A link into
    /tmp made `cswap config set` exit 1 with a raw traceback; the chmod
    precedes the write, so the write never happened at all. It is not a
    ClaudeSwitchError, so the CLI's handler does not catch it, and in
    the autoswitch loop it degrades to a transient error while state
    silently never persists.
  - Silent narrowing in the motivating layout: one write took a
    dotfiles repo directory 0755 -> 0700 and its tracked file
    0644 -> 0600. Git records neither, so nothing surfaces it.

The chmod is defense-in-depth — mkstemp already creates the temp file
0600 and the final file is chmod'ed 0600 — so scoping it back to
path.parent costs nothing and restores pre-PR behaviour for it.

Also closes the coverage hole the same review found: reverting only the
mkstemp directory (temp beside the LINK instead of the target) survived
the entire suite, while in reality it raises EXDEV whenever the target
is on another mount — the write fails outright. Both defects now have a
test that kills the corresponding mutant (verified by mutating).

Docstring: states the three placement decisions and why, drops a call-site
count that was wrong (nine, not eight) and would rot anyway.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tui): hide watch-title snapshot age until it exceeds 60s

* fix(autoswitch): obey the poll plan instead of oversleeping it

The planner and the loop disagreed about time. When the active account burns
near the switch threshold the planner tightens its row to URGENT_INTERVAL_S
(60s) so the crossing is caught quickly — but the loop always slept
`interval_seconds`, so on any machine configured slower than the plan (360s
here, the shipped default) that plan simply could not be honoured.

Measured on this box mid-episode: the active row asked to be polled 112s ago
(nextPollAt -112s, pollIntervalS 60) while the engine still had minutes of
sleep left. The account sat over the threshold, doing nothing, until the user
toggled LIVE off and on by hand — which constructs a new engine and ticks
immediately, which is why a manual toggle "fixed" it every time and looked
like a dead engine. It was never dead; it was asleep past its own alarm.

The 90%→100% climb is where this hurts. The threshold gate at
_rank_candidates is deliberate — a landing account at/over the threshold
would re-trigger next tick — so with every account above the line the escape
is the at-limit trigger, which needs the active account to be seen AT 100%.
Learning that up to 6 minutes late is exactly the "it used to switch in real
time" regression.

_next_delay now shortens a normal-cadence sleep to the row's own nextPollAt.
Only ever shortens, and never below URGENT_INTERVAL_S, so it cannot raise the
request rate above what the plan already permits: the 429 budget lives in the
plan (245f5ba), and this makes the loop obey that budget rather than override
it. A relaxed plan does not stretch the cadence past the configured interval,
and a store read that fails leaves the cadence alone — the unshortened delay
is always a safe answer.

Four tests; the first two fail on pre-fix code (measured: 393s and 372s where
60s is required), the other two pin the no-lengthening and never-break-the-
loop guarantees.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(autoswitch): when nothing is below the threshold, go where quota returns first

Measured on this box: all three accounts' 5-hour windows at 100/99/95%,
threshold 90. Every candidate failed the "landing must be healthy" gate, so
the engine sat still, the active account burned to 100%, and Claude Code took
a hard session limit — while a peer whose window reset in 8 minutes was never
tried.

Waiting is the wrong move there, and it is not recoverable after the fact:
Claude Code's retry timer comes from the rate-limit headers it already
received (`URe.error.rateLimits.resetsAt` drives the "Retrying in 2h" banner;
the process-wide limit state is only ever updated from response headers). No
credential swap shortens a backoff that has already started. The only cure is
not to arrive there.

So when the active account and every measured candidate are all at/over the
threshold, the goal changes from "most headroom" to "soonest back": rank by
the reset of each account's BINDING window — the one actually holding it back,
the same window account_headroom measures — and move there. In the measured
case that is 8 minutes instead of 2 hours.

Deliberately narrow, three ways. It engages only when nothing is below the
line, so one healthy peer still wins the ordinary way. An account at its limit
(h <= 0) is still never a target, so genuinely-all-exhausted still reports
exhausted and parks on the reset. An unknown reset sorts LAST, not first, so a
snapshot nobody refreshed cannot masquerade as "back immediately".

The percentage-point hysteresis is replaced, not dropped, in this state only:
it is unmeetable by construction (nothing can be 10 points better when
everything is within a few points of its limit), so it would block the escape
rather than protect anything. The flap it exists to prevent is prevented on
the axis being ranked instead — a target must come back RECOVERY_HYSTERESIS_S
(5min, comfortably over one poll cycle) sooner than where we are, so the
reverse move never qualifies. Two tests pin that: 60s sooner does not switch,
an hour sooner does.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(autoswitch): trim the recovery-ranking path

Ponytail pass on f780441: drop an unused parameter, stop computing each
candidate's recovery timestamp twice per tick, and fold the build-filter-max
into one comprehension. The block comment claimed the percentage-point
hysteresis still guards the escape — it is exactly what the escape replaces,
so it now names RECOVERY_HYSTERESIS_S.

No behaviour change: 142 autoswitch tests pass, and disabling the escape
still fails four of them.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(autoswitch): the recovery gate must not depend on the strategy

Found reviewing the escape against the open issues and PRs it touches.

`if consume_first:` caught first, so a consume-first user reached the
all-above ranking (soonest binding recovery) without ever passing the
recovery-hysteresis gate: filtered on weekly reset ordering, sorted on
5-hour recovery — two different axes — and with no anti-flap guard at all.
Two accounts whose windows roll over a minute apart traded places.

With nothing below the threshold the strategy question is moot. consume-first
exists to spend perishable WEEKLY quota, and every account in that state is
blocked on a window returning in minutes; both strategies want the account
that can work again first. So all_above is checked before either strategy and
owns both the gate and the key.

Test fails on the previous commit (switches on a 60s difference).

Co-Authored-By: Claude <noreply@anthropic.com>

* test(autoswitch): pin that at-limit is unaffected by the recovery escape

The escape relaxes the landing rule, and at-limit already skips that whole
block — this asserts the two do not interact, so a future edit cannot make
the 100% case narrower by routing it through recovery ranking.

Co-Authored-By: Claude <noreply@anthropic.com>

* version bump

* version bump

* fix(autoswitch): three defects found in review of #202

All three reproduced before fixing, and all three share a cause: the code was
written against the interval I happened to run and the account shapes I
happened to test, not against the configurable range or the trigger matrix.

1. _respect_poll_plan LENGTHENED short intervals. The default is 60s and the
   floor is 15s, not the 360s I developed against, and
   max(min(delay, due_in), URGENT) RAISES a delay already below URGENT — a 15s
   interval slept 60s, and at the 60s default the whole lower jitter half was
   flattened. That is the exact inverse of the "only ever shortens" invariant
   the commit message claimed. Clamping the DEADLINE instead,
   min(delay, max(due_in, URGENT)), holds the invariant at every configured
   interval and still refuses to poll below the planner's floor.

2. _binding_recovery_ts could answer with a NON-binding window. It filtered
   unusable resets before taking the max, so whenever the binding window's
   reset was unknown or past, a lower window won — measured: 7d at 95% with no
   resets_at and 5h at 40% resetting in an hour reported "back in an hour",
   contradicting the docstring directly above it. Now the binding window is
   chosen first and its reset (or inf) is the answer.

3. The recovery KEY leaked into at-limit. The gate was scoped to
   proactive/consume-first; the key was not, so at-limit and failover ranked by
   soonest-recovery instead of most-headroom. Those triggers skip the gate
   deliberately — escaping a dead or blocked account wants headroom, not a
   return time. My earlier at-limit test missed this because its healthy
   candidate made all_above False, so it never reached the key at all.

Three tests, one per finding. Verified each FAILS on the pre-review code:
"a 15s interval slept 60.0s", "assert 1003600.0 == inf", and "at-limit must
take the most headroom, not the soonest recovery".

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(add): fail closed on an unreadable profile keychain at capture instead of silently falling back to a possibly-stale seed

* fix(add): resolve the capture profile through CLAUDE_SECURESTORAGE_CONFIG_DIR the way claude 2.1.220 does

* readme update

* test: a developer's terminal must not decide whether the suite passes

FORCE_COLOR or NO_COLOR exported in a shell changes 11 test_switcher.py
outcomes. Measured on this box with FORCE_COLOR=3:

    11 failed, 353 passed      # FORCE_COLOR=3 (as exported)
    364 passed                 # env -u FORCE_COLOR, same tree, same commit

Every failure is the same shape — a test asserting on plain output getting
styled output instead:

    assert 'Skipping Account-2 (disabled)' in
           '\x1b[38;5;173mSkipping\x1b[0m Account-2 (disabled)'

CAUSE: printer._detect_color_support consults FORCE_COLOR/NO_COLOR BEFORE
isatty(). That order is correct for the CLI — the variables exist precisely
to override detection — and wrong under pytest, where stdout is captured and
colour should therefore be off. The tests were right and the code was right;
the environment decided. Same class as the CLAUDE_CONFIG_DIR / XDG_DATA_HOME
scrub _isolate_real_home already does, and it belongs beside it.

The tests assert on OUTPUT, not on os.environ. Checking only that the
variable is absent passes for free on any box that never exported it, which
is most boxes and every CI runner, so it would not have caught this.

NOT INCLUDED, deliberately: a printer._colors_enabled cache reset. It reads
as prudent — the flag is a module global cached on first use — but nothing
styles output at import or collection time, so it is still None when the
fixture runs. A mutation run with the reset removed left the whole suite
green, i.e. the line was unfalsifiable. Left out rather than shipped as
untested defence.

Mutation-verified, each guard against the test that covers it:
    fixture removed              -> 2 fail
    FORCE_COLOR delenv removed   -> 2 fail
    NO_COLOR delenv removed      -> 1 fail (under NO_COLOR=1)

1697 pass under FORCE_COLOR=3, under NO_COLOR=1, and with neither set.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(test): reset the colour cache too — the scrub alone is not enough

Review found both claims in ef5a079's "NOT INCLUDED" section were wrong, and
I reproduced both before changing anything.

CLAIM 1, "the cache is still None when the fixture runs" — FALSE. Instrumented
the fixture to log printer._colors_enabled at entry across a full run:

    1599 False
      98 None

It is None only for the ~98 tests that run before anything first calls
colors_enabled(); after that it stays filled for the session. So the scrub
decides the verdict exactly once, and every later test rides the cached
answer.

CLAIM 2, "the reset is unfalsifiable" — FALSE. My mutation was too weak: I
removed the reset and ran the suite, which stays green because nothing in it
latches the cache. The falsifying case needs a test in ANOTHER file to latch
it. Measured on a clean env (no FORCE_COLOR, no NO_COLOR — the CI-runner
case), with a probe test in test_settings.py setting _colors_enabled = True:

    without the reset   11 failed, 1687 passed   <- the same original eleven
    with the reset      1698 passed

So the fixture's protection of test_switcher.py was resting on test_printer.py's
own module-local autouse fixture happening to run first and happening to clean
up. Real coupling, invisible from conftest.py.

monkeypatch.setattr restores it automatically, so the reset is strictly safer
than the manual assignment test_printer.py uses. That file's fixture stays: it
also resets _theme and is not ours to touch.

ALSO, from the same review: the NO_COLOR guard was environment-shaped
(`"NO_COLOR" not in os.environ`), which passes for free on any machine that
never exported it. Replaced with an output-shaped one. Both scrubs land on the
same answer — plain — so what separates them is WHY: with the variables gone,
detection must fall through to the captured-stdout isatty() check, so forcing
that to report a TTY has to flip it. A surviving NO_COLOR pins it False
regardless. Verified: removing the NO_COLOR delenv under NO_COLOR=1 fails that
test and nothing else.

Mutation-verified, each guard against the test that kills it:
    cache reset removed       -> 11 fail  (clean env, external latch)
    FORCE_COLOR delenv removed -> 12 fail  (FORCE_COLOR=3)
    NO_COLOR delenv removed    ->  1 fail  (NO_COLOR=1)

1698 pass under FORCE_COLOR=3, under NO_COLOR=1, and with neither set.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(test): monkeypatch the isatty, drop the hand-rolled stub

Ponytail pass on the same diff. Four cuts, no behaviour change:

  the 13-line _Tty class + try/finally is what monkeypatch.setattr already
  does — setattr(sys.stdout, "isatty", lambda: True, raising=False), one
  line, auto-restoring. Verified it still kills the NO_COLOR mutation.

  three comment blocks restating each other: the conftest fixture, the test
  class docstring and the third test's docstring all carried the same
  measurement. Kept it once, in the fixture, and pointed at it.

net -29 lines. Mutation-verified again after the trim, each guard against the
test that kills it:
    cache reset removed        -> 11 fail  (clean env, external latch,
                                            whole suite — a partial run
                                            orders the latch after the
                                            switcher tests and misses it)
    FORCE_COLOR delenv removed ->  1 fail
    NO_COLOR delenv removed    ->  1 fail

1698 pass under FORCE_COLOR=3, NO_COLOR=1, and neither.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(test): pin stdout — the plain-output assertion was tty-dependent

Review caught this branch INTRODUCING the failure class it exists to remove.
Reproduced in a real pty:

    base 9f35426, pytest -q -s   1695 passed
    head 15e66ba, pytest -q -s   1 failed
      test_styled_output_is_plain
      assert '\x1b[38;5;173mSkipping\x1b[0m' == 'Skipping'

Under -s, sys.stdout is the real TextIOWrapper and isatty() is True, so with
both variables scrubbed detection CORRECTLY falls through and enables colour.
The fixture guarantees the variables are gone; it does not guarantee stdout is
not a tty. Asserting unconditionally made the outcome depend on how the
developer invoked pytest — exactly requirement 1.

Fixed by pinning stdout to a StringIO, which is what the rest of this file
already does. Verified green under -s in a pty, FORCE_COLOR=3, NO_COLOR=1, and
a clean env: 1697 passed each. The FORCE_COLOR mutation still kills it.

ALSO REMOVED, as vacuous: test_the_fixture_resets_the_cache_it_inherits. No
mutation kills it — not the cache reset, not deleting the whole fixture. The
reason is structural: test_printer.py has its own module-local autouse
_reset_color_cache, module fixtures run after conftest ones, so None is the
only state the body can observe. Rewriting it to depend on a prior test
latching the cache would be order-dependent AND still defeated by that
fixture. The cache reset stays covered by the 11-switcher cascade under
mutation 3.

And dropped a redundant manual _colors_enabled reset in the isatty test —
two fixtures already did it, and the manual form is the one this branch
argues against.

CORRECTION to 15e66ba's ordering caveat, which I had backwards. It is not
'a partial run orders the latch after the switcher tests'. Measured:

    latch in test_settings.py (sorts BEFORE switcher), whole suite  11 failed
    latch in test_settings.py, 'test_switcher.py test_settings.py'  no kill
    latch in test_theme.py    (sorts AFTER switcher),  whole suite  no kill

The invariant is that the latch must EXECUTE BEFORE the tests it poisons; a
whole-suite run satisfies that only by alphabetical accident. A partial run
misses it because the latch file is not in the run at all. Stated wrong, a
reader drops their probe in the wrong file and concludes the guard is
unfalsifiable — which is the conclusion ef5a079 already reached once.

Two tests now, each killed by exactly one mutation.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(test): remove one environment dependency without adding a narrower one

Two findings from review, both the PR's own thesis turned back on it.

1. THE GUARD TEST FAILED ON TERM=dumb. Detection consults TERM *after*
   isatty(), so patching isatty to report a TTY is not enough — an Emacs
   M-x shell, or any dumb-term CI shell, still returns False and the
   assertion fails for a reason unrelated to what it guards. Measured:

       TERM=dumb           1 failed, 23 passed
       xterm-256color      24 passed
       unknown / unset     24 passed

   Trading a FORCE_COLOR dependency for a TERM one is the same defect in a
   smaller coat. The test now pins TERM like the fixture pins the other two.

2. THE CACHE RESET WAS UNFALSIFIABLE, and its stated justification was
   wrong. The docstring claimed `printer._colors_enabled` is "non-None for
   1599 of 1698 tests". Instrumenting the fixture to count it reports the
   opposite:

       fixture entry, cache non-None: 0 / 1697

   Nothing styles at import or collection time, so the flag is always unset
   when the scrub runs. Removing the line leaves the suite identical under
   FORCE_COLOR=3, NO_COLOR=1 and neither — 1697 passed each way.

   This PR already excluded that same reset from the production path, on the
   grounds that an unfalsifiable guard is worse than none. The rule applies
   to our own line too.

Full matrix after both fixes, all 1697 passed / 3 skipped:
    FORCE_COLOR=3 · NO_COLOR=1 · neither · TERM=dumb

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): restore the colour-cache reset — the measurement that removed it was wrong

102aa95 deleted `monkeypatch.setattr("claude_swap.printer._colors_enabled",
None)` from `_deterministic_colour`, arguing the cache is None on entry for
every test so the line proves nothing. That measurement was taken with the
probe BELOW the reset, reading back the value the reset had just written.
Probing at the TOP of the fixture: **1599 False, 98 None** — the 98 are the
tests that run before anything calls colors_enabled().

The guard is falsifiable, and the case that kills it needs no environment
variable at all. Detection CACHES: colors_enabled() latches its first answer
and returns it forever, so the scrub only decides what happens on a MISS.
Under `pytest -s` stdout stays the real terminal, isatty() is True, and the
latch happens in ordinary tests — measured in test_migrations, test_printer,
test_swap_accounts, test_transfer, test_tui:

    env -u FORCE_COLOR -u NO_COLOR TERM=xterm-256color \
      script -qec "pytest tests/test_migrations.py tests/test_switcher.py -q -s" /dev/null
    11 failed, 382 passed        <- the same eleven this PR exists to remove

With the line restored: 393 passed. The default alphabetical order hid it
only because test_api_key_accounts happens to latch False first, which is an
accident of collection order.

tests/test_colour_cache_isolation.py guards it: one test latches, the next
asserts its output is plain. Its own file on purpose — test_printer.py has a
module-local _reset_color_cache fixture that clears the cache around each of
its tests, so a leak staged there would be cleaned up by that fixture and the
guard would pass with conftest's reset removed. Mutation-checked: neutering
the reset fails test_the_next_test_is_not_styled_by_it on the exact
`accent("Skipping") == "Skipping"` assertion.

1699 passed, 3 skipped under FORCE_COLOR=3, NO_COLOR=1, neither, and TERM=dumb.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): correct the cache docstring's measurement, and stop querying the real terminal

TWO CORRECTIONS TO MY OWN WORK, both from a reviewer re-measuring instead of
reading.

1. THE DOCSTRING'S NUMBER WAS UNREPRODUCIBLE. It claimed 1599 False / 98 None
   against the shipped fixture and blamed probe placement. Probing the shipped
   fixture as its first statement gives 1699 None — zero False. 1599/98 is what
   you get with the line REMOVED.

   The removal-era observation ("the cache is None on entry for every test")
   was therefore CORRECT; the inference drawn from it was not. The None is
   produced BY this line: `monkeypatch.setattr` restores the pre-test value at
   teardown, so every test both enters and leaves unset. Control that separates
   the two explanations — identical reset, plain assignment, no restore:

       as shipped                                1699 None
       same reset, no monkeypatch restore         295 False / 1402 None / 2 True
       reset removed entirely                    1599 False /   98 None

   The old docstring rebutted the observation rather than the inference and
   left a figure the next reader could not reproduce — an argument for deleting
   the line a third time.

2. THE SUITE QUERIED THE DEVELOPER'S TERMINAL. `appearance.detect_terminal_background`
   puts the tty into cbreak, writes an OSC-11 query, and blocks reading stdin
   for up to a second. Under `pytest -s` stdin is the real terminal, so the
   suite emits escape bytes at it and can swallow a keypress. Measured on a pty
   with a probe at `tty.setcbreak`: reached 8 times.

   Pinning TERM=dumb in the fixture takes the function's own documented
   short-circuit, which returns before touching termios. Now 2 — and the
   remaining 6 are test_appearance.py's own, which set TERM themselves to
   exercise the detection. Only the accidental reaches are closed.

   Resetting `appearance._cache` would NOT have helped: the cache is what stops
   the second query, not the first.

NO TEST PINS THE TERM LINE, and the conftest comment says so instead of
implying otherwise. Its effect is observable only where `sys.stdin.isatty()` is
True, and under pytest it is False — the detection short-circuits on the tty
check before it ever consults TERM, so an in-suite guard passes with the line
removed. I wrote such a test, measured that it passed both with and without
the line, and deleted it rather than ship a guard that proves nothing. The
falsifying evidence is the pty measurement, recorded in the comment with the
command that reproduces it.

Also documented: the two-test ordering guard is vacuous under single-test
selection (reader alone -> 1 passed with the fixture broken; the pair -> 1
failed). Inherent to any ordering guard; noted so a green single-test run is
not mistaken for evidence.

1699 passed, 3 skipped under FORCE_COLOR=3, NO_COLOR=1, neither, TERM=dumb,
and under a pty.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): pin the TERM line with a real pty, correct two wrong measurements

THE COMMENT SAYING NO TEST COULD PIN TERM=dumb WAS WRONG, and wrong for an
inverted reason. It claimed "the detection short-circuits on the tty check
BEFORE it ever consults TERM" — appearance.py reads TERM at line 97 and
isatty() at line 107. Both gates block under plain pytest, so the conclusion
was accidentally safe while the reasoning was false, and the false reasoning
is what made an honest guard look impossible.

A `pty.openpty()` pair makes both isatty() calls genuinely True, which removes
the masking gate and leaves TERM as the only thing standing between the suite
and the terminal. The test then reads the pty master to see whether the OSC-11
query actually went out — bytes, not `os.environ`. Measured: passes with the
pin; fails without it under TERM unset, xterm-256color and screen-256color.

TWO DOCSTRING ROWS DID NOT REPRODUCE.

    row 2  claimed  295 False / 1402 None /   2 True
           measured 296 False / 1402 None /   1 True
    row 3  claimed 1599 False /   98 None
           measured 1186 False /  415 True / 98 None

Row 3 is the substantive one: 1599/98 totals 1697 tests and the tree has 1699.
It was measured before `test_colour_cache_isolation.py` existed and never
re-taken after the same commit added it. The 415 `True` ARE that file — its
latch test assigns the cache by design, and with the reset gone nothing clears
it until test_printer.py's module fixture fires 415 tests later. Row 3 poisons
its own measurement, which is now said in the docstring so the next reader
re-takes it when the guard file changes.

That is the third time this docstring has carried a number that did not
reproduce. The rows are the argument for keeping the reset, so a stale one is
an argument for deleting it.

ALSO CLOSED: `printer._theme`, the other latched global in the same module.
`tui/app.py` calls `set_theme("light")` — a plain assignment with nothing
restoring it — so measured 1582 dark / 118 light on fixture entry, the 118
being test_usage_store (90), test_update_check (26) and test_tui (2). Green
today only because none of them asserts a palette code, which is exactly what
was true of `_colors_enabled` until a developer exported FORCE_COLOR. Pinned
by its own latch/read pair, asserting on the palette bytes rather than the
private name.

1702 passed, 3 skipped under FORCE_COLOR=3, NO_COLOR=1, neither and TERM=dumb.

Co-Authored-By: Claude <noreply@anthropic.com>

* test: the pty terminal-query guard is POSIX-only

`pty`/`termios` do not exist on Windows, so the guard died there with
ModuleNotFoundError. Skipped rather than reworked: the function's FIRST gate
is `os.name == "nt"`, so on Windows there is no terminal query to guard —
the test has nothing to assert, not merely no way to assert it.

1702 passed, 3 skipped locally; the Windows run gains one skip.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(tests): re-take every measurement, and stop hand-copying the ones that move

Third consecutive stale docstring, same cause each time: the commit that
changes the guard file also changes the denominator, and the numbers are
copied from the run made before the tests were added. `9b9f012` added three
tests and rewrote the docstring in one commit.

Re-measured on THIS tree, probe as the first statement of the fixture body:

    as shipped                                1702 None
    same reset, plain assignment (no restore)  296 False / 1403 None /   3 True
    reset removed entirely                    1186 False /  418 True /  98 None
    _theme with its reset removed             1581 dark  /  121 light

The docstring now says which part is the contract — `shipped == all None`,
`removed == mostly not-None` — and which part is evidence that moves with the
suite. Copying a figure that has gone stale three times is the defect; naming
it as re-takeable is the fix.

Also corrected:

  the pty reach count. "8 before, 2 after" counted gate-passes on the parent
  tree. Instrumenting `tty.setcbreak` itself: 2 without the pin, 0 with it.

  the `_theme` leak attribution. 121 light, not 118, and the extra includes
  this guard file's own latch leaking past its reader into test_config_cli —
  the same self-poisoning the row-3 note describes, now for `_theme` too.

  the "26 passed" claim, which was true when written and is now unreachable
  (test_printer.py has 24 tests). Replaced with the property rather than a
  count, since that is what the sentence is for.

  the ordering hole. The file said the guards escape only under explicit
  nodeid selection. Measured with the guards mutated across 30 random-order
  seeds: the cache pair escaped 10 times, the theme pair 15; under `-n 4` the
  pair lands on different workers and neither latch reaches its reader.
  Neither plugin is a dependency today and the full-suite mutants still fail
  under both, so only file-scoped runs go blind — but "single-test selection"
  understated it.

  a duplicated local `import os` shadowing the module-level one.

1702 passed, 3 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(colour): assert the globals are unlatched, instead of staging an ordering

The latch/read pairs only fire in one ordering, and that ordering is not
guaranteed. Measured with each reset mutated out by line number (landing
asserted):

    full suite, -p randomly   cache seeds 4, 5 -> 1702 passed, 3 skipped
                              theme seeds 1, 2 -> 1702 passed, 3 skipped
    file-scoped, seeds 1-30   cache escaped 6, theme escaped 17
    file-scoped, -n 4         both -> 5 passed

So the docstring's "the FULL-suite mutants still fail under both, so only
file-scoped runs go blind" was false, and it is the sentence that told a
reader the hole was bounded.

Two assertions at the top of the fixture close it without needing any
ordering: same mutants, same seeds, 1694 / 1684 / 433 / 794 errors. They
cost nothing with the fixture intact (1702 passed, 3 skipped, and removing
just the assertions leaves the suite green). The pair stays as the readable
narrative of what the leak looks like; it is no longer what makes the suite
safe.

Three measurements corrected, all re-taken here:

  418 True in row 3, not 415, and they are not one file — test_oauth 98,
  test_menubar 64, test_json_output 38, test_migrations 29, test_poll_policy
  29, test_move_accounts 28, test_paths 27, test_config_cli 26 and more. The
  guard file starts the latch without accounting for the count, and
  test_printer.py is not where it ends.

  6/30 and 17/30 escapes, not 10/30 and 15/30, with the seeds named so the
  figure is re-takeable — which is the property this docstring set out to fix
  elsewhere.

  The invariant now names the VALUE, not just "not latched": pinning False
  instead of None leaves 1702 passed and a probe reading 1702 None, identical
  to shipped, and then both delenv lines can be deleted with the suite still
  green. Same signature, different mechanism.

Suite 1702 passed, 3 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(tests): name the file the 98 -> 514 figure was measured on

Review Minors. The figure is test_switcher.py's, not any file's: the same
fixture added to test_oauth.py gives 270 and to test_printer.py 98 (it
already has one). Since the sentence's point is 'only the digits say so',
naming the file makes it re-takeable.

The assertion message also now names the other way it can fire. tests/ has
zero non-function-scoped fixtures today, so nothing can hit it — but a
module- or class-scoped autouse fixture that latches and restores correctly
still trips it, because higher-scoped setup runs first, and the current
message would send its author hunting for a leak that is not there.

Suite 1702 passed, 3 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(colour): delete the two module-local resets this fixture replaces

Review Important, and it inverts a premise I had backwards. `test_printer.py`
carried its own `_reset_color_cache`, which I had described as what CATCHES a
`_colors_enabled` pinned to `False` instead of `None`. Measured, it is what
HIDES it:

    pin False, fixture present   1702 passed   (escapes)
    pin False, fixture deleted   9 failed      (caught)

Those nine set FORCE_COLOR=1 and assert styling IS present; a pinned False
makes detection never run, and the module-local fixture re-Nones the cache
after conftest's, restoring detection and swallowing the mutant. Since this
PR, that fixture does strictly less than conftest's — deleting it closes the
escape at -8 lines.

`appearance._cache` is the third `global`-rebound name in the package and the
fixture now resets it too. It is inert today ONLY because the TERM pin makes
detection return before writing it — a guarantee about the QUERY, not the
cache, which then latches that None against any test that stubs
`_query_terminal_background`. Two added tests, no mutation of the fixture: a
leaker at the top of collection poisons 28 later tests sequentially and 473 at
--randomly-seed=7, and a detect-then-read pair fails while the reader alone
passes. The comment claiming a reset "would NOT help" was true of the query
and read as a general dismissal; corrected.

That makes `test_appearance.py`'s `_reset_detect_cache` redundant too, so it
goes as well. Removing the conftest reset now fails 2 tests in that file,
which is the coverage it used to provide.

Also corrects the guard file's stale 1582/118 to the measured 1581/121 with
its five-file breakdown, matching what conftest already carries.

Suite 1702 passed, 3 skipped — sequentially and on seeds 1, 7, 42.

Co-Authored-By: Claude <noreply@anthropic.com>

* test: close two holes in the colour-isolation guard, re-take its table

The TERM pin could be changed from `dumb` to `linux` with the full suite
green — seq, `-n 4` and eight seeds. `appearance` short-circuits on both
(appearance.py:97) so the emitted-bytes assertion cannot separate them, but
`printer` tests only `== "dumb"` (printer.py:97): measured inside a pty,
`linux` gives `colors_enabled = True`. The mutant silently re-enabled the
styling the fixture exists to suppress, under `-s`, which is the exact
scenario it was written for. The pty guard now also reads printer's answer,
WHILE stdout is still the pty — checked after the block it passes for free,
because stdout is pytest's capture object and `isatty()` is False.

The two entry assertions could be killed by REORDERING, no line deleted:
moving the resets above them makes each read the value the reset just wrote.
Proof it was a real kill rather than a no-op — a session-scoped fixture
latching `True`/`"light"` gave 1702 errors in the shipped order and 1702
PASSED reordered. Now snapshotted into a local on the fixture's first
statement, so detection no longer depends on where the assertions sit:
same reordering, same latch, 1702 errors.

The docstring table was stale for the fourth time, and this branch is what
moved it — deleting `test_printer.py`'s module-local reset changes rows 2
and 3, exactly the effect the docstring warns about. Re-taken with the probe
as the fixture's first statement:

    row 2 (no restore)  296/1403/3   ->  301 False / 1386 None /  15 True
    row 3 (no reset)   1186/418/98   ->  775 False /  919 True /   8 None

Shape unchanged (shipped == all None, removed == mostly not-None); digits
now reproduce. The guard file's own note about living separately was stale
by the same deletion — re-ran that experiment on HEAD: 6 passed, 20 errors,
so the leak IS caught in `test_printer.py` today.

Full suite 1702 passed, 3 skipped. TERM mutation now fails exactly its own
test (1701 passed + 1 failed).

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(tests): delete a seed table nobody can re-take, correct pin-False

Two docstring claims measured wrong, both in this branch's favour.

The pin-False paragraph says pinning `False` instead of `None` "leaves 1702
passed", i.e. that the mutant is uncaught. Re-measured on this tree: 9
failed, headed by `test_bold_accent_with_colors_enabled`. This branch is what
closed it — deleting `test_printer.py`'s module-local `_reset_color_cache`,
which had been cleaning up after the mutant inside the one file that would
have noticed. The text still described the world before that deletion.

The seed table in the guard file named six cache seeds and seventeen theme
seeds as escapes. None of its four headline figures re-measures here: the
file-scoped pair passes at every seed I tried (4 5 7 12 13 24 25 27 29), and
a reviewer taking it a third way got numbers matching neither. The condition
is not reconstructible — the same deletion moved the denominator, the fourth
time a table in this file or its sibling has gone stale for that reason.

Deleted rather than re-taken. A figure nobody can reproduce is not evidence
and it invites the next reader to trust it. The CLAIM it supported does still
measure and stays: with the reset mutated out, the pair together gives 4
errors and the reader alone passes, so an ordering that puts the reader first
escapes and the pair alone proves nothing.

Full suite 1702 passed, 3 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(colour): cover both env scrubs on a clean box, and stop hand-keeping figures

TWO REAL FINDINGS, both about coverage rather than behaviour.

1. The two `delenv` lines were dead code on every CI runner. Measured: with
   neither variable exported and BOTH lines deleted, the whole suite is
   1702 passed. Their only coverage came from a box whose developer had
   exported FORCE_COLOR — that is, from the bug already happening. A guard
   that needs the failure present to be tested is not covered.

   `TestColourEnvDoesNotLeakIntoTests` now exports both variables from a
   CLASS-scoped autouse fixture. The scope is the mechanism: pytest builds
   higher-scoped fixtures first, so this lands before the function-scoped
   conftest one and the scrub has something to scrub. Setting them in a test
   body cannot work — the fixture has already run by then. Mutation-checked
   individually on a clean env: drop the FORCE_COLOR line and
   test_styled_output_is_plain dies; drop the NO_COLOR line and
   test_detection_reaches_isatty_rather_than_an_override dies. 1 failed each,
   where before both could be deleted together for 1702 passed.

2. Six figures across the two files were stale or wrong. Re-measured here:

     full suite, cache mutant, seeds 4 / 5   claimed green; actually 20 and 18 failed
     file-scoped escapes, seeds 1-30         claimed 6; actually 9 (4 5 7 12 13 24 25 27 29)
     termios reaches without the TERM pin    claimed 2; actually 31, and +30s of wall clock
     pin-True failure count                  claimed 12; actually 14
     theme leak on entry                     claimed 1581/121 over 5 files; actually 1580/122 over 6

   Every one went stale for the same reason, and it is not that a reviewer
   found new defects: THIS BRANCH invalidated its own numbers. Deleting
   `test_printer.py`'s module-local `_reset_color_cache` moved the denominator,
   which made a sentence false three lines above the paragraph warning about
   exactly that, and added two tests that moved the pin-True count. That is the
   fifth and sixth instance of one failure mode.

   So the figures are DELETED rather than corrected — correcting them books the
   seventh. What stays is what re-measures: the shape (`shipped == all None`,
   `removed == mostly not-None`), the counts the claims actually need, and the
   mutations that check them. The theme breakdown was hand-copied into both
   files and wrong in both; it is gone from both and the fact it encoded lives
   next to the line it is about, once.

   The deleted seed table was also wrong about ITSELF: it said the digits were
   no longer reconstructible. They are — the earlier note sampled only the
   seeds where the mutant escapes and read a uniform result as noise. The 9
   escaping seeds it listed as evidence of irreproducibility ARE the answer.

Two claims narrowed to what measures:
- The entry snapshot bounds test ORDERING, not setup order. A module-level
  autouse fixture latching after it escapes — measured, 8 failures inside
  test_printer.py with the assertion silent. That is the shape of the two
  fixtures this branch deletes, so the fix is to not have them.
- "Either alone proves nothing" was stated only of the cache pair; the theme
  reader alone is also `1 passed` over a broken reset. Both pairs.

Suite: 1702 passed, 3 skipped — clean env, FORCE_COLOR=3, and -n 4.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(colour): mutation-kill the conftest entry assertions directly

C-1: the two `assert inherited[...]` lines in _deterministic_colour were
dead code on a clean box — deleting both left the suite green (1702
passed, reproduced). Invoking the fixture body directly with a scratch
MonkeyPatch, against a poisoned printer global, needs no test-ordering
trick: delete either assertion and the matching test below dies.

Measured: assert1 removed -> test_kills_colors_enabled_assertion fails
(DID NOT RAISE). assert2 removed -> test_kills_theme_assertion fails.
Both restored -> 1704 passed, 3 skipped (up from the 1702 baseline by
exactly these two tests).

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): stop monkeypatch.undo() from leaking the developer's real env

H-1: pytest hands out ONE MonkeyPatch instance per test even when
multiple fixtures request it, so a test's own `monkeypatch` parameter
is the SAME instance _deterministic_colour used to scrub FORCE_COLOR/
NO_COLOR. 8 sites (3 in test_move_accounts.py, 5 in
test_swap_accounts.py) called monkeypatch.undo() mid-test to unwind an
injected-failure patch -- and that call also popped the autouse scrub
off the same stack, restoring whatever FORCE_COLOR the developer's
shell actually has exported for the rest of the test. On a box with
FORCE_COLOR=1 (the exact condition this PR exists to remove) that
reintroduces the non-determinism.

Verified the mechanism first (all 8 sites use the shared fixture
instance, confirmed via id() probe), then reproduced the leak in a
subprocess with FORCE_COLOR=1 genuinely exported before pytest starts.

Fix: each site now scopes its injected patch to a
`pytest.MonkeyPatch.context()` instead of the shared `monkeypatch`
fixture, and drops the `.undo()` call (the context manager undoes on
exit). The now-unused `monkeypatch` parameter is dropped from 5 test
signatures (3 already had it satisfied by other needs and keep it).

New regression coverage in test_colour_cache_isolation.py proves both
directions against a class-scoped FORCE_COLOR export standing in for a
real developer shell: the old shared-instance undo() pattern leaks it
(confirms the finding), the scoped-context fix does not (confirms the
fix, and would catch a regression back to the old pattern).

1706 passed, 3 skipped (up from 1704 by these 2 new tests).

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(tests): cut hand-maintained figures, keep what re-measures

M-1/M-2/M-3: this PR's own thesis is that a number nobody can re-take
is not evidence. Three files still carried hand-copied counts that had
already gone stale five times by conftest.py's own account (per-seed
escape lists, per-run pass/fail tallies from superseded experiments,
digits that assumed a denominator this branch's own commits move).

Two of the counts were already stale before this commit even landed:
- test_printer.py claimed 'the whole suite is 1702 passed' with both
  delenv lines removed; the C-1 fix in this branch adds two tests, so
  the true figure is 1706 and would drift again at the next unrelated
  addition. Replaced with the re-measurable claim (stays green) plus a
  cross-reference to the guard that actually closes C-1's hole.
- conftest.py's '1702 errors / 1702 PASSED' session-scoped-latch proof
  had the same problem for the same reason.

Cut, don't replace, everywhere else: per-seed escape lists (9 of 30,
17 of 30), superseded re-measurement anecdotes ('8-and-2 and then
2-and-0'), and the revision history of the comment itself ('this is
the sixth stale-figure correction'). What stays is the SHAPE claim a
real test checks (mutating the reset out flips the suite red; the
entry assertions die when poisoned) and the still-accurate structural
facts (line numbers in appearance.py/printer.py, re-verified against
current source).

test_colour_cache_isolation.py's module docstring cut from ~68 lines
of measurement history to a dozen; conftest.py's fixture docstring and
inline comments dropped digit-bearing paragraphs about mutations that
are no longer even performed in this file. No behavior change: full
suite still 1706 passed, 3 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(colour): ponytail-review cut, one class not two

/ponytail-review on the H-1 diff flagged the two-test H-1 class:
test_undo_on_the_shared_instance_leaks_it asserted a fact about
pytest's own MonkeyPatch contract (undo() unwinds a shared instance),
not about any of our code -- useful as scratch-work to establish the
finding, not as a permanent guard. Dropped it and kept only
test_scoped_context_does_not_leak_it, the actual regression guard for
the fix pattern applied at all 8 sites. Docstring trimmed to match.

1705 passed, 3 skipped (down 1 from the dropped demonstration test;
full suite re-run confirms no other regression).

Co-Authored-By: Claude <noreply@anthropic.com>

* perf(tests): run the suite in parallel — 42.58s to 6.45s

The suite was slow for a reason no single test explains: mostly-idle work
(file locks, subprocess fakes, Textual pilots) done one at a time. Measured
on this branch, 48 cores, same pass count both ways:

    serial     42.58s
    -n auto     6.45s      6.6x faster

UPSTREAM IS THE SAME SHAPE, so this is not something this PR introduced --
upstream/main runs 1695 tests in 42.50s, 25.1 ms/test, and every branch here
sits within a millisecond of that per test. The lever is concurrency, not any
individual test.

`pytest-xdist` is a DECLARED dev dependency and `uv.lock` carries it. It
happened to be installed in one local environment, which is exactly how a
green local run and a red CI diverge: CI runs `uv sync` + `uv run pytest` and
installs only what the lockfile names. Verified through that path on #199,
where CI (linux + windows + macos) went green with it.

`-n auto` rather than a fixed count so a 2-core CI runner and a 48-core box
each get the cores they have.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tests): serialize the real-Keychain tests under xdist

The parallel switch broke `macos-keychain` on CI. Measured, commit 0aa9c1f,
macos-latest — green on the previous head, serially:

    FAILED test_read_credentials_finds_claude_code_seeded_entry
    AssertionError: assert '' == 'fake-token-read'

Those tests drive the real `security` CLI against a real keychain. Two
workers seed and delete the same `Claude Code-credentials` item, so a reader
sees "" where it just wrote a token. The keychain is process-wide; parallel
workers cannot each have their own.

Fixed in conftest, not in ci.yml, so the constraint travels with the tests
however they are invoked. `no_keychain_fake` already means exactly "this one
touches the real keychain", so the hook reuses it rather than inventing a
second marker that could drift from the first.

`xdist_group` sends the group to ONE worker: those three serialize against
each other while the other ~1900 tests stay parallel.

`tryfirst` IS LOAD-BEARING, and the first attempt without it failed silently.
The marker was applied — verified on the node itself — and the tests still
scattered across all 8 workers, indistinguishable from no grouping. xdist
reads the group when it builds the schedule, so applying the marker late is
the same as not applying it. Measured, both directions:

    via the hook, tryfirst      -> workers: gw0                    (grouped)
    same tests, marker removed  -> workers: gw0..gw7               (control)
    direct @pytest.mark.xdist_group -> gw0   (proved xdist itself was fine)

Verified through the exact CI command that went red:

    uv run pytest tests/test_macos_keychain_contract.py tests/test_macos_keychain.py
    22 passed, 3 skipped

Full suite unaffected: 1705 passed, 3 skipped in 5.88s.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ci): lock the lockfile, so a stale one cannot pass

`uv.lock` did not match `pyproject.toml`. The lock carried a `pin` extra and
its whole dependency tree (cswap-pin, cryptography, cffi, pycparser) plus
`provides-extras = ["menubar", "pin"]`, while this branch's `pyproject.toml`
declares only `menubar`. It was written in a checkout that had the pin extra
and committed here.

    uv lock --check   before  rc=1
                      after   rc=0

CI could not see it. Every job ran bare `uv sync`, which REWRITES the lock to
match and then succeeds, so a lock that disagrees with the manifest installs
cleanly and reports green. `uv sync --locked` refuses instead — the three jobs
now use it, which is what makes the regenerated lock stay correct rather than
being re-corrected silently on the next run.

Proven in both directions on the fixed tree:

    add an extra the lock does not carry   uv lock --check rc=1
    restore                                uv lock --check rc=0

pytest-xdist and execnet are still locked; the four pin-only packages are gone.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(usage): clear the Retry-After deadline instead of retrying on it

Follow-up observation on #152, from its own post-merge data.

#152 raised RETRY_AFTER_FLOOR_CAP_S 900 -> 3600 so we stop re-probing
inside a block that lasts the full window. That was right, and this
keeps it. What it left is the block BOUNDARY: honoring Retry-After
exactly puts the retry on the deadline itself, where the server is not
reliably ready.

Measured over one machine's whole log (511 http-429 lines -> 39 deadline
blocks; 37 of 39 opened at exactly Retry-After 3600, so the penalty is a
fixed hour armed at violation, not capacity aging out). Of the 19
post-fix block lapses that were followed by another 429, 10 re-blocked
within 900s of their own deadline:

  +2s +3s +3s +71s +79s +101s +337s +346s +714s +716s

and each earned a fresh full-hour penalty. The next one after that is
+3853s, so the distribution is bimodal with an empty band between.
2026-07-28 was an 11th instance: backoffUntil 22:30:16, next 429 at
22:30:19, deadline +3s. cswap had made one request in the trailing hour
at that point, so this is not a request-volume problem and no local rate
counter would have refused it.

Wait Retry-After * (1 + RETRY_AFTER_MARGIN_FRAC) so the retry lands in
that empty band. Proportional rather than a flat 900s because the
re-block evidence is entirely hour-scale, while short blocks were
measured separately (2026-07-06) as accurate: a flat margin would
inflate a 300s block 4x on no evidence, and would also let a 90s server
ask overtake our own saturated failure curve. 0.25 * 3600 reproduces the
measured 900s without extrapolating onto short blocks.

Costs one wait per block, not per probe, since the deadline is fixed and
not re-armed by probing.

Also correct the documented budget scope. poll_policy said
"per-access-token"; on 2026-07-28 a freshly minted token was blocked
135s after issue, which a per-token counter cannot produce. The scope is
the account/org, so re-authenticating does not clear a block and two
machines holding different tokens for one account still compete - which
is what #152's AIMD is for. Nothing here depends on the scope: the wait
comes from the server's own deadline.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(usage): fix stale constant name, document the cap/margin boundary

Review follow-ups, comments only, no behavior change.

- poll_policy referenced RETRY_AFTER_MARGIN_S, which never existed under
  that name (the constant is RETRY_AFTER_MARGIN_FRAC).
- Note that the cap binds only above the measured shape: 3600 x 1.25 is
  exactly RETRY_AFTER_FLOOR_CAP_S, so an ask past the measured window
  keeps a shorter margin by design.
- Note which knob to turn if a re-block ever appears past +900s: the
  fraction, not the cap.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(usage): the Retry-After margin must not decay as the deadline nears

#197 added a margin so the retry lands past a block's deadline instead of on
it. The margin is a FRACTION of what the server asked — but Retry-After is a
countdown to a fixed deadline, so what the server reports depends on WHEN we
ask, and the budget is account-scoped (#197 established this itself): a second
machine polling into a block another one opened sees only the remainder.

Scaling the remainder shrinks the margin toward zero exactly as the deadline
approaches. Measured on the 0.25 fraction:

    block seen at   server says   we wait   retry lands
              t=0          3600      4500   deadline +900s
           t=1800          1800      2250   deadline +450s
           t=3000           600       750   deadline +150s
           t=3400           200       250   deadline  +50s

Only the fresh-block case clears the +2s..+716s re-block band the margin
exists to clear, and 35 of 72 observed 429s were mid-block — the common case,
not an edge. Each one that lands in the band earns a fresh full hour.

Make the margin absolute (RETRY_AFTER_MARGIN_S = 900, the band's measured
edge). It applies only to asks STRICTLY above BACKOFF_CAP_S: at or below it
our own saturated curve already waits at least that long, so adding to a short
ask would only overtake the curve — test_own_curve_may_exceed_retry_after and
test_huge_failure_count_does_not_overflow both pin that, and the boundary is
strict because an ask of exactly BACKOFF_CAP_S is what the curve already
waits. RETRY_AFTER_FLOOR_CAP_S is unchanged: 3600 + 900 is still 4500.

A remainder that has fallen UNDER the cap still retries near its deadline.
Telling that apart from a genuine short burst block needs the row's own
backoff state rather than the ask, so it is left to the caller instead of
guessed at in the arithmetic; test_short_asks_stay_on_our_own_curve records
the limit rather than hiding it.

Three existing tests pinned the fraction's arithmetic (112.5 = 90x1.25,
375 = 300x1.25) and are updated to the new shape with their meanings intact:
the server ask still beats our first-failure curve, and a measured-accurate
300s burst block is still not inflated.

Mutation-checked: restoring the fraction fails all four.
Full suite 1666 passed.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(usage): name the margin's residue, and fix three stale references

Review of the absolute-margin commit, four findings, all real.

The margin only applies above BACKOFF_CAP_S, so an hour-scale block whose
REMAINDER has fallen under 600s — its last ten minutes — still retries near
its deadline. The previous commit said this was "left to the caller"; record()
is the only caller and passes the value straight through, so that was a
deferral to nobody. Document it where the decision is made instead, with what
was actually measured: nothing local separates that ask from a genuine short
burst block. The burst rule really does send Retry-After 300 for a 300s block,
backoffUntil is per-machine while the budget is per-account (a second machine
polling into someone else's block has no live backoff), and last429At is
recent in both cases because a burst is several rapid 429s by definition.

Blanket-applying the margin was measured against the pinned contracts and
breaks them — a 300s burst block would wait 1200s, and a 10s ask would
out-wait our own curve. Clamping to BACKOFF_CAP_S still leaves an ask of
exactly 600 with zero margin and perturbs short asks. The residue is far
narrower than the decay it replaced (that one shrank the margin at every
mid-block observation, 35 of 72; this one only in a block's final minutes), so
it stays open and named rather than closed by a guess.

Three staleness fixes:

- poll_policy's docstring pointed at RETRY_AFTER_MARGIN_FRAC, which no longer
  exists, and said "scaled" when the margin is now added. That paragraph is
  what an operator reads when tuning.
- oauth.py's 429 comment still asserted "budgets requests per access token" —
  the exact claim this branch corrects two files over. The user-facing string
  was fixed and its rationale left contradicting it, inviting a revert.
- test_retry_after_is_the_floor asserted `not in_backoff(now + 114)`, a
  leftover from the fractional revision (90 x 1.25 = 112.5). With the absolute
  margin the ask backs off exactly 90s, so 114 passes even if a margin wrongly
  leaked onto short asks by up to +24s. Tightened to 91, which pins the
  invariant the comment claims.

Full suite 1666 passed.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(usage): trim the margin comments to what the code doesn't say

ponytail pass. The in-function block restated the constant's docstring and
recorded two approaches I tried and rejected — that belongs in the commit that
rejected them, not above the arithmetic. The re-block evidence listed all ten
offsets where the range and the gap say the same thing. And 'Proportional, not
a flat 900s' survived from the fractional revision, contradicting the very next
sentence ('The margin is ABSOLUTE, not a fraction').

-19 lines, no logic change. Suite 1666 passed.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(usage): cut the margin comments to what the code cannot say

RETRY_AFTER_MARGIN_S carried 28 lines for one constant. Kept the bimodal
measurement (10 of 19 lapses re-blocked within 900s) and why the margin is
absolute rather than fractional — both decide the VALUE. Dropped the
post-mortem of the 0.25 fraction it replaced: that code is gone, and git
remembers it.

Also drops the per-token -> per-account correction where it was restated a
third time (the module docstring already says it), and shortens KNOWN
RESIDUE to the one line that tells a reader not to 'fix' it.

net -23 lines. 1688 pass; the 11 test_switcher ANSI failures are upstream's.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(usage): pin the BACKOFF_CAP_S boundary where a reader will look

Review found the test comment said 'at or above BACKOFF_CAP_S' while the code
is strictly '>', and no test in test_usage_store.py pinned the boundary.
Reproduced: mutating > to >= leaves that whole file green (94 passed) and
trips only test_autoswitch.py::test_consume_first_stale_target_holds_then_switches,
whose failure reads

    assert <TickOutcome.NO_ACTION: 2> is <TickOutcome.SWITCHED: 0>

— nothing about backoff, margins or a boundary. So a reader who trusts the
comment, 'corrects' the operator, and sees an unrelated scheduler test break
concludes the scheduler test is flaky. At >= an ask of exactly 600 jumps from
600s to 1500s, 2.5x, on a value this module treats as short-block territory.

Now: the comment says ABOVE (strictly), and the boundary is asserted on both
sides in the test that exists to guard it. Mutation now fails there.

Also from the same review, comment accuracy only:
  - the cap's rationale said 'every observed block opens at 3600'; the test
    says '37 of 39'. Quote the same number, since 4500 is derived from it.
  - name where the margin reaches ZERO, not only where it starts shrinking:
    at an ask of 4500 the overshoot is 0 and beyond it the wait is shorter
    than asked. Costs one re…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants