From e1e656c2a3564947083a181c465a5b9453957c56 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sat, 8 Aug 2026 18:58:36 +0900 Subject: [PATCH] fix(codex): honor scoped cooldowns in subagent fallback --- src/codex/routing.ts | 9 +++++++++ src/codex/subagent-model-fallback.ts | 16 ++++++++++------ tests/subagent-model-fallback.test.ts | 17 +++++++++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 8510957abf..e1352baccc 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -589,6 +589,15 @@ export function tryAcquireCodexQuotaScopeProbeLease( return probeLeaseId; } +/** Side-effect-free check for a confirmed model-specific quota probe. */ +export function canAcquireCodexQuotaScopeProbeLease( + accountId: string, + scope: CodexQuotaScope, + now = Date.now(), +): boolean { + return canAcquireQuotaProbeLease(scopedHealthFor(accountId, scope), now); +} + /** * Hand a probe lease back without recording an upstream outcome. Used by paths * that take a lease and then fail before any request reaches upstream. diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 9d37829008..04de4439d3 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -15,11 +15,11 @@ import { CODEX_HOME, getCodexHome } from "./paths"; import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; import { canAcquireCodexQuotaProbeLease, + canAcquireCodexQuotaScopeProbeLease, codexQuotaScopeForModel, computeCodexUsageScore, getCodexQuotaHealthSnapshot, getPoolAccountPlan, - isCodexAccountInCooldown, } from "./routing"; import { isCodexAccountUsable, @@ -233,11 +233,15 @@ export function isSubagentModelUnavailable( // advances instead of selecting a candidate that exact auth will reject. const quotaScope = codexQuotaScopeForModel(route.modelId); if (getCodexQuotaHealthSnapshot(resolvedAccountId, quotaScope, now) !== null) return true; - } else if ( - isCodexAccountInCooldown(resolvedAccountId, now) - && !canAcquireCodexQuotaProbeLease(resolvedAccountId, now) - ) { - return true; + } else { + const quotaScope = codexQuotaScopeForModel(route.modelId); + const cooldown = getCodexQuotaHealthSnapshot(resolvedAccountId, quotaScope, now); + if (cooldown !== null) { + const probeAvailable = cooldown.quotaScope + ? canAcquireCodexQuotaScopeProbeLease(resolvedAccountId, cooldown.quotaScope, now) + : canAcquireCodexQuotaProbeLease(resolvedAccountId, now); + if (!probeAvailable) return true; + } } return isNativeModelQuotaExhausted(model, config, accountId, now); } diff --git a/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index 6eb69b2179..a00bb31dab 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -295,6 +295,23 @@ describe("subagent model fallback chain", () => { }); }); + test("pool fallback skips a reset-derived cooldown in the model's quota scope", () => { + const now = 1_800_000_000_000; + updateAccountQuota("pool-a", 10, undefined, 20); + const config = cfg({ subagentModelFallback: ["kimi/k3"] }); + recordCodexUpstreamOutcome(config, "pool-a", 429, { + modelId: "gpt-5.6-sol", + now, + resetAt: Math.floor((now + 60 * 60_000) / 1_000), + }); + + expect(selectAvailableSubagentModel("gpt-5.6-sol", config, [], "pool-a", now + 1)).toEqual({ + model: "kimi/k3", + rewritten: true, + skipped: ["gpt-5.6-sol"], + }); + }); + test("account selector fallbacks still reject invalid or disabled native models", () => { resetSubagentModelFallbackStateForTests(); updateAccountQuota("pool-a", 95, undefined, 20);