Skip to content

Commit b4b3cf6

Browse files
committed
fix(codex): keep scope isolation after the soft avoid expires
Suppressing the shared promotion at the moment of the failure only covered one of the two places it happens. The failure streak lives for five minutes but the soft avoid clears in thirty seconds, so there is a window where the account is selectable again while shouldFailover still trips. A scoped resolve reaches applyFailureFailover in that window, and it took no quotaScope at all -- it promoted and persisted a new shared active account, and picked its alternate from shared-scope eligibility rather than the scope's own. Thread the scope through and guard the promotion. The scope still routes away from the failing account, which is its own decision to make; it just no longer moves the cursor every other scope resolves from. Found by CodeRabbit on #715.
1 parent cf44228 commit b4b3cf6

2 files changed

Lines changed: 41 additions & 4 deletions

File tree

src/codex/routing.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,11 +1116,22 @@ function shouldFailover(config: OcxConfig, accountId: string, now: number): bool
11161116
return !!health && health.consecutiveFailures >= threshold;
11171117
}
11181118

1119-
function applyFailureFailover(config: OcxConfig, active: string, now: number): string {
1119+
function applyFailureFailover(
1120+
config: OcxConfig,
1121+
active: string,
1122+
now: number,
1123+
quotaScope?: CodexQuotaScope,
1124+
): string {
11201125
if (!shouldFailover(config, active, now)) return active;
1121-
const best = pickAlternateCodexAccount(config, active, now);
1126+
const best = pickAlternateCodexAccount(config, active, now, quotaScope);
11221127
if (best) {
1123-
promoteActiveCodexAccount(config, best);
1128+
// The scope still routes away from the failing account — that is this request's
1129+
// own decision — but an independent one must not persist a new shared active
1130+
// account. recordCodexUpstreamOutcome only suppresses the promotion it makes at
1131+
// the moment of the failure; the streak outlives the soft avoid, so a later
1132+
// scoped resolve reaches here with the streak still tripped and would otherwise
1133+
// move the shared cursor after all.
1134+
if (!isIndependentCodexQuotaScope(quotaScope)) promoteActiveCodexAccount(config, best);
11241135
return best;
11251136
}
11261137
return active;
@@ -1309,7 +1320,7 @@ export function resolveCodexAccountForThreadDetailed(
13091320
active = preempted;
13101321
}
13111322
active = applyQuotaAutoSwitch(config, active, now, quotaScope);
1312-
active = applyFailureFailover(config, active, now);
1323+
active = applyFailureFailover(config, active, now, quotaScope);
13131324
if (!isCodexAccountUsable(config, active)) {
13141325
return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" };
13151326
}

tests/codex-pool-rotation.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import {
1919
clearCodexUpstreamHealth,
2020
clearThreadAccountMap,
21+
CODEX_TRANSIENT_SOFT_AVOID_MS,
2122
getEffectiveActiveCodexAccountId,
2223
isCodexAccountInCooldown,
2324
pickAlternateCodexAccount,
@@ -919,6 +920,31 @@ describe("selection order across rotation strategies", () => {
919920
expect(config.activeCodexAccountId).toBe("a");
920921
});
921922

923+
test("an independent scope still leaves shared routing alone once its soft avoid expires", () => {
924+
// Suppressing the promotion at the moment of the failure is not enough: the
925+
// failure streak outlives the soft avoid, so there is a window where the account
926+
// is selectable again while shouldFailover still trips. A scoped resolve lands in
927+
// applyFailureFailover there, and that is the second place shared state can move.
928+
const config = makeThreeAccountConfig({
929+
activeCodexAccountId: "a",
930+
upstreamFailoverThreshold: 1,
931+
} as Partial<OcxConfig>);
932+
primeAllQuota();
933+
const failedAt = Date.now();
934+
935+
recordCodexUpstreamOutcome(config, "a", 503, {
936+
modelId: "gpt-5.3-codex-spark",
937+
now: failedAt,
938+
});
939+
940+
// Past the 30s soft avoid, inside the 5-minute failure window.
941+
const afterSoftAvoid = failedAt + CODEX_TRANSIENT_SOFT_AVOID_MS + 1_000;
942+
resolveCodexAccountForThread(null, config, afterSoftAvoid, "spark");
943+
944+
expect(getEffectiveActiveCodexAccountId(config)).toBe("a");
945+
expect(config.activeCodexAccountId).toBe("a");
946+
});
947+
922948
test.each([
923949
["quota", ["a", "a", "a", "a", "a", "a"]],
924950
["round-robin", ["a", "b", "c", "a", "b", "c"]],

0 commit comments

Comments
 (0)