From bd981dac9311630d7553cab33a3ae50ceae54760 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 20:26:26 +0900 Subject: [PATCH] fix(codex): let clear-cooldown lift an avoidance the cooldown outlived MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clearCodexAccountCooldown returned before it looked at quotaAvoidUntil whenever cooldownUntil had already passed. The two durations have different lengths on purpose — the cooldown caps at fifteen minutes, the announced 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. The call was a no-op there, and the dashboard reported cleared: false while selection kept passing the account over for hours. Treat a live avoidance window as its own reason to act, and keep the existing contract otherwise: failure counters and softAvoid still survive. --- src/codex/routing.ts | 12 ++++++++-- tests/codex-integration/codex-routing.test.ts | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) 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;