diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 895510d88c4..518fa8c1b76 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -565,6 +565,7 @@ "codex-plugins-doctor.test.ts": "codex-integration", "codex-pool-plan-exclusion.test.ts": "codex-integration", "codex-pool-refresh-backoff.test.ts": "codex-integration", + "codex-pool-request-owned-main.test.ts": "codex-integration", "codex-pool-rotation.test.ts": "codex-integration", "codex-priority-failback.test.ts": "codex-integration", "codex-prompt-adopt.test.ts": "codex-integration", diff --git a/src/codex/routing.ts b/src/codex/routing.ts index e603371af95..403ffa90ca8 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -111,6 +111,7 @@ import { pickUnboundStrategyAccount, preferModelEntitledAccount, sharedStateSelectionOptions, + sharesActiveSelection, strategySelectionOptionsForModelDetour, shouldFailover, peekAlternateCodexAccount, @@ -851,12 +852,6 @@ export function resolveCodexAccountForThreadDetailed( // An entitlement roster constrains only this model request. It must not rewrite // the operator's shared active/pin choice or the task's ordinary-model affinity. const modelScopedSelection = selectionOptions?.modelEligibleAccountIds !== undefined; - // A main that is live only through this request's own bearer serves this request alone; writing - // it back as the shared active account would route later requests through a credential they do - // not carry (see CodexAccountUsabilityOptions.requestOwnedMainCredential). - const sharesActiveSelection = (accountId: string): boolean => !( - accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.requestOwnedMainCredential === true - ); let preserveExistingModelScopedAffinity = false; const sharedSelectionOptions: CodexAccountUsabilityOptions | undefined = modelScopedSelection ? sharedStateSelectionOptions(selectionOptions) ?? {} @@ -988,7 +983,7 @@ export function resolveCodexAccountForThreadDetailed( // rotation is new-session-only (affinity policy A). const cooler = reevaluateAffinityQuota(entry, config, now, quotaScope, selectionOptions); if (cooler) { - if (!isIndependentCodexQuotaScope(quotaScope)) { + if (!isIndependentCodexQuotaScope(quotaScope) && sharesActiveSelection(cooler, selectionOptions)) { promoteActiveCodexAccount(config, cooler); } bindThreadAffinity(threadId, cooler, now, quotaScope); // rebinds + resets clocks @@ -1034,7 +1029,9 @@ export function resolveCodexAccountForThreadDetailed( && !shouldFailover(config, expiredDetour, now) && !isCodexAccountSoftAvoided(expiredDetour, now) ) { - if (!isIndependentCodexQuotaScope(quotaScope)) promoteActiveCodexAccount(config, expiredDetour); + if (!isIndependentCodexQuotaScope(quotaScope) && sharesActiveSelection(expiredDetour, selectionOptions)) { + promoteActiveCodexAccount(config, expiredDetour); + } bindThreadAffinity(threadId, expiredDetour, now, quotaScope); return { status: "selected", @@ -1128,7 +1125,7 @@ export function resolveCodexAccountForThreadDetailed( // process-local cursor to whoever is actually serving and releases the pin; the // operator's persisted activeCodexAccountId is left untouched either way, which is // the thing the preference exists to protect. - if (sharesActiveSelection(strategyPick)) promoteActiveCodexAccount(config, strategyPick); + if (sharesActiveSelection(strategyPick, selectionOptions)) promoteActiveCodexAccount(config, strategyPick); } return { status: "selected", accountId: strategyPick, affinity: affinityAfterRelease(threadId, releaseReason) }; } @@ -1146,7 +1143,7 @@ export function resolveCodexAccountForThreadDetailed( return { status: "none", affinity: affinityOnNoAccount(threadId, releaseReason) }; } if (!isIndependentCodexQuotaScope(quotaScope) && !modelScopedSelection) { - if (sharesActiveSelection(selected)) setActiveCodexAccount(config, selected); + if (sharesActiveSelection(selected, selectionOptions)) setActiveCodexAccount(config, selected); } active = selected; } @@ -1167,7 +1164,7 @@ export function resolveCodexAccountForThreadDetailed( && preserveSharedSelectionForModelDetour && activeHealthyForSharedSelection; if (!isIndependentCodexQuotaScope(quotaScope) && !modelOnlyMove) { - if (sharesActiveSelection(fallback)) setActiveCodexAccount(config, fallback); + if (sharesActiveSelection(fallback, selectionOptions)) setActiveCodexAccount(config, fallback); } active = fallback; } else if ( @@ -1201,6 +1198,7 @@ export function resolveCodexAccountForThreadDetailed( if ( !preserveSharedSelectionForModelDetour && !isIndependentCodexQuotaScope(quotaScope) + && sharesActiveSelection(preempted, selectionOptions) ) { // Preemption is an automatic pick competing with the operator, so it yields. if (!manualPreferenceBlocks(POOL_KEY_CODEX, preempted)) { diff --git a/src/codex/routing/selection.ts b/src/codex/routing/selection.ts index 37d331e896e..b9ffeaf4847 100644 --- a/src/codex/routing/selection.ts +++ b/src/codex/routing/selection.ts @@ -441,7 +441,7 @@ export function pickUnboundStrategyAccount( } picked = pickRoundRobinAccount(poolKey, eligible, limit); if (!picked) return null; - if (commitSharedActive) { + if (commitSharedActive && sharesActiveSelection(picked, selectionOptions)) { if (!isIndependentCodexQuotaScope(quotaScope) && !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) { rememberActiveCodexAccount(config, picked); @@ -457,7 +457,7 @@ export function pickUnboundStrategyAccount( ? pickResetFirstCodexAccount(config, listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions), now, selectionOptions) : pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions); if (!picked) return null; - if (commitSharedActive) { + if (commitSharedActive && sharesActiveSelection(picked, selectionOptions)) { if (!isIndependentCodexQuotaScope(quotaScope) && !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) { rememberActiveCodexAccount(config, picked); @@ -514,6 +514,18 @@ export function sharedStateSelectionOptions( }; } +/** + * A main that is live only through this request's own credential serves this request alone. + * Recording it as the shared active account would route later requests through a credential + * they do not carry (see CodexAccountUsabilityOptions.requestOwnedMainCredential). + */ +export function sharesActiveSelection( + accountId: string, + selectionOptions?: CodexAccountUsabilityOptions, +): boolean { + return !(accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.requestOwnedMainCredential === true); +} + export function pickLowerUsageAccount( config: OcxConfig, active: string, @@ -744,7 +756,8 @@ export function applyQuotaAutoSwitch( if (activeUsage < threshold) return active; const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope, selectionOptions); if (best !== active) { - if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) { + if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope) + && sharesActiveSelection(best, selectionOptions)) { setActiveCodexAccount(config, best); } return best; @@ -816,7 +829,8 @@ export function applyFailureFailover( // the moment of the failure; the streak outlives the soft avoid, so a later // scoped resolve reaches here with the streak still tripped and would otherwise // move the shared cursor after all. - if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) { + if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope) + && sharesActiveSelection(best, selectionOptions)) { promoteActiveCodexAccount(config, best); } return best; diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 1016f76c1c5..abaf25a3e92 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -399,6 +399,8 @@ sent if selection named main — so `requestOwnedMainCredentialIsLive` answers t question yes, and main is compared against the stored accounts on the operator's own usage, priority and reset ordering. Selecting main then serves it from the caller's credential without claiming, reading, reconciling or priming the stored profile, and without owning affinity or health state. +A main that wins only through that bearer is also never recorded as the shared active account: the +request resolves to main, while the persisted and runtime shared active account remain unchanged. Answering that question with the pin predicate instead scored main `main_credential_unavailable` on every unpinned request, so a pool with one stored sibling degraded to "stored account until it cannot serve, then main" whatever the usage numbers, the strategy or `codexAccountPriorities` said (#5019). diff --git a/tests/codex-integration/codex-pool-request-owned-main.test.ts b/tests/codex-integration/codex-pool-request-owned-main.test.ts new file mode 100644 index 00000000000..e5d2cde8da3 --- /dev/null +++ b/tests/codex-integration/codex-pool-request-owned-main.test.ts @@ -0,0 +1,364 @@ +import { clearPoolRotationState } from "../../src/codex/pool-rotation"; +import { + clearCodexUpstreamHealth, + clearThreadAccountMap, + getEffectiveActiveCodexAccountId, + recordCodexUpstreamOutcome, + resolveCodexAccountForThreadDetailed, +} from "../../src/codex/routing"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/account-id"; +import { clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api"; +import type { OcxConfig } from "../../src/types"; +import { existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const TEST_DIR = join(import.meta.dir, ".tmp-codex-pool-request-owned-main-test"); +let previousOpencodexHome: string | undefined; +let previousCodexHome: string | undefined; + +function makeConfig(overrides: Partial = {}): OcxConfig { + return { + providers: {}, + codexAccounts: [], + activeCodexAccountId: undefined, + autoSwitchThreshold: 80, + ...overrides, + } as OcxConfig; +} + +function saveTestCredential(id: string): void { + saveCodexAccountCredential(id, { + accessToken: `access-${id}`, + refreshToken: `refresh-${id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `acct-${id}`, + }); +} + +function makeThreeAccountConfig(overrides: Partial = {}): OcxConfig { + const ids = ["a", "b", "c"]; + for (const id of ids) saveTestCredential(id); + return makeConfig({ + activeCodexAccountId: "a", + autoSwitchThreshold: 80, + codexAccounts: ids.map(id => ({ id, email: `${id}@example.test`, isMain: false })), + ...overrides, + }); +} + +const THREE_ACCOUNT_IDS = ["a", "b", "c"] as const; + +describe("selection order across rotation strategies", () => { + beforeEach(() => { + previousOpencodexHome = process.env.OPENCODEX_HOME; + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = TEST_DIR; + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + clearPoolRotationState(); + }); + + afterEach(() => { + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + }); + + function primeAllQuota(usage = 10): void { + for (const id of THREE_ACCOUNT_IDS) updateAccountQuota(id, usage); + } + + describe("a request-owned main serves the request without becoming the shared active account", () => { + // A request that carries its own main bearer makes main an ordinary pool candidate for + // THAT request only (CodexAccountUsabilityOptions.requestOwnedMainCredential). Every write + // of shared active state reachable with the request's selection options must skip it: + // recording main would route later requests, which do not carry the credential, through a + // main they cannot use. The storedMainLive variant of each scenario is the control that + // proves the pick really moves the shared cursor when the credential is not request-owned. + const requestOwnedMain = { + requestOwnedMainCredential: true, + isMainAccountTokenLive: () => true, + }; + const storedMainLive = { isMainAccountTokenLive: () => true }; + + test("quota auto-switch to a request-owned main serves it but keeps the operator selection", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + activeCodexAccountId: "a", + autoSwitchThreshold: 80, + }); + updateAccountQuota("a", 95); + updateAccountQuota("b", 50); + updateAccountQuota("c", 50); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 5); + + expect(resolveCodexAccountForThreadDetailed(null, config, Date.now(), "shared", requestOwnedMain)) + .toMatchObject({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }); + expect(config.activeCodexAccountId).toBe("a"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + }); + + test("quota auto-switch to a live stored main persists the selection (control)", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + activeCodexAccountId: "a", + autoSwitchThreshold: 80, + }); + updateAccountQuota("a", 95); + updateAccountQuota("b", 50); + updateAccountQuota("c", 50); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 5); + + expect(resolveCodexAccountForThreadDetailed(null, config, Date.now(), "shared", storedMainLive)) + .toMatchObject({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }); + expect(config.activeCodexAccountId).toBe(MAIN_CODEX_ACCOUNT_ID); + expect(getEffectiveActiveCodexAccountId(config)).toBe(MAIN_CODEX_ACCOUNT_ID); + }); + + test("a round-robin new session picks a request-owned main without moving the shared cursor", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 1, + activeCodexAccountId: "a", + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 10); + + // Main heads the eligible list, so the first ring pick is the request-owned main. + expect(resolveCodexAccountForThreadDetailed(null, config, Date.now(), "shared", requestOwnedMain)) + .toMatchObject({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }); + expect(config.activeCodexAccountId).toBe("a"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + }); + + test("a round-robin new session moves the cursor to a live stored main (control)", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 1, + activeCodexAccountId: "a", + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 10); + + expect(resolveCodexAccountForThreadDetailed(null, config, Date.now(), "shared", storedMainLive)) + .toMatchObject({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }); + expect(config.activeCodexAccountId).toBe("a"); + expect(getEffectiveActiveCodexAccountId(config)).toBe(MAIN_CODEX_ACCOUNT_ID); + }); + + test("a fill-first new session picks a request-owned main without moving the shared cursor", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "fill-first", + activeCodexAccountId: "c", + autoSwitchThreshold: 80, + }); + updateAccountQuota("a", 95); + updateAccountQuota("b", 95); + updateAccountQuota("c", 95); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 5); + + // "c" is drained, so fill-first advances in stable order; "__main__" wraps to the + // successor of the last stored id and is the only candidate with headroom. + expect(resolveCodexAccountForThreadDetailed(null, config, Date.now(), "shared", requestOwnedMain)) + .toMatchObject({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }); + expect(config.activeCodexAccountId).toBe("c"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("c"); + }); + + test("a fill-first new session moves the cursor to a live stored main (control)", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "fill-first", + activeCodexAccountId: "c", + autoSwitchThreshold: 80, + }); + updateAccountQuota("a", 95); + updateAccountQuota("b", 95); + updateAccountQuota("c", 95); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 5); + + expect(resolveCodexAccountForThreadDetailed(null, config, Date.now(), "shared", storedMainLive)) + .toMatchObject({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }); + expect(config.activeCodexAccountId).toBe("c"); + expect(getEffectiveActiveCodexAccountId(config)).toBe(MAIN_CODEX_ACCOUNT_ID); + }); + + test("priority preemption to a request-owned main serves it without moving the shared cursor", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + activeCodexAccountId: "a", + codexAccountPriorities: { __main__: 2, a: 1, b: 1, c: 1 }, + } as Partial); + primeAllQuota(); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 10); + + // Main is alone in the highest eligible tier, so the unbound request preempts "a" up + // to it — the rememberActiveCodexAccount(preempted) site. + expect(resolveCodexAccountForThreadDetailed(null, config, Date.now(), "shared", requestOwnedMain)) + .toMatchObject({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }); + expect(config.activeCodexAccountId).toBe("a"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + }); + + test("priority preemption moves the cursor to a live stored main (control)", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + activeCodexAccountId: "a", + codexAccountPriorities: { __main__: 2, a: 1, b: 1, c: 1 }, + } as Partial); + primeAllQuota(); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 10); + + expect(resolveCodexAccountForThreadDetailed(null, config, Date.now(), "shared", storedMainLive)) + .toMatchObject({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }); + expect(config.activeCodexAccountId).toBe("a"); + expect(getEffectiveActiveCodexAccountId(config)).toBe(MAIN_CODEX_ACCOUNT_ID); + }); + + test("a bound thread re-evaluating onto a request-owned main does not promote it", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + pool: { cacheAffinity: false }, + } as Partial); + const threadId = "request-owned-quota-rebind"; + updateAccountQuota("a", 10); + updateAccountQuota("b", 50); + updateAccountQuota("c", 50); + const start = Date.now(); + expect(resolveCodexAccountForThreadDetailed(threadId, config, start, "shared")) + .toMatchObject({ status: "selected", accountId: "a", affinity: { move: "new_bind", reason: "healthy" } }); + + updateAccountQuota("a", 95); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 5); + const reboundAt = Date.now(); + + // The bound account crossed its threshold and the request-owned main is the strictly + // cooler candidate — the promoteActiveCodexAccount(cooler) site. + expect(resolveCodexAccountForThreadDetailed(threadId, config, reboundAt, "shared", requestOwnedMain)) + .toMatchObject({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID, + affinity: { move: "rebound", reason: "quota_headroom" } }); + expect(config.activeCodexAccountId).toBe("a"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + }); + + test("a bound thread re-evaluating onto a live stored main promotes it (control)", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + pool: { cacheAffinity: false }, + } as Partial); + const threadId = "stored-main-quota-rebind"; + updateAccountQuota("a", 10); + updateAccountQuota("b", 50); + updateAccountQuota("c", 50); + const start = Date.now(); + expect(resolveCodexAccountForThreadDetailed(threadId, config, start, "shared")) + .toMatchObject({ status: "selected", accountId: "a", affinity: { move: "new_bind", reason: "healthy" } }); + + updateAccountQuota("a", 95); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 5); + const reboundAt = Date.now(); + + expect(resolveCodexAccountForThreadDetailed(threadId, config, reboundAt, "shared", storedMainLive)) + .toMatchObject({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID, + affinity: { move: "rebound", reason: "quota_headroom" } }); + expect(config.activeCodexAccountId).toBe(MAIN_CODEX_ACCOUNT_ID); + expect(getEffectiveActiveCodexAccountId(config)).toBe(MAIN_CODEX_ACCOUNT_ID); + }); + + test("an expired transient detour on a request-owned main does not promote it", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + upstreamFailoverThreshold: 3, + }); + const threadId = "request-owned-expired-detour"; + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + updateAccountQuota("c", 30); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 5); + const start = Date.now(); + expect(resolveCodexAccountForThreadDetailed(threadId, config, start, "shared")) + .toMatchObject({ status: "selected", accountId: "a", affinity: { move: "new_bind", reason: "healthy" } }); + + recordCodexUpstreamOutcome(config, "a", 503, { now: start }); + recordCodexUpstreamOutcome(config, "a", 503, { now: start }); + recordCodexUpstreamOutcome(config, "a", 503, { now: start }); + + // The streak detours this request onto the request-owned main — the coolest eligible + // account — while the binding itself stays on "a". + expect(resolveCodexAccountForThreadDetailed(threadId, config, start, "shared", requestOwnedMain)) + .toMatchObject({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID, + affinity: { move: "detour", reason: "transient" } }); + const operatorAccount = config.activeCodexAccountId; + expect(operatorAccount).not.toBe(MAIN_CODEX_ACCOUNT_ID); + + // The hold outlives its window with "a" still failing, so the thread adopts its + // detour — the promoteActiveCodexAccount(expiredDetour) site. + const late = start + 11 * 60_000; + recordCodexUpstreamOutcome(config, "a", 503, { now: late }); + recordCodexUpstreamOutcome(config, "a", 503, { now: late }); + recordCodexUpstreamOutcome(config, "a", 503, { now: late }); + expect(resolveCodexAccountForThreadDetailed(threadId, config, late, "shared", requestOwnedMain)) + .toMatchObject({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID, + affinity: { move: "rebound", reason: "transient_hold_expired" } }); + expect(config.activeCodexAccountId).toBe(operatorAccount); + expect(getEffectiveActiveCodexAccountId(config)).toBe(operatorAccount); + }); + + test("an expired transient detour on a live stored main promotes it (control)", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + upstreamFailoverThreshold: 3, + }); + const threadId = "stored-main-expired-detour"; + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + updateAccountQuota("c", 30); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 5); + const start = Date.now(); + expect(resolveCodexAccountForThreadDetailed(threadId, config, start, "shared")) + .toMatchObject({ status: "selected", accountId: "a", affinity: { move: "new_bind", reason: "healthy" } }); + + recordCodexUpstreamOutcome(config, "a", 503, { now: start }); + recordCodexUpstreamOutcome(config, "a", 503, { now: start }); + recordCodexUpstreamOutcome(config, "a", 503, { now: start }); + + expect(resolveCodexAccountForThreadDetailed(threadId, config, start, "shared", storedMainLive)) + .toMatchObject({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID, + affinity: { move: "detour", reason: "transient" } }); + + const late = start + 11 * 60_000; + recordCodexUpstreamOutcome(config, "a", 503, { now: late }); + recordCodexUpstreamOutcome(config, "a", 503, { now: late }); + recordCodexUpstreamOutcome(config, "a", 503, { now: late }); + expect(resolveCodexAccountForThreadDetailed(threadId, config, late, "shared", storedMainLive)) + .toMatchObject({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID, + affinity: { move: "rebound", reason: "transient_hold_expired" } }); + expect(config.activeCodexAccountId).toBe(MAIN_CODEX_ACCOUNT_ID); + expect(getEffectiveActiveCodexAccountId(config)).toBe(MAIN_CODEX_ACCOUNT_ID); + }); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 729a15b53e6..53d53517396 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -397,6 +397,7 @@ "codex-plugins-doctor.test.ts": "codex-integration", "codex-pool-plan-exclusion.test.ts": "codex-integration", "codex-pool-refresh-backoff.test.ts": "codex-integration", + "codex-pool-request-owned-main.test.ts": "codex-integration", "codex-pool-rotation.test.ts": "codex-integration", "codex-priority-failback.test.ts": "codex-integration", "codex-prompt-adopt.test.ts": "codex-integration",