Skip to content

stack 5/5: probe reset-derived cooldowns without waiting to be selected (#915) - #955

Open
lidge-jun wants to merge 5 commits into
codex/545-classifier-thinking-disabledfrom
codex/915-cooldown-recovery-probe
Open

stack 5/5: probe reset-derived cooldowns without waiting to be selected (#915)#955
lidge-jun wants to merge 5 commits into
codex/545-classifier-thinking-disabledfrom
codex/915-cooldown-recovery-probe

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Stack

5/5 — cooldown early-recovery probe

Base: codex/545-classifier-thinking-disabled (#954)

Summary

A reset-derived cooldown is a prediction, and OpenAI can reset earlier than predicted. The account should come back — but the probe that would notice was unreachable.

The chain: cooled accounts are filtered out before every strategy runs (src/codex/routing.ts:740-768), and resolveCodexAuthContext() selects an account first and only then checks that account's probe lease (src/codex/auth-context.ts:238-247, :276-298). So with account B eligible, A is never selected, never reaches the lease code, and never recovers. The recovery mechanism was not broken; it was unreachable.

A forced WHAM read did not help either: setAccountQuotaFromParsed() writes the quota cache and never touches routing state.

  • claimDueCodexQuotaRecoveryProbes() / settleCodexQuotaRecoveryProbe() — enumerate accounts independently of selection
  • runs on the state sweeper's existing 60s tick, no new timer
  • src/codex/auth-context.ts unchanged; request admission is untouched

Deliberate narrowings

reset-derived only. The existing request-path predicate also admits default, but a default cooldown is the 60-second headerless fallback — there is no prediction to be early against. retry-after stays authoritative.

Oldest-first ordering. Bounded claims with stable config order starve: if more accounts are due than the per-pass limit and the early ones keep failing, they become due again and consume the budget forever. Claims sort by oldest lastProbeAt ?? cooldownSince, so a probed account rotates to the back whether or not it recovered.

Spark is never claimed. GET /backend-api/wham/usage takes no scope parameter and returns generic windows, so its result can never prove a spark recovery. Claiming it would spend the account's one claim per pass to settle false and leave the shared scope — which that evidence can clear — cooled behind it.

Main account excluded from this first cut. Pool accounts have generation-aware single-flight; fetchMainAccountInfo() has none, so a sweeper probe could race a dashboard refresh into parallel WHAM requests. Giving main the same admission semantics is its own change.

Settle is narrow. It clears only the exact map entry, only while the lease, cooldown generation, and credential generation all still match; any mismatch releases the lease and retains the cooldown. It does not use clearCodexAccountCooldown() (clears every scope) and does not route through recordCodexUpstreamOutcome(200) (also mutates account-wide failure state).

What the audit changed

Five rounds, four FAIL. Every finding was reproduced before fixing.

  1. Unknown plans failed open. An unfamiliar plan_type with a finite weekly percentage cleared the cooldown on evidence we could not interpret.
  2. The first fix was the wrong shape. An allowlist missed prolite — then the snapshot turned out to carry 21 distinct plan strings (edu_plus, finserv, k12, quorum, self_serve_business_usage_based, …) with 12 unclassified, and CodexAccount.plan is an unrestricted string. Every omission meant an account cooled forever: the defect this PR exists to fix, reintroduced as a typo-shaped hole. Replaced with codexQuotaWindowForPlan(), the parser's own binary rule, now shared by parsing, exhaustion, and recovery so the three cannot drift.
  3. Three tests passed vacuously. Both lease-release cases asserted only that the cooldown survived, never that the lease came back — a stranded lease means that account is never probed again. The fairness test spaced passes 60s apart, inside the 5-minute probe interval, so already-probed accounts dropped out on their own and the ordering was never exercised.
  4. The replacement test was tautological — it computed its expected window from the function under test, so ablating the rule to always-weekly still passed 18/18. The contract is now pinned in literals first.

Verification

  • bun x tsc --noEmit — exit 0
  • bun run test — 7729 pass, 8 skip, 0 fail, 508 files
  • bun run privacy:scan — passed
  • red-green: ablating claimDueCodexQuotaRecoveryProbes() fails 5 tests; ablating the oldest-first sort fails the fairness test; ablating the window rule fails the plan matrix; ablating lease release fails both retention tests

The defining case: account A cooled reset-derived, B eligible, ordinary routing selects B — after the interval the worker probes A's credential and clears A's cooldown, with no routing call ever having selected A.

Fixes #915

…ted (#915)

A reset-derived cooldown is a prediction, and OpenAI can reset earlier than
predicted. The probe that would notice was unreachable: cooled accounts are
filtered out before every strategy runs, and resolveCodexAuthContext() selects
an account first and only then checks that account's lease. With another
account eligible, the cooled one was never selected, never probed, and never
recovered.

Adds a background claim/settle pair that enumerates accounts independently of
selection, on the state sweeper's existing tick rather than a new timer. Claims
require reset-derived exactly — a default cooldown is the 60s headerless
fallback, so there is no prediction to be early against — and are ordered
oldest-first so a bounded pass cannot starve later accounts.

Settle clears only the exact scope entry, and only while the lease, cooldown
generation, and credential generation all still match; any mismatch releases
the lease and retains the cooldown. Recovery does not route through
recordCodexUpstreamOutcome(200), which would also mutate account-wide failure
state, and never uses clearCodexAccountCooldown(), which clears every scope.

Spark is skipped at the claim site: WHAM takes no scope parameter, so a generic
result can never prove a spark recovery. Claiming it would spend the account's
one claim per pass to settle false and leave the shared scope cooled behind it.

The main account is excluded from this first cut — it has no WHAM single-flight,
so a sweeper probe could race a dashboard refresh into parallel requests.

Fixes #915
… load-bearing

The recovery predicate assumed weekly-window semantics for every plan except
go/free, so an unfamiliar plan_type with a finite weekly percentage cleared the
cooldown on evidence we could not interpret — routing traffic to an account
that may still be restricted in a window we never read. Recognized plans are
now enumerated and anything else retains the cooldown, which only costs a delay
since it expires on its own.

Three tests also passed vacuously. Both lease-release cases asserted only that
the cooldown survived, never that the lease came back — a stranded lease means
that account is never probed again, which is worse than the bug. They now
require a later pass to reach WHAM and clear it. The fairness test spaced its
passes 60s apart, inside the 5-minute probe interval, so already-probed
accounts dropped out on their own and the ordering was never exercised; it now
spaces past the interval so served and unserved accounts genuinely compete, and
it fails when the oldest-first sort is removed.
The hand-written weekly set missed prolite, which the upstream model snapshot
enumerates alongside the other eight plans. A recovered prolite account would
have failed the completeness check and stayed cooled — reintroducing the exact
defect this change exists to fix, through an incomplete list rather than a
wrong rule. The test now loops every weekly plan the snapshot names.
…list (#915)

The allowlist was the wrong shape, not just incomplete. The upstream snapshot
carries 21 distinct plan strings — edu_plus, finserv, k12, quorum,
self_serve_business_usage_based and eight more — and CodexAccount.plan is an
unrestricted string, so any hand-written list is a list of the plans someone
remembered. Twelve real plans would have been refused recovery and stayed
cooled forever: the exact defect this unit exists to fix, reintroduced as a
typo-shaped hole. Adding prolite fixed one name and left eleven.

Extracted codexQuotaWindowForPlan() as the single rule and routed parsing,
exhaustion, and recovery through it, so the three cannot drift. Recovery still
fails closed on missing EVIDENCE — credits-only and windowless payloads never
clear a cooldown — which is the guard that actually protects a restricted
account. Failing closed on an unfamiliar plan NAME only ever meant cooled
forever.

The test now derives its plan set from the snapshot instead of restating a
list, so a newly added plan is covered without anyone remembering to update it.
… under test

The data-derived loop computed its own expectation from codexQuotaWindowForPlan(),
so it only proved the function agrees with itself — ablating the rule to always
return weekly still passed 18/18. The contract is now stated in literals first
(go/free monthly; plus, pro, prolite, team, business, enterprise, edu, finserv,
k12, absent, empty weekly; free_workspace is not free), and that ablation now
fails.
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^dev$
  • ^preview$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f8587d88-7d74-41de-9dc1-51211afb33a7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@lidge-jun

Copy link
Copy Markdown
Owner Author

Stack navigation

  1. stack 1/5: triage the open issue surface and lock the bug plan #951 — triage the open issue surface and lock the bug plan (base dev)
  2. stack 2/5: price long-context requests at the published long rate (#908) #952 — long-context pricing tiers, Cost estimates ignore published long-context pricing tiers (OpenAI >272k, xAI >=200k) #908 (base stack 1/5: triage the open issue surface and lock the bug plan #951)
  3. stack 3/5: carry six contributor bug fixes with authorship intact #953 — carry six contributor bug fixes (base stack 2/5: price long-context requests at the published long rate (#908) #952)
  4. stack 4/5: keep an explicit thinking disable through translation (#545) #954 — Claude Desktop classifier thinking round-trip, Claude Desktop 3P Auto Mode classifier retries after 64-token Anthropic OAuth outputs #545 (base stack 3/5: carry six contributor bug fixes with authorship intact #953)
  5. stack 5/5: probe reset-derived cooldowns without waiting to be selected (#915) #955 — cooldown early-recovery probe, [Bug]: Reset-derived cooldowns can miss early recovery while another pool account remains eligible #915 (base stack 4/5: keep an explicit thinking disable through translation (#545) #954)

Review and merge bottom-up. Each PR targets the preceding stack branch, so its Files changed view contains only that layer.

The layers touch disjoint files — devlog/, src/usage/, the carried contributors' paths, src/claude/ + src/adapters/anthropic.ts, and src/codex/ — so any layer can be retargeted to dev and taken independently without a rebase conflict.

#954 needs human security review per MAINTAINERS.md: it changes request construction on an Anthropic OAuth execution path. #955 sits above it in the chain but is independent of it in code, so if that review blocks, #955 can be retargeted to #953 without conflict.

Carried in #953, with authorship preserved: #939, #942, #943, #944, #945, #948.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 493329df01

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/codex/quota.ts
Comment on lines +106 to +108
const required = codexQuotaWindowForPlan(plan) === "monthly"
? quota.monthlyPercent
: quota.weeklyPercent;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Handle duration-classified monthly recovery snapshots

For Team and other non-Free/Go plans, WHAM can legitimately return only an explicitly monthly primary window; parseUsageQuota() then produces only monthlyPercent, as covered by tests/rate-limit-reset-credits.test.ts:121-132. This helper nevertheless always requires weeklyPercent for those plans, so every successful background probe is rejected and the reset-derived cooldown remains until its predicted expiry, potentially keeping a single-account pool unavailable for a long monthly window. Determine completeness from the window actually classified in the fresh response rather than from the plan name alone.

Useful? React with 👍 / 👎.

Comment thread src/codex/routing.ts
Comment on lines +511 to +512
&& claim.credentialGeneration === proof.credentialGeneration
&& isCodexAccountGenerationLive(claim.accountId, claim.credentialGeneration);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Allow the probe's own token refresh generation

When a cooled account's access token is near expiry, getValidCodexToken() refreshes it and increments the credential generation before performing the WHAM request (src/codex/account-store.ts:415-421,467). The returned fresh quota is therefore proven under the new live generation, but this equality compares it with the pre-refresh claim generation and rejects the recovery, leaving the account cooled for another probe interval despite a successful reading. Preserve fencing against external credential replacement while recognizing a generation transition performed by this probe's own refresh.

Useful? React with 👍 / 👎.

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.

1 participant