diff --git a/src/codex/routing.ts b/src/codex/routing.ts index deb7a13f19..12c3e5e951 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -1074,12 +1074,20 @@ export function isCodexAccountInCooldown(accountId: string, now = Date.now()): b * bumps it in {@link recordCodexUpstreamOutcome}, so the bump here is not load-bearing * today and is kept so the invariant survives a future change that retains the lease. * - * Returns false when the account carried no live cooldown (already expired or never set). + * Returns false when the account carried neither a live cooldown nor a live avoidance window. + * The window outlives the cooldown by design — the cooldown caps at fifteen minutes and the + * window runs up to six hours — so the moment an operator actually reaches for this escape + * hatch is usually after the cooldown lapsed and only the window is still keeping the account + * out of rotation. Refusing to look at the window then would leave the hatch shut in the one + * case it exists for. */ export function clearCodexAccountCooldown(accountId: string, now = Date.now()): boolean { const clear = (health: CodexUpstreamHealth): CodexUpstreamHealth | null => { const cooldownUntil = health.cooldownUntil; - if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null; + const liveCooldown = typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now; + const avoidUntil = health.quotaAvoidUntil; + const liveAvoidance = typeof avoidUntil === "number" && Number.isFinite(avoidUntil) && avoidUntil > now; + if (!liveCooldown && !liveAvoidance) return null; const { cooldownUntil: _until, cooldownSince: _since, diff --git a/tests/codex-integration/codex-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index 92375ef2fc..72919b8dff 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -1046,6 +1046,28 @@ describe("codex routing", () => { expect(resolveCodexAccountForThread("cleared-after", config, now + 61_000, "spark")).toBe("a"); }); + test("clearing a lapsed cooldown still lifts the avoidance it left behind", () => { + const config = makeConfig(); + const now = 1_800_000_000_000; + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + recordCodexUpstreamOutcome(config, "a", 429, { + now, + modelId: "gpt-5.3-codex-spark", + resetAt: Math.floor((now + 4 * 60 * 60_000) / 1_000), + }); + + // The capped cooldown is already gone and only the announced window is still holding the + // account out, which is exactly when an operator reaches for this button. Reading the + // cooldown alone made the call a no-op for the next several hours. + expect(isCodexAccountInCooldown("a", now + 16 * 60_000)).toBe(false); + expect(resolveCodexAccountForThread("lapsed-before", config, now + 16 * 60_000, "spark")).toBe("b"); + + expect(clearCodexAccountCooldown("a", now + 16 * 60_000)).toBe(true); + + expect(resolveCodexAccountForThread("lapsed-after", config, now + 17 * 60_000, "spark")).toBe("a"); + }); + test("a request the account serves releases the threads its quota refusal moved", () => { const config = makeConfig(); const now = 1_800_000_000_000;