From 725a352e7a1fa26d1e636e533540d6cbcef79d07 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 15:31:34 +0900 Subject: [PATCH 1/7] feat(codex): record why a live binding was kept, moved, or released (#4546) logCtx.affinity was typed and persisted but never assigned, and routing had no reason to report, so an account move was only visible by comparing account labels across log lines. resolveCodexAccountForThreadDetailed now returns the decision and its cause, the pool auth context carries it, and the usage entry persists both move and reason. --- src/codex/auth-context.ts | 8 ++ src/codex/routing.ts | 81 ++++++++++++++++--- src/server/request-log.ts | 10 ++- src/server/responses/core.ts | 7 ++ .../codex-pool-rotation.test.ts | 40 +++++++++ 5 files changed, 131 insertions(+), 15 deletions(-) diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 32d124b7fd..fffb992a44 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -38,6 +38,7 @@ import { tryAcquireCodexQuotaScopeProbeLease, pickAlternateCodexAccount, resolveCodexAccountForThreadDetailed, + type CodexAffinityDecision, } from "./routing"; import { entitledCodexAccountIdsForModel, @@ -137,6 +138,8 @@ export type CodexAuthContext = probeLeaseId?: string; /** Native model quota group selected for this request, when known. */ quotaScope?: CodexQuotaScope; + /** What happened to this thread's binding on this request (#4546). */ + affinityDecision?: CodexAffinityDecision; /** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */ probeQuotaScope?: CodexQuotaScope; } @@ -845,6 +848,9 @@ export async function resolveCodexAuthContext( // and may still route to non-main pool accounts without touching switch state. if (reserve && !nativeMainReadsForbidden && !selectionAdmission) throw new CodexMainProfileDrainingError(); if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState(); + // Why this request is on this account, carried to the request log so a move is visible as + // an event rather than inferred from account labels across lines (#4546). + let affinityDecision: CodexAffinityDecision | undefined; const resolution = fixedAccountId !== undefined ? { status: "selected" as const, accountId: fixedAccountId } : options.excludeAccountId @@ -870,6 +876,7 @@ export async function resolveCodexAuthContext( ); if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId); const selected = resolution.status === "selected" ? resolution.accountId : null; + affinityDecision = resolution.affinity; if (!selected) { // A retry that excluded a failed Pool account may still use the validated caller-owned // main credential. Treating every exclusion as if main itself had failed strands a healthy @@ -1047,6 +1054,7 @@ export async function resolveCodexAuthContext( ...(quotaScope ? { quotaScope } : {}), ...(probeLeaseId ? { probeLeaseId } : {}), ...(probeQuotaScope ? { probeQuotaScope } : {}), + ...(affinityDecision ? { affinityDecision } : {}), }; } diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 321d267aae..35a22b9b70 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -59,9 +59,52 @@ type ThreadAffinityEntry = { }; export type CodexThreadResolution = - | { status: "selected"; accountId: string } - | { status: "none" } - | { status: "expired"; accountId: string }; + | { status: "selected"; accountId: string; affinity?: CodexAffinityDecision } + | { status: "none"; affinity?: CodexAffinityDecision } + | { status: "expired"; accountId: string; affinity?: CodexAffinityDecision }; + +/** What happened to this thread's binding on this request (#4546). */ +export type CodexAffinityMove = + /** Served by its own bound account, which was healthy. */ + | "reused" + /** Served by its own bound account while something transient was wrong with it. */ + | "held" + /** Served by another account while the binding stayed put. */ + | "detour" + /** The binding was released and a different account took the thread. */ + | "rebound" + /** There was no live binding; this request established one. */ + | "new_bind" + /** The binding was released without a replacement on this request. */ + | "cleared"; + +/** + * Why. A move is the expensive event -- it discards the prompt-cache prefix warmed on the old + * account -- so the operator should not have to infer it from account labels across log lines, + * which is how #4546 had to be diagnosed. + */ +export type CodexAffinityReason = + | "healthy" + | "quota_headroom" + | "quota_refusal" + | "transient" + | "transient_hold_expired" + | "unusable" + | "generation" + | "expired" + | "model_lane"; + +export interface CodexAffinityDecision { + move: CodexAffinityMove; + reason: CodexAffinityReason; +} + +/** The decision to report once a binding has been released and selection starts over. */ +function affinityAfterRelease(releaseReason: CodexAffinityReason | undefined): CodexAffinityDecision { + return releaseReason === undefined + ? { move: "new_bind", reason: "healthy" } + : { move: "rebound", reason: releaseReason }; +} /** * Process-local cursor for automatic RR/fill-first (and quota-429 when not @@ -2701,9 +2744,9 @@ export function resolveCodexAccountForThreadDetailed( ); if (cooler) { bindModelDetourAffinity(threadId, cooler, now, modelId, quotaScope); - return { status: "selected", accountId: cooler }; + return { status: "selected", accountId: cooler, affinity: { move: "rebound", reason: "model_lane" } }; } - return { status: "selected", accountId: detourEntry.accountId }; + return { status: "selected", accountId: detourEntry.accountId, affinity: { move: "reused", reason: "model_lane" } }; } // The model lane gets the same transient hold as the ordinary one. Without it a // model-scoped request drops its detour pin on three 503s and falls back to an ordinary @@ -2717,13 +2760,13 @@ export function resolveCodexAccountForThreadDetailed( detourEntry.lastUsedAt = now; if (lane !== null && lane !== detourEntry.accountId) { detourEntry.transientDetourAccountId = lane; - return { status: "selected", accountId: lane }; + return { status: "selected", accountId: lane, affinity: { move: "detour", reason: "transient" } }; } // A provider-wide outage soft-avoids every sibling, so there is nowhere to detour. // That is a statement about where this request can go, not about who owns the // conversation: dropping the pin here would rebuild the cold prefix elsewhere for // exactly the failure mode the hold exists to survive. - return { status: "selected", accountId: detourEntry.accountId }; + return { status: "selected", accountId: detourEntry.accountId, affinity: { move: "held", reason: "transient" } }; } // Detour expiry or invalidation must not expire the ordinary task. Drop only // this model lane and select from ordinary/shared state below. @@ -2731,11 +2774,14 @@ export function resolveCodexAccountForThreadDetailed( } } + // Why the binding went away, when it did. Carried to the selection below so the request that + // pays for a cold prefix can say what it paid for. + let releaseReason: CodexAffinityReason | undefined; const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined; if (threadId && entry) { if (isThreadAffinityExpired(entry, now)) { deleteThreadAffinity(threadId, quotaScope); - return { status: "expired", accountId: entry.accountId }; + return { status: "expired", accountId: entry.accountId, affinity: { move: "cleared", reason: "expired" } }; } const generationLive = isThreadAffinityGenerationLive(entry); const selectableForSharedState = generationLive @@ -2779,9 +2825,9 @@ export function resolveCodexAccountForThreadDetailed( promoteActiveCodexAccount(config, cooler); } bindThreadAffinity(threadId, cooler, now, quotaScope); // rebinds + resets clocks - return { status: "selected", accountId: cooler }; + return { status: "selected", accountId: cooler, affinity: { move: "rebound", reason: "quota_headroom" } }; } - return { status: "selected", accountId: entry.accountId }; + return { status: "selected", accountId: entry.accountId, affinity: { move: "reused", reason: "healthy" } }; } // Transient trouble on the bound account is a reason to send elsewhere, not a reason to // give up the conversation. Detour this request and KEEP the binding, so recovery is free @@ -2798,16 +2844,25 @@ export function resolveCodexAccountForThreadDetailed( entry.transientDetourAccountId = detour; // Deliberately no promoteActiveCodexAccount and no rebind: this is one request routing // around a blip, not the pool deciding where the conversation now lives. - return { status: "selected", accountId: detour }; + return { status: "selected", accountId: detour, affinity: { move: "detour", reason: "transient" } }; } // No sibling can take it either -- the usual shape of a provider-wide 503. The binding // survives: "cannot send right now" and "forget which account owns this conversation" // are different answers, and conflating them is what the hold was added to stop. - return { status: "selected", accountId: entry.accountId }; + return { status: "selected", accountId: entry.accountId, affinity: { move: "held", reason: "transient" } }; } // A model-only exclusion does not invalidate the shared task binding. Health, // generation, pause, cooldown, and failure evidence still retire it normally. if (!modelScopedSelection || !healthyForSharedAffinity) { + releaseReason = !generationLive + ? "generation" + : quotaRefused + ? "quota_refusal" + : isTransientHoldExpired(entry, now) + ? "transient_hold_expired" + : !isCodexAccountUsable(config, entry.accountId, selectionOptions) + ? "unusable" + : "quota_headroom"; deleteThreadAffinity(threadId, quotaScope); } else { preserveExistingModelScopedAffinity = true; @@ -2853,7 +2908,7 @@ export function resolveCodexAccountForThreadDetailed( // the thing the preference exists to protect. promoteActiveCodexAccount(config, strategyPick); } - return { status: "selected", accountId: strategyPick }; + return { status: "selected", accountId: strategyPick, affinity: affinityAfterRelease(releaseReason) }; } let active = getEffectiveActiveCodexAccountId(config); diff --git a/src/server/request-log.ts b/src/server/request-log.ts index c77db6cbc0..f09cb060b7 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -12,6 +12,7 @@ import { upstreamErrorMessageFromPayload, } from "../lib/errors"; import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths"; +import type { CodexAffinityMove, CodexAffinityReason } from "../codex/routing"; import { readCodexCatalogPath } from "../codex/catalog"; import type { AttemptTierOutcome, OcxProviderConfig, OcxUsage } from "../types"; import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace"; @@ -139,7 +140,9 @@ export interface RequestLogContext { errorCode?: string; /** Structured reason from `response.incomplete`; internal-only input to log classification. */ terminalIncompleteReason?: string; - affinity?: "reused" | "new_bind" | "rebound" | "cleared"; + affinity?: CodexAffinityMove; + /** Why the binding was kept, moved, or released (#4546). */ + affinityReason?: CodexAffinityReason; transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse"; terminalSource?: "upstream" | "synthetic"; /** Bounded route-decision trace (RI-01); never contains secrets. */ @@ -204,7 +207,9 @@ export interface RequestLogEntry { totalTokens?: number; attempts?: PersistedUsageAttempt[]; /** Codex pool affinity decision for this request (diagnostics for #186). */ - affinity?: "reused" | "new_bind" | "rebound" | "cleared"; + affinity?: CodexAffinityMove; + /** Why that decision was made (#4546): a move is the expensive event, so it names its cause. */ + affinityReason?: CodexAffinityReason; /** Where the upstream terminal/failure was observed. */ transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse"; /** Whether the terminal came from a real upstream SSE event or a proxy synthetic tail. */ @@ -1080,6 +1085,7 @@ export function addFinalRequestLog( ...(totalTokens !== undefined ? { totalTokens } : {}), ...(attempts !== undefined ? { attempts } : {}), ...(logCtx.affinity ? { affinity: logCtx.affinity } : {}), + ...(logCtx.affinityReason ? { affinityReason: logCtx.affinityReason } : {}), ...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}), ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}), ...(logCtx.routeDecision ? { routeDecision: logCtx.routeDecision } : {}), diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 8aaf92227c..ec1b583ed3 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -4214,6 +4214,13 @@ async function handleResponsesInner( ? `${route.providerName}-${route.codexAccountNamespace}` : formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config); logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config); + // A move is the expensive event: it discards the prefix warmed on the previous account. Record + // it as an event with its cause, so the operator reads it off one line instead of inferring it + // from account labels across many (#4546). + if (authCtx.kind === "pool" && authCtx.affinityDecision) { + logCtx.affinity = authCtx.affinityDecision.move; + logCtx.affinityReason = authCtx.affinityDecision.reason; + } // Seed an account-derived scope before final adapter binding. Cursor never treats it as // authoritative: bindRouteReasoningReplayScope replaces it with the exact route owner or a // per-request fail-closed sentinel after the final provider and credential are known. diff --git a/tests/codex-integration/codex-pool-rotation.test.ts b/tests/codex-integration/codex-pool-rotation.test.ts index 335e5e183e..54cb28f739 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -32,6 +32,7 @@ import { reconcileCodexRoutingHealth, resetCodexRoutingForManualSelection, resolveCodexAccountForThread, + resolveCodexAccountForThreadDetailed, } from "../../src/codex/routing"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/account-id"; @@ -1499,6 +1500,45 @@ describe("selection order across rotation strategies", () => { expect(served).not.toBe("a"); }); + test("every binding decision records what happened and why (#4546)", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + upstreamFailoverThreshold: 3, + }); + const threadId = "affinity-reason-thread"; + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + updateAccountQuota("c", 30); + const start = Date.now(); + + // A thread with no binding yet is a placement, not a move. + expect(resolveCodexAccountForThreadDetailed(threadId, config, start)).toMatchObject({ + accountId: "a", + affinity: { move: "new_bind", reason: "healthy" }, + }); + // Served by its own healthy account. + expect(resolveCodexAccountForThreadDetailed(threadId, config, start)).toMatchObject({ + affinity: { move: "reused", reason: "healthy" }, + }); + + // A transient streak sends this request elsewhere while the binding stays put. + recordCodexUpstreamOutcome(config, "a", 503, { now: start }); + recordCodexUpstreamOutcome(config, "a", 503, { now: start }); + recordCodexUpstreamOutcome(config, "a", 503, { now: start }); + expect(resolveCodexAccountForThreadDetailed(threadId, config, start)).toMatchObject({ + accountId: "b", + affinity: { move: "detour", reason: "transient" }, + }); + + // A quota refusal is the account telling this thread it cannot serve, so the binding goes + // and the record names which cause fired instead of leaving it to be inferred. + recordCodexUpstreamOutcome(config, "a", 429, { now: start }); + expect(resolveCodexAccountForThreadDetailed(threadId, config, start).affinity) + .toMatchObject({ move: "rebound", reason: "quota_refusal" }); + }); + test("a transient block with nowhere to detour keeps the binding", () => { const config = makeThreeAccountConfig({ accountPoolStrategy: "quota", From aa5d441e529205838ee14e537db9be2c95965c20 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 15:33:55 +0900 Subject: [PATCH 2/7] fix(codex): hoist the affinity decision to the pool context scope The declaration sat inside the selection block and the spread landed on the main-pool return, so the pool context never carried it and typecheck failed. Reading resolution.affinity through an in-check keeps the fixed-account branch of the union valid. --- src/codex/auth-context.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index fffb992a44..7b0eb72ecc 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -801,6 +801,9 @@ export async function resolveCodexAuthContext( const affinityKey = fixedAccountId === undefined && !requestScopedMainCredential ? codexPoolAffinityKey(headers) : undefined; + // Why this request is on this account, carried to the request log so a move reads as an event + // instead of something inferred from account labels across lines (#4546). + let affinityDecision: CodexAffinityDecision | undefined; // Retained startup recovery makes the physical main identity ineligible. Routing // can still preserve service by selecting a healthy configured pool account. A // request-owned bearer likewise cannot inspect or reconcile file-main state. @@ -848,9 +851,6 @@ export async function resolveCodexAuthContext( // and may still route to non-main pool accounts without touching switch state. if (reserve && !nativeMainReadsForbidden && !selectionAdmission) throw new CodexMainProfileDrainingError(); if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState(); - // Why this request is on this account, carried to the request log so a move is visible as - // an event rather than inferred from account labels across lines (#4546). - let affinityDecision: CodexAffinityDecision | undefined; const resolution = fixedAccountId !== undefined ? { status: "selected" as const, accountId: fixedAccountId } : options.excludeAccountId @@ -876,7 +876,7 @@ export async function resolveCodexAuthContext( ); if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId); const selected = resolution.status === "selected" ? resolution.accountId : null; - affinityDecision = resolution.affinity; + affinityDecision = "affinity" in resolution ? resolution.affinity : undefined; if (!selected) { // A retry that excluded a failed Pool account may still use the validated caller-owned // main credential. Treating every exclusion as if main itself had failed strands a healthy @@ -1054,7 +1054,6 @@ export async function resolveCodexAuthContext( ...(quotaScope ? { quotaScope } : {}), ...(probeLeaseId ? { probeLeaseId } : {}), ...(probeQuotaScope ? { probeQuotaScope } : {}), - ...(affinityDecision ? { affinityDecision } : {}), }; } @@ -1074,6 +1073,7 @@ export async function resolveCodexAuthContext( ...(quotaScope ? { quotaScope } : {}), ...(probeLeaseId ? { probeLeaseId } : {}), ...(probeQuotaScope ? { probeQuotaScope } : {}), + ...(affinityDecision ? { affinityDecision } : {}), }; } catch (cause) { if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId); From cfe295d45dd9887fccad2bab8c6eb5da741965ea Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 15:39:31 +0900 Subject: [PATCH 3/7] fix(codex): report the affinity decision on every selection path A first placement returned through the active-account retention path, which carried no decision, so the record was missing for exactly the case that establishes a binding. All selection returns now report, and the detailed-resolver assertions move to toMatchObject because the resolution carries a field they did not previously expect. --- src/codex/routing.ts | 12 ++--- tests/codex-integration/codex-routing.test.ts | 54 +++++++++---------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 35a22b9b70..23d23198ad 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -2919,7 +2919,7 @@ export function resolveCodexAccountForThreadDetailed( selectionOptions?.nativeMainSelectionOnly === true && selectionOptions.modelEligibleAccountIds !== undefined ) { - return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }; + return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID, affinity: affinityAfterRelease(releaseReason) }; } return { status: "none" }; } @@ -2957,13 +2957,13 @@ export function resolveCodexAccountForThreadDetailed( // return main only as a non-mutating sentinel so the caller's atomic claim can // classify maintenance. Do not fall through to the configured-but-ineligible // active account or persist/bind this synthetic selection. - return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }; + return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID, affinity: affinityAfterRelease(releaseReason) }; } else if ( hasConfiguredPoolAccount(config, active, selectionOptions) && !isCodexAccountPaused(config, active) && !isCodexAccountPlanExcluded(config, active) ) { - return { status: "selected", accountId: active }; + return { status: "selected", accountId: active, affinity: affinityAfterRelease(releaseReason) }; } else { return { status: "none" }; } @@ -3005,13 +3005,13 @@ export function resolveCodexAccountForThreadDetailed( ); if (!isCodexAccountUsable(config, active, selectionOptions)) { return hasConfiguredPoolAccount(config, active, selectionOptions) - ? { status: "selected", accountId: active } + ? { status: "selected", accountId: active, affinity: affinityAfterRelease(releaseReason) } : { status: "none" }; } if (isCodexAccountPaused(config, active)) return { status: "none" }; if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) { return hasConfiguredPoolAccount(config, active, selectionOptions) - ? { status: "selected", accountId: active } + ? { status: "selected", accountId: active, affinity: affinityAfterRelease(releaseReason) } : { status: "none" }; } if (threadId) { @@ -3021,7 +3021,7 @@ export function resolveCodexAccountForThreadDetailed( bindThreadAffinity(threadId, active, now, quotaScope); } } - return { status: "selected", accountId: active }; + return { status: "selected", accountId: active, affinity: affinityAfterRelease(releaseReason) }; } export function recordCodexUpstreamOutcome( diff --git a/tests/codex-integration/codex-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index 44160ec8b0..31f13f5bdd 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -1690,13 +1690,13 @@ describe("codex routing", () => { updateAccountQuota("b", 10); const now = 1_800_000_000_000; expect(resolveCodexAccountForThreadDetailed("expired-detailed", config, now)) - .toEqual({ status: "selected", accountId: "a" }); + .toMatchObject({ status: "selected", accountId: "a" }); expect(resolveCodexAccountForThreadDetailed( "expired-detailed", config, now + CODEX_THREAD_AFFINITY_IDLE_TTL_MS + 1, - )).toEqual({ status: "expired", accountId: "a" }); + )).toMatchObject({ status: "expired", accountId: "a" }); }); test("thread affinity LRU cap evicts the oldest mapping", () => { @@ -2469,7 +2469,7 @@ describe("codex account selection order", () => { now + 1, "shared", { modelEligibleAccountIds: new Set(["a"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBe("b"); @@ -2516,7 +2516,7 @@ describe("codex account selection order", () => { eligible, modelId, ); - expect(first).toEqual({ status: "selected", accountId: firstPreview }); + expect(first).toMatchObject({ status: "selected", accountId: firstPreview }); expect(["a", "c"]).toContain(firstPreview); expect(previewCodexAccountForRequest( @@ -2549,7 +2549,7 @@ describe("codex account selection order", () => { "shared", { modelEligibleAccountIds: new Set([other]) }, modelId, - )).toEqual({ status: "selected", accountId: other }); + )).toMatchObject({ status: "selected", accountId: other }); expect(resolveCodexAccountForThreadDetailed( threadId, config, @@ -2557,7 +2557,7 @@ describe("codex account selection order", () => { "shared", { modelEligibleAccountIds: new Set(["a", "b", "c"]) }, modelId, - )).toEqual({ status: "selected", accountId: other }); + )).toMatchObject({ status: "selected", accountId: other }); expect(resolveCodexAccountForThread(threadId, config, now + 6, "shared")).toBe("b"); }); @@ -2588,7 +2588,7 @@ describe("codex account selection order", () => { "shared", { modelEligibleAccountIds: new Set(["a"]) }, modelId, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); // B is the highest tier after the detour exists. Filtering only after tier // selection would drop B without ever exposing healthy C to the picker. config.codexAccountPriorities = { b: 2, c: 1 }; @@ -2618,7 +2618,7 @@ describe("codex account selection order", () => { "shared", eligible, modelId, - )).toEqual({ status: "selected", accountId: "c" }); + )).toMatchObject({ status: "selected", accountId: "c" }); expect(config.activeCodexAccountId).toBe("c"); expect(config.activeCodexAccountPinned).toBeUndefined(); expect(getEffectiveActiveCodexAccountId(config)).toBe("c"); @@ -2659,7 +2659,7 @@ describe("codex account selection order", () => { config, resolveAt, "shared", - )).toEqual({ status: "selected", accountId: "c" }); + )).toMatchObject({ status: "selected", accountId: "c" }); expect(config.activeCodexAccountId).toBe("c"); expect(getEffectiveActiveCodexAccountId(config)).toBe("c"); }); @@ -2801,7 +2801,7 @@ describe("codex account selection order", () => { "shared", eligible, "gated-model-0", - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); for (let index = 1; index <= CODEX_THREAD_AFFINITY_MAX_ENTRIES; index += 1) { expect(resolveCodexAccountForThreadDetailed( threadId, @@ -2829,7 +2829,7 @@ describe("codex account selection order", () => { "shared", eligible, "gated-model-0", - )).toEqual({ status: "selected", accountId: "c" }); + )).toMatchObject({ status: "selected", accountId: "c" }); expect(resolveCodexAccountForThreadDetailed( threadId, config, @@ -2837,7 +2837,7 @@ describe("codex account selection order", () => { "shared", eligible, "gated-model-0", - )).toEqual({ status: "selected", accountId: "c" }); + )).toMatchObject({ status: "selected", accountId: "c" }); }, STORE_BUDGET_MS); test("a gated first request binds its actual account without replacing global active", () => { @@ -2852,7 +2852,7 @@ describe("codex account selection order", () => { now, "shared", { modelEligibleAccountIds: new Set(["a"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("b"); expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); expect(resolveCodexAccountForThread("gated-first-task", config, now + 1, "shared")).toBe("a"); @@ -2872,14 +2872,14 @@ describe("codex account selection order", () => { Date.now(), "shared", selectionOptions, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(resolveCodexAccountForThreadDetailed( null, config, Date.now() + 1, "shared", selectionOptions, - )).toEqual({ status: "selected", accountId: "b" }); + )).toMatchObject({ status: "selected", accountId: "b" }); expect(config.activeCodexAccountId).toBe("b"); expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); }); @@ -2906,7 +2906,7 @@ describe("codex account selection order", () => { now + 1, "shared", { modelEligibleAccountIds: new Set(["a"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBe("b"); @@ -2956,7 +2956,7 @@ describe("codex account selection order", () => { now + CODEX_TRANSIENT_SOFT_AVOID_MS + 4, "shared", selectionOptions, - )).toEqual({ status: "selected", accountId: "b" }); + )).toMatchObject({ status: "selected", accountId: "b" }); expect(config.activeCodexAccountId).toBe("c"); expect(config.activeCodexAccountPinned).toBe("c"); expect(getEffectiveActiveCodexAccountId(config)).toBe("c"); @@ -2986,7 +2986,7 @@ describe("codex account selection order", () => { now + 1, "shared", { modelEligibleAccountIds: new Set(["a"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBeUndefined(); expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); @@ -3024,7 +3024,7 @@ describe("codex account selection order", () => { resolveAt, "shared", { modelEligibleAccountIds: new Set(["a"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBeUndefined(); expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); @@ -3064,7 +3064,7 @@ describe("codex account selection order", () => { now, "shared", selectionOptions, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBeUndefined(); expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); @@ -3107,7 +3107,7 @@ describe("codex account selection order", () => { resolveAt, "shared", selectionOptions, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBeUndefined(); expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); @@ -3130,7 +3130,7 @@ describe("codex account selection order", () => { nativeMainSelectionOnly: true, modelEligibleAccountIds: new Set(), }, - )).toEqual({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }); + )).toMatchObject({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }); expect(config.activeCodexAccountPinned).toBeUndefined(); }); @@ -3160,7 +3160,7 @@ describe("codex account selection order", () => { now + CODEX_TRANSIENT_SOFT_AVOID_MS + 3, "shared", { modelEligibleAccountIds: new Set(["a", "c"]) }, - )).toEqual({ status: "selected", accountId: "c" }); + )).toMatchObject({ status: "selected", accountId: "c" }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBe("b"); expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); @@ -3180,7 +3180,7 @@ describe("codex account selection order", () => { Date.now(), "shared", { modelEligibleAccountIds: new Set(["a", "b"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("a"); expect(config.activeCodexAccountPinned).toBeUndefined(); }); @@ -3205,7 +3205,7 @@ describe("codex account selection order", () => { now + CODEX_TRANSIENT_SOFT_AVOID_MS + 3, "shared", { modelEligibleAccountIds: new Set(["a", "b"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("a"); expect(config.activeCodexAccountPinned).toBeUndefined(); }); @@ -3224,7 +3224,7 @@ describe("codex account selection order", () => { Date.now(), "shared", { modelEligibleAccountIds: new Set(["a"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("a"); expect(config.activeCodexAccountPinned).toBeUndefined(); }); @@ -3249,7 +3249,7 @@ describe("codex account selection order", () => { now + CODEX_TRANSIENT_SOFT_AVOID_MS + 3, "shared", { modelEligibleAccountIds: new Set(["a"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("a"); expect(config.activeCodexAccountPinned).toBeUndefined(); }); From bc408e39f516c57159297db2d14a67fc397a7bf5 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 15:45:09 +0900 Subject: [PATCH 4/7] fix(codex): carry a release reason from the outcome path to the next resolve A 429 clears the pin inside recordCodexUpstreamOutcome, so the request that pays for the cold prefix arrived with nothing left to explain why. The reason is now held per thread, bounded, and consumed by that thread next resolve. Two routing cases compared whole resolutions to each other and now compare the account, because a first placement and a later reuse legitimately report different decisions. --- src/codex/routing.ts | 40 +++++++++++++++++-- tests/codex-integration/codex-routing.test.ts | 6 ++- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 23d23198ad..690e0636b4 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -403,17 +403,46 @@ export function clearThreadAccountMap(): void { threadAffinityEntryTotal = 0; } -export function clearThreadAccountMapForAccount(accountId: string): void { +export function clearThreadAccountMapForAccount( + accountId: string, + reason: CodexAffinityReason = "unusable", +): void { for (const [threadId, affinities] of threadAccountMap) { for (const [scope, entry] of affinities) { if (entry.accountId === accountId && affinities.delete(scope)) { threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1); + notePendingReleaseReason(threadId, reason); } } if (affinities.size === 0) threadAccountMap.delete(threadId); } } +/** + * Why a binding was released, held until that thread's next resolve can report it (#4546). + * + * A release and the request that pays for it are two different moments: a 429 clears the pin + * inside the outcome recorder, and the next request arrives with nothing left to explain why it + * is starting cold. Bounded, because it is a diagnostic and must not become a leak. + */ +const pendingReleaseReasons = new Map(); +const MAX_PENDING_RELEASE_REASONS = 4096; + +function notePendingReleaseReason(threadId: string, reason: CodexAffinityReason): void { + if (!pendingReleaseReasons.has(threadId) && pendingReleaseReasons.size >= MAX_PENDING_RELEASE_REASONS) { + const oldest = pendingReleaseReasons.keys().next(); + if (!oldest.done) pendingReleaseReasons.delete(oldest.value); + } + pendingReleaseReasons.set(threadId, reason); +} + +function consumePendingReleaseReason(threadId: string | null): CodexAffinityReason | undefined { + if (threadId === null) return undefined; + const reason = pendingReleaseReasons.get(threadId); + if (reason !== undefined) pendingReleaseReasons.delete(threadId); + return reason; +} + export function clearCodexUpstreamHealth(): void { // Operator preferences are routing state, not health, but they live and die with the same // reset points. Leaving them behind lets a selection from one context suppress the @@ -2868,6 +2897,9 @@ export function resolveCodexAccountForThreadDetailed( preserveExistingModelScopedAffinity = true; } } + // A release recorded by the outcome path (a 429 clears the pin before the next request even + // arrives) is the reason this request is starting cold, so it outranks having found nothing. + releaseReason ??= consumePendingReleaseReason(threadId); // A request-scoped roster may still contain unhealthy candidates. Non-quota strategies return // before the quota/failover helpers below, so prefer only shared-healthy roster members here; @@ -3208,7 +3240,7 @@ export function recordCodexUpstreamOutcome( // The reauth flag carries the same provenance, so a replacement landing after this call cannot // inherit a quarantine that was never about it. markAccountNeedsReauth(accountId, writerGeneration, meta.credentialGeneration); - clearThreadAccountMapForAccount(accountId); + clearThreadAccountMapForAccount(accountId, "quota_refusal"); return; } @@ -3243,7 +3275,7 @@ export function recordCodexUpstreamOutcome( // threads must leave it and new requests should prefer an eligible account. // Reserve remains isolated so a same-account Terra/Luna combo fallback can run. if (quotaScope === "shared" && !meta.fixedAccount) { - clearThreadAccountMapForAccount(accountId); + clearThreadAccountMapForAccount(accountId, "quota_refusal"); notePoolRotationFailure(POOL_KEY_CODEX, accountId); if (getEffectiveActiveCodexAccountId(config) === accountId) { // Same-request 429 retry already picked via excludeAccountId — reuse it so @@ -3289,7 +3321,7 @@ export function recordCodexUpstreamOutcome( }), }); if (!meta.fixedAccount) { - clearThreadAccountMapForAccount(accountId); + clearThreadAccountMapForAccount(accountId, "quota_refusal"); // An independent native quota request may discover an account-wide throttle, // but it still must not advance the shared RR ring or active cursor. The next // shared request observes the cooldown and chooses its own fallback. diff --git a/tests/codex-integration/codex-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index 31f13f5bdd..9cd754da69 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -2534,7 +2534,9 @@ describe("codex account selection order", () => { "shared", eligible, modelId, - )).toEqual(first); + // The resolution now also carries the affinity decision, which legitimately differs + // between a first placement and a later reuse. This case is about the account. + )).toMatchObject({ status: "selected", accountId: firstPreview }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBe("b"); @@ -2710,7 +2712,7 @@ describe("codex account selection order", () => { "shared", eligible, modelId, - )).toEqual(first); + )).toMatchObject({ status: "selected", accountId: first.accountId }); } expect(config.activeCodexAccountPinned).toBe("b"); }); From 168cbfa2d33408b3774b4b98225b77e06fd1e85e Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 15:49:28 +0900 Subject: [PATCH 5/7] fix(test): compare the account, not the whole resolution, for model detour independence --- tests/codex-integration/codex-routing.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/codex-integration/codex-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index 9cd754da69..4df90f48e9 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -2763,7 +2763,7 @@ describe("codex account selection order", () => { "shared", eligible, "gpt-daybreak-blue-latest", - )).toEqual(firstModel); + )).toMatchObject({ status: "selected", accountId: firstModel.accountId }); expect(resolveCodexAccountForThreadDetailed( threadId, config, From 736d5dde7083cf33422fa12c3e5acbce5ab9c53b Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 15:53:25 +0900 Subject: [PATCH 6/7] fix(test): compare the account for the second model detour lane too --- tests/codex-integration/codex-routing.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/codex-integration/codex-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index 4df90f48e9..b769a74c70 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -2771,7 +2771,7 @@ describe("codex account selection order", () => { "shared", eligible, "gpt-other-account-gated", - )).toEqual(secondModel); + )).toMatchObject({ status: "selected", accountId: secondModel.accountId }); } expect(resolveCodexAccountForThread(threadId, config, now + 5, "shared")).toBe("b"); }); From dc805c29da6defb4130107cd25d0d9a56b23bb92 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 15:59:47 +0900 Subject: [PATCH 7/7] fix(test): tolerate the affinity decision in the 401 replay resolution check --- tests/responses/responses-pool-401-refresh.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/responses/responses-pool-401-refresh.test.ts b/tests/responses/responses-pool-401-refresh.test.ts index 609b24d772..88dbfc0afc 100644 --- a/tests/responses/responses-pool-401-refresh.test.ts +++ b/tests/responses/responses-pool-401-refresh.test.ts @@ -605,7 +605,7 @@ describe("ordinary pool 401 refresh and replay (#2887)", () => { // reported as expired, which is the behavior the missing handoff produces. // The binding lives under the model's quota scope, so resolution must be asked in that // same scope; a scopeless read looks in the legacy bucket and finds nothing. - expect(resolveCodexAccountForThreadDetailed(THREAD_ID, cfg, Date.now(), "shared")).toEqual({ + expect(resolveCodexAccountForThreadDetailed(THREAD_ID, cfg, Date.now(), "shared")).toMatchObject({ status: "selected", accountId: ACCOUNT_ID, });