From 80d4a3125540d7cf84cc7e18739be31cae33a752 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 21:50:03 +0900 Subject: [PATCH 1/3] refactor(oauth): move the pool rotation kernel out of the Codex namespace --- scripts/test-layout/layout.json | 1 + src/codex/pool-rotation.ts | 300 +--------------- src/oauth/pool-kernel.ts | 321 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/oauth/pool-kernel-generic-sweep.test.ts | 76 +++++ 5 files changed, 407 insertions(+), 292 deletions(-) create mode 100644 src/oauth/pool-kernel.ts create mode 100644 tests/oauth/pool-kernel-generic-sweep.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7b6e068d60..7cef263dd1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -665,6 +665,7 @@ "gemini-inline.test.ts": "images", "gemini-web-search.test.ts": "adapters/google", "generic-oauth-failover.test.ts": "oauth", + "pool-kernel-generic-sweep.test.ts": "oauth", "github-copilot-account-origin.test.ts": "providers/github-copilot", "github-copilot-oauth.test.ts": "providers/github-copilot", "github-copilot-sse-rewrite.test.ts": "providers/github-copilot", diff --git a/src/codex/pool-rotation.ts b/src/codex/pool-rotation.ts index d0d032be06..09936dc8c2 100644 --- a/src/codex/pool-rotation.ts +++ b/src/codex/pool-rotation.ts @@ -1,295 +1,11 @@ -import type { OcxAccountPoolRotationStrategy } from "../types"; -import type { GenerationContext } from "../lib/state-store-sweeper"; - -export const POOL_KEY_CODEX = "codex"; -export const POOL_KEY_ANTHROPIC = "anthropic"; - -interface SelectionState { - activeKey?: string; - successes: number; - currentWeights: Map; -} - -const selectionState = new Map(); -let lastReconciledGeneration = 0; - -const DEFAULT_STICKY_LIMIT = 1; -const MIN_STICKY_LIMIT = 1; -const MAX_STICKY_LIMIT = 100; -const DEFAULT_STRATEGY: OcxAccountPoolRotationStrategy = "quota"; -const VALID_STRATEGIES = new Set(["quota", "round-robin", "fill-first"]); - -/** Selection order for an account with no stored preference: one flat tier. */ -export const DEFAULT_ACCOUNT_PRIORITY = 0; -export const MIN_ACCOUNT_PRIORITY = -100; -export const MAX_ACCOUNT_PRIORITY = 100; - -/** Strict parse for management APIs — returns null instead of defaulting. */ -export function parseAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy | null { - if (typeof raw === "string" && VALID_STRATEGIES.has(raw as OcxAccountPoolRotationStrategy)) { - return raw as OcxAccountPoolRotationStrategy; - } - return null; -} - -/** Strict parse for management APIs — returns null instead of defaulting. */ -export function parseAccountPoolStickyLimit(raw: unknown): number | null { - if (typeof raw === "number" && Number.isInteger(raw) && raw >= MIN_STICKY_LIMIT && raw <= MAX_STICKY_LIMIT) { - return raw; - } - return null; -} - -export function normalizeAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy { - return parseAccountPoolStrategy(raw) ?? DEFAULT_STRATEGY; -} - -export function normalizeAccountPoolStickyLimit(raw: unknown): number { - return parseAccountPoolStickyLimit(raw) ?? DEFAULT_STICKY_LIMIT; -} - -/** Strict parse for management APIs — returns null instead of defaulting. */ -export function parseAccountPriority(raw: unknown): number | null { - if ( - typeof raw === "number" - && Number.isInteger(raw) - && raw >= MIN_ACCOUNT_PRIORITY - && raw <= MAX_ACCOUNT_PRIORITY - ) { - return raw; - } - return null; -} - -export function normalizeAccountPriority(raw: unknown): number { - return parseAccountPriority(raw) ?? DEFAULT_ACCOUNT_PRIORITY; -} - /** - * Narrow an already-eligible account list to the highest selection-order tier that - * still has usable quota. Priority is an *ordering* boundary layered on top of - * eligibility: it never admits an account the caller already filtered out, and it - * never keeps the pool on a tier whose every member is drained. + * Compatibility shim. The rotation primitives moved to `src/oauth/pool-kernel.ts` + * so every credential kind can share them, not only Codex and Anthropic. * - * Contract (each clause is load-bearing for "no behavior change when unconfigured"): - * - one distinct priority across `ids` (the unconfigured case) returns `ids` unchanged, - * so today's pick sequence is preserved byte for byte; - * - input order is preserved inside the returned tier, which keeps the `__main__` - * head-of-list bias and the first-index tie-break used by SWRR/lowest-usage; - * - every tier drained returns `ids` unchanged, reproducing today's - * stay-put-until-429 behavior rather than inventing a pick; - * - a `pinnedId` that is present *and* has headroom lowers the ceiling to its own - * tier, which is what makes a manual "use this now" survive round-robin and - * fill-first without any mutable selection state. A drained or absent pin is - * ignored, so the pin expires on its own once the account crosses the threshold. + * This file stays because the move is behaviour-preserving and its importers are + * spread across files that other work owns right now. Re-exporting keeps + * `routing.ts`, `auth-api.ts`, `account-priority.ts` and + * `state-store-registrations.ts` on their existing import path, so the extraction + * lands without editing any of them. */ -export function selectPriorityTier( - ids: readonly string[], - priorityOf: (id: string) => number, - hasHeadroom: (id: string) => boolean, - pinnedId?: string, -): readonly string[] { - // Readonly out as well as in: the no-change cases return the caller's own array, so a - // mutating caller would corrupt its input in exactly the cases that must not change. - const list = ids; - if (list.length <= 1) return list; - - const priorities = list.map(priorityOf); - const firstPriority = priorities[0]!; - if (priorities.every(priority => priority === firstPriority)) return list; - - let ceiling = Number.POSITIVE_INFINITY; - if (pinnedId !== undefined) { - const pinnedIndex = list.indexOf(pinnedId); - if (pinnedIndex >= 0 && hasHeadroom(pinnedId)) ceiling = priorities[pinnedIndex]!; - } - - const tiers = [...new Set(priorities)].sort((a, b) => b - a); - for (const tier of tiers) { - if (tier > ceiling) continue; - const members = list.filter((_, index) => priorities[index] === tier); - if (members.some(hasHeadroom)) return members; - } - return list; -} - -function getOrCreateState(poolKey: string): SelectionState { - let state = selectionState.get(poolKey); - if (!state) { - state = { successes: 0, currentWeights: new Map() }; - selectionState.set(poolKey, state); - } - return state; -} - -function cloneSelectionState(state: SelectionState): SelectionState { - return { - activeKey: state.activeKey, - successes: state.successes, - currentWeights: new Map(state.currentWeights), - }; -} - -function smoothWeightedIndex(ids: readonly string[], state: SelectionState): number { - let best = -1; - let bestScore = Number.NEGATIVE_INFINITY; - let total = 0; - const weight = 1; - for (let i = 0; i < ids.length; i++) { - const id = ids[i]!; - const score = (state.currentWeights.get(id) ?? 0) + weight; - state.currentWeights.set(id, score); - total += weight; - if (score > bestScore) { - best = i; - bestScore = score; - } - } - if (best >= 0) { - const key = ids[best]!; - state.currentWeights.set(key, (state.currentWeights.get(key) ?? 0) - total); - } - return best; -} - -/** - * Shared pick core. Mutates `state` the same way live resolve does; callers pass - * either the live map entry or a scratch/clone for dry-run peek. - */ -function pickRoundRobinFromState( - eligibleIds: readonly string[], - stickyLimit: number, - state: SelectionState, - commitSticky: boolean, -): string | null { - if (eligibleIds.length === 0) return null; - - const limit = normalizeAccountPoolStickyLimit(stickyLimit); - - if (state.activeKey && eligibleIds.includes(state.activeKey)) { - return state.activeKey; - } - - if (state.activeKey) { - delete state.activeKey; - state.successes = 0; - } - - const index = smoothWeightedIndex(eligibleIds, state); - if (index < 0) return null; - - const picked = eligibleIds[index]!; - if (commitSticky && limit > 1) { - state.activeKey = picked; - state.successes = 0; - } - return picked; -} - -export function pickRoundRobinAccount( - poolKey: string, - eligibleIds: readonly string[], - stickyLimit: number, -): string | null { - return pickRoundRobinFromState(eligibleIds, stickyLimit, getOrCreateState(poolKey), true); -} - -/** - * Dry-run of {@link pickRoundRobinAccount}: returns the same account resolve would - * pick without advancing ring weights, activeKey, or successes. - */ -export function peekRoundRobinAccount( - poolKey: string, - eligibleIds: readonly string[], - stickyLimit: number, -): string | null { - const live = selectionState.get(poolKey); - const scratch = live - ? cloneSelectionState(live) - : { successes: 0, currentWeights: new Map() }; - return pickRoundRobinFromState(eligibleIds, stickyLimit, scratch, false); -} - -export function notePoolRotationSuccess( - poolKey: string, - accountId: string, - stickyLimit: number, -): void { - const limit = normalizeAccountPoolStickyLimit(stickyLimit); - const state = selectionState.get(poolKey); - if (!state) return; - if (state.activeKey !== accountId) { - state.activeKey = accountId; - state.successes = 0; - } - state.successes += 1; - if (state.successes >= limit) { - delete state.activeKey; - state.successes = 0; - } -} - -export function notePoolRotationFailure(poolKey: string, accountId: string): void { - const state = selectionState.get(poolKey); - if (state?.activeKey === accountId) { - delete state.activeKey; - state.successes = 0; - } -} - -/** - * Force the next sticky/RR pick onto `accountId` (manual dashboard selection). - * Clears sticky success counters and ring weights so the seeded account is held - * for the next new-session pick before ordinary rotation resumes. - */ -export function seedPoolRotationAccount(poolKey: string, accountId: string): void { - const state = getOrCreateState(poolKey); - state.activeKey = accountId; - state.successes = 0; - state.currentWeights.clear(); -} - -export function clearPoolRotationState(poolKey?: string): void { - if (poolKey === undefined) { - selectionState.clear(); - return; - } - selectionState.delete(poolKey); -} - -export function reconcilePoolRotationState(context: GenerationContext): number { - if (context.generation <= lastReconciledGeneration) return 0; - const anthropicIds = new Set(); - for (const key of context.oauthAccountKeys) { - const separator = key.indexOf("\0"); - if (separator > 0 && key.slice(0, separator) === "anthropic") { - anthropicIds.add(key.slice(separator + 1)); - } - } - let removed = 0; - for (const [poolKey, state] of selectionState) { - const valid = poolKey === POOL_KEY_ANTHROPIC - ? anthropicIds - : poolKey === POOL_KEY_CODEX || poolKey.startsWith(`${POOL_KEY_CODEX}:`) - ? context.codexAccountIds - : null; - if (!valid) continue; - if (valid.size === 0) { - selectionState.delete(poolKey); - removed += 1; - continue; - } - if (state.activeKey && !valid.has(state.activeKey)) { - delete state.activeKey; - state.successes = 0; - removed += 1; - } - for (const accountId of state.currentWeights.keys()) { - if (valid.has(accountId)) continue; - state.currentWeights.delete(accountId); - removed += 1; - } - } - lastReconciledGeneration = context.generation; - return removed; -} +export * from "../oauth/pool-kernel"; diff --git a/src/oauth/pool-kernel.ts b/src/oauth/pool-kernel.ts new file mode 100644 index 0000000000..b36ac88e03 --- /dev/null +++ b/src/oauth/pool-kernel.ts @@ -0,0 +1,321 @@ +import type { OcxAccountPoolRotationStrategy } from "../types"; +import type { GenerationContext } from "../lib/state-store-sweeper"; + +export const POOL_KEY_CODEX = "codex"; +export const POOL_KEY_ANTHROPIC = "anthropic"; + +/** + * Pool key for a generic OAuth provider. Namespaced so a provider can never collide + * with the two dedicated kinds, and so `reconcilePoolRotationState` can recognise + * the entry as sweepable rather than skipping it as unknown. + */ +export function genericPoolKey(providerName: string): string { + return `generic:${providerName}`; +} + +interface SelectionState { + activeKey?: string; + successes: number; + currentWeights: Map; +} + +const selectionState = new Map(); +let lastReconciledGeneration = 0; + +const DEFAULT_STICKY_LIMIT = 1; +const MIN_STICKY_LIMIT = 1; +const MAX_STICKY_LIMIT = 100; +const DEFAULT_STRATEGY: OcxAccountPoolRotationStrategy = "quota"; +const VALID_STRATEGIES = new Set(["quota", "round-robin", "fill-first"]); + +/** Selection order for an account with no stored preference: one flat tier. */ +export const DEFAULT_ACCOUNT_PRIORITY = 0; +export const MIN_ACCOUNT_PRIORITY = -100; +export const MAX_ACCOUNT_PRIORITY = 100; + +/** Strict parse for management APIs — returns null instead of defaulting. */ +export function parseAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy | null { + if (typeof raw === "string" && VALID_STRATEGIES.has(raw as OcxAccountPoolRotationStrategy)) { + return raw as OcxAccountPoolRotationStrategy; + } + return null; +} + +/** Strict parse for management APIs — returns null instead of defaulting. */ +export function parseAccountPoolStickyLimit(raw: unknown): number | null { + if (typeof raw === "number" && Number.isInteger(raw) && raw >= MIN_STICKY_LIMIT && raw <= MAX_STICKY_LIMIT) { + return raw; + } + return null; +} + +export function normalizeAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy { + return parseAccountPoolStrategy(raw) ?? DEFAULT_STRATEGY; +} + +export function normalizeAccountPoolStickyLimit(raw: unknown): number { + return parseAccountPoolStickyLimit(raw) ?? DEFAULT_STICKY_LIMIT; +} + +/** Strict parse for management APIs — returns null instead of defaulting. */ +export function parseAccountPriority(raw: unknown): number | null { + if ( + typeof raw === "number" + && Number.isInteger(raw) + && raw >= MIN_ACCOUNT_PRIORITY + && raw <= MAX_ACCOUNT_PRIORITY + ) { + return raw; + } + return null; +} + +export function normalizeAccountPriority(raw: unknown): number { + return parseAccountPriority(raw) ?? DEFAULT_ACCOUNT_PRIORITY; +} + +/** + * Narrow an already-eligible account list to the highest selection-order tier that + * still has usable quota. Priority is an *ordering* boundary layered on top of + * eligibility: it never admits an account the caller already filtered out, and it + * never keeps the pool on a tier whose every member is drained. + * + * Contract (each clause is load-bearing for "no behavior change when unconfigured"): + * - one distinct priority across `ids` (the unconfigured case) returns `ids` unchanged, + * so today's pick sequence is preserved byte for byte; + * - input order is preserved inside the returned tier, which keeps the `__main__` + * head-of-list bias and the first-index tie-break used by SWRR/lowest-usage; + * - every tier drained returns `ids` unchanged, reproducing today's + * stay-put-until-429 behavior rather than inventing a pick; + * - a `pinnedId` that is present *and* has headroom lowers the ceiling to its own + * tier, which is what makes a manual "use this now" survive round-robin and + * fill-first without any mutable selection state. A drained or absent pin is + * ignored, so the pin expires on its own once the account crosses the threshold. + */ +export function selectPriorityTier( + ids: readonly string[], + priorityOf: (id: string) => number, + hasHeadroom: (id: string) => boolean, + pinnedId?: string, +): readonly string[] { + // Readonly out as well as in: the no-change cases return the caller's own array, so a + // mutating caller would corrupt its input in exactly the cases that must not change. + const list = ids; + if (list.length <= 1) return list; + + const priorities = list.map(priorityOf); + const firstPriority = priorities[0]!; + if (priorities.every(priority => priority === firstPriority)) return list; + + let ceiling = Number.POSITIVE_INFINITY; + if (pinnedId !== undefined) { + const pinnedIndex = list.indexOf(pinnedId); + if (pinnedIndex >= 0 && hasHeadroom(pinnedId)) ceiling = priorities[pinnedIndex]!; + } + + const tiers = [...new Set(priorities)].sort((a, b) => b - a); + for (const tier of tiers) { + if (tier > ceiling) continue; + const members = list.filter((_, index) => priorities[index] === tier); + if (members.some(hasHeadroom)) return members; + } + return list; +} + +function getOrCreateState(poolKey: string): SelectionState { + let state = selectionState.get(poolKey); + if (!state) { + state = { successes: 0, currentWeights: new Map() }; + selectionState.set(poolKey, state); + } + return state; +} + +function cloneSelectionState(state: SelectionState): SelectionState { + return { + activeKey: state.activeKey, + successes: state.successes, + currentWeights: new Map(state.currentWeights), + }; +} + +function smoothWeightedIndex(ids: readonly string[], state: SelectionState): number { + let best = -1; + let bestScore = Number.NEGATIVE_INFINITY; + let total = 0; + const weight = 1; + for (let i = 0; i < ids.length; i++) { + const id = ids[i]!; + const score = (state.currentWeights.get(id) ?? 0) + weight; + state.currentWeights.set(id, score); + total += weight; + if (score > bestScore) { + best = i; + bestScore = score; + } + } + if (best >= 0) { + const key = ids[best]!; + state.currentWeights.set(key, (state.currentWeights.get(key) ?? 0) - total); + } + return best; +} + +/** + * Shared pick core. Mutates `state` the same way live resolve does; callers pass + * either the live map entry or a scratch/clone for dry-run peek. + */ +function pickRoundRobinFromState( + eligibleIds: readonly string[], + stickyLimit: number, + state: SelectionState, + commitSticky: boolean, +): string | null { + if (eligibleIds.length === 0) return null; + + const limit = normalizeAccountPoolStickyLimit(stickyLimit); + + if (state.activeKey && eligibleIds.includes(state.activeKey)) { + return state.activeKey; + } + + if (state.activeKey) { + delete state.activeKey; + state.successes = 0; + } + + const index = smoothWeightedIndex(eligibleIds, state); + if (index < 0) return null; + + const picked = eligibleIds[index]!; + if (commitSticky && limit > 1) { + state.activeKey = picked; + state.successes = 0; + } + return picked; +} + +export function pickRoundRobinAccount( + poolKey: string, + eligibleIds: readonly string[], + stickyLimit: number, +): string | null { + return pickRoundRobinFromState(eligibleIds, stickyLimit, getOrCreateState(poolKey), true); +} + +/** + * Dry-run of {@link pickRoundRobinAccount}: returns the same account resolve would + * pick without advancing ring weights, activeKey, or successes. + */ +export function peekRoundRobinAccount( + poolKey: string, + eligibleIds: readonly string[], + stickyLimit: number, +): string | null { + const live = selectionState.get(poolKey); + const scratch = live + ? cloneSelectionState(live) + : { successes: 0, currentWeights: new Map() }; + return pickRoundRobinFromState(eligibleIds, stickyLimit, scratch, false); +} + +export function notePoolRotationSuccess( + poolKey: string, + accountId: string, + stickyLimit: number, +): void { + const limit = normalizeAccountPoolStickyLimit(stickyLimit); + const state = selectionState.get(poolKey); + if (!state) return; + if (state.activeKey !== accountId) { + state.activeKey = accountId; + state.successes = 0; + } + state.successes += 1; + if (state.successes >= limit) { + delete state.activeKey; + state.successes = 0; + } +} + +export function notePoolRotationFailure(poolKey: string, accountId: string): void { + const state = selectionState.get(poolKey); + if (state?.activeKey === accountId) { + delete state.activeKey; + state.successes = 0; + } +} + +/** + * Force the next sticky/RR pick onto `accountId` (manual dashboard selection). + * Clears sticky success counters and ring weights so the seeded account is held + * for the next new-session pick before ordinary rotation resumes. + */ +export function seedPoolRotationAccount(poolKey: string, accountId: string): void { + const state = getOrCreateState(poolKey); + state.activeKey = accountId; + state.successes = 0; + state.currentWeights.clear(); +} + +export function clearPoolRotationState(poolKey?: string): void { + if (poolKey === undefined) { + selectionState.clear(); + return; + } + selectionState.delete(poolKey); +} + +export function reconcilePoolRotationState(context: GenerationContext): number { + if (context.generation <= lastReconciledGeneration) return 0; + const anthropicIds = new Set(); + // Live account ids per generic OAuth provider, built from the same + // `provider\0id` roster the Anthropic pass already walks. Without this the + // `generic:*` entries fall through as unknown and are never swept, so a removed + // account would keep its rotation weight forever. + const genericIds = new Map>(); + for (const key of context.oauthAccountKeys) { + const separator = key.indexOf("\0"); + if (separator <= 0) continue; + const provider = key.slice(0, separator); + const accountId = key.slice(separator + 1); + if (provider === "anthropic") { + anthropicIds.add(accountId); + continue; + } + let bucket = genericIds.get(provider); + if (!bucket) { + bucket = new Set(); + genericIds.set(provider, bucket); + } + bucket.add(accountId); + } + let removed = 0; + for (const [poolKey, state] of selectionState) { + const valid = poolKey === POOL_KEY_ANTHROPIC + ? anthropicIds + : poolKey === POOL_KEY_CODEX || poolKey.startsWith(`${POOL_KEY_CODEX}:`) + ? context.codexAccountIds + : poolKey.startsWith("generic:") + ? genericIds.get(poolKey.slice("generic:".length)) ?? new Set() + : null; + if (!valid) continue; + if (valid.size === 0) { + selectionState.delete(poolKey); + removed += 1; + continue; + } + if (state.activeKey && !valid.has(state.activeKey)) { + delete state.activeKey; + state.successes = 0; + removed += 1; + } + for (const accountId of state.currentWeights.keys()) { + if (valid.has(accountId)) continue; + state.currentWeights.delete(accountId); + removed += 1; + } + } + lastReconciledGeneration = context.generation; + return removed; +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 36b7a2e1ae..39bcb31f19 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -809,6 +809,7 @@ "plan-video.test.ts": "videos", "plan.test.ts": "images", "policy-execution.test.ts": "routing", + "pool-kernel-generic-sweep.test.ts": "oauth", "port-reclaim.test.ts": "server", "ports.test.ts": "server", "prime-client.test.ts": "clients", diff --git a/tests/oauth/pool-kernel-generic-sweep.test.ts b/tests/oauth/pool-kernel-generic-sweep.test.ts new file mode 100644 index 0000000000..400fc6af25 --- /dev/null +++ b/tests/oauth/pool-kernel-generic-sweep.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { + clearPoolRotationState, + genericPoolKey, + peekRoundRobinAccount, + reconcilePoolRotationState, + seedPoolRotationAccount, +} from "../../src/oauth/pool-kernel"; +import type { GenerationContext } from "../../src/lib/state-store-sweeper"; + +/** + * The rotation primitives moved out of src/codex/pool-rotation.ts so every credential + * kind can share them. Before the move, reconcilePoolRotationState recognised only the + * two dedicated pool keys and skipped everything else, so a generic OAuth provider's + * rotation state would have survived the removal of the very account it points at. + */ +function generation(n: number, oauthAccountKeys: string[]): GenerationContext { + return { + generation: n, + providerNames: new Set(), + comboIds: new Set(), + comboTargets: new Set(), + codexAccountIds: new Set(), + oauthAccountKeys: new Set(oauthAccountKeys), + configRoots: new Set(), + } as GenerationContext; +} + +describe("generic pool keys are swept", () => { + test("genericPoolKey namespaces a provider so it cannot collide with the dedicated kinds", () => { + expect(genericPoolKey("cursor")).toBe("generic:cursor"); + expect(genericPoolKey("cursor")).not.toBe("codex"); + expect(genericPoolKey("anthropic")).not.toBe("anthropic"); + }); + + test("a generic entry survives while its account is still live", () => { + const key = genericPoolKey("cursor"); + clearPoolRotationState(key); + seedPoolRotationAccount(key, "acct-1"); + // Seeding pins the sticky account, so a peek over both candidates returns it. + expect(peekRoundRobinAccount(key, ["acct-1", "acct-2"], 5)).toBe("acct-1"); + + // Nothing was removed, so the sweep must report no change and leave the pin. + expect(reconcilePoolRotationState(generation(9001, ["cursor\u0000acct-1"]))).toBe(0); + expect(peekRoundRobinAccount(key, ["acct-1", "acct-2"], 5)).toBe("acct-1"); + clearPoolRotationState(key); + }); + + test("a generic entry is dropped once its account leaves the roster", () => { + const key = genericPoolKey("kimi"); + clearPoolRotationState(key); + seedPoolRotationAccount(key, "gone"); + expect(peekRoundRobinAccount(key, ["gone", "still-here"], 5)).toBe("gone"); + + // The account is absent from this generation. Before the generic branch existed + // this key fell through as unknown and the stale pin survived forever. + expect(reconcilePoolRotationState(generation(9002, ["kimi\u0000still-here"]))).toBeGreaterThan(0); + expect(peekRoundRobinAccount(key, ["still-here"], 5)).toBe("still-here"); + clearPoolRotationState(key); + }); + + test("one provider's roster does not sweep another provider's entry", () => { + const cursor = genericPoolKey("cursor"); + const kimi = genericPoolKey("kimi"); + clearPoolRotationState(cursor); + clearPoolRotationState(kimi); + seedPoolRotationAccount(cursor, "c1"); + seedPoolRotationAccount(kimi, "k1"); + + expect(reconcilePoolRotationState(generation(9003, ["cursor\u0000c1", "kimi\u0000k1"]))).toBe(0); + expect(peekRoundRobinAccount(cursor, ["c1", "c2"], 5)).toBe("c1"); + expect(peekRoundRobinAccount(kimi, ["k1", "k2"], 5)).toBe("k1"); + clearPoolRotationState(cursor); + clearPoolRotationState(kimi); + }); +}); From 3015be811665feccc1eddde5756486e52bebd9f9 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 21:57:08 +0900 Subject: [PATCH 2/3] docs(devlog): record the second-half audit findings for the pool kernel --- .../020_phase2_shared_kernel.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md b/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md index b9ead490fd..3b0fc74ba3 100644 --- a/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md +++ b/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md @@ -172,6 +172,42 @@ would have forced an edit inside lane L3's freeze. The second was that dropping which requires `pool.kernel` to default off and the old behaviour to be exactly restorable. +## Second-half audit (the flagged behaviour change) + +The extraction shipped as PR #4279. A separate audit of the remaining half returned +FAIL, and its findings change that half materially. Recorded here so the next cycle +starts from them rather than rediscovering them. + +1. **BLOCKER. Branching the final ranking expression is not enough.** + `preferredInitialAccount` encodes the quota strategy BEFORE its tail: the + healthy-active early return tests `isAccountQuotaExhausted` (:262) and the + roster-wide `hasHeadroomEvidence` check (:272) returns null when a provider has + no quota data at all. Leave those untouched and round-robin can never run for a + provider without quota evidence, and fill-first never reaches + `autoSwitchThreshold` because the healthy active account already returned. Both + guards have to be strategy-gated: skip the evidence requirement for round-robin, + and use the threshold rather than exhaustion for fill-first. +2. **BLOCKER. The preference must peek, not pick.** + `pickRoundRobinAccount` mutates live ring state, but + `preferredInitialAccount` is explicitly a discardable proposal that the caller + drops on a resolver throw or a missing project. Mutating there desyncs the + cursor against requests that never happened. Use `peekRoundRobinAccount` and + mutate with `pickRoundRobinAccount` plus `notePoolRotationSuccess` only after + the selection is admitted, which is what Anthropic already does. +3. **The 429 path is safe to branch but fill-first must still move.** That tail has + no evidence guard, so a strategy branch is structurally fine. Fill-first there + cannot mean keep-active: the account that just returned 429 is already cooled, + so staying put would skip rotation entirely. +4. **`stickyLimit` does not exist for the generic kind yet.** The + `oauthAccountFailover` type carries only `enabled`, `strategy` and + `autoSwitchThreshold`. Lifting the 400 at `oauth-account-routes.ts:395` before + adding the field to the type, the DTO, GET and the PUT writer would accept a + value and then drop it. The kernel default is 1. +5. **The flag lands in a lane-owned file.** `OcxConfig` has no `pool` key today, + so `pool.kernel` belongs in `src/types/config.ts` (around :363) - which lane L3 + owns. This half therefore inherits the same freeze as work-phases 1 and 2 until + that ownership clears, or the flag needs a different home. + - `tests/codex-integration/codex-pool-rotation.test.ts` — unchanged behaviour through the re-export (`pickRoundRobinAccount` `:270`, `selectPriorityTier` `:111`) - `tests/oauth/generic-oauth-failover.test.ts` — a configured strategy changes the From e1e8b250a7488e40a869370dd6c780478a8f085e Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 21:58:25 +0900 Subject: [PATCH 3/3] docs(devlog): re-verify phase 3 anchors and record what blocks it --- .../030_phase3_cache_affinity.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md b/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md index bee0f768df..393c498934 100644 --- a/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md +++ b/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md @@ -69,3 +69,35 @@ passing the threshold, because a threshold rebind throws away a warm cache. A cache-affine account is chosen over a higher-headroom one; an exhausted affine account still yields; concurrent distinct sessions keep distinct accounts; a shared-cohort cache key does not collapse every session onto one account. + +## Staleness re-verification and why this phase is not open yet + +Re-verified at the wp3 P entry against `origin/dev` `1da8dae96`. Every anchor this +document relies on is unchanged from the original reading: + +| Symbol | File | Line | +|---|---|---| +| `CODEX_THREAD_AFFINITY_MAX_ENTRIES` | `src/codex/routing.ts` | 135 | +| `pruneLruThreadAffinities` | `src/codex/routing.ts` | 1212 | +| `reevaluateAffinityQuota` | `src/codex/routing.ts` | 1942 | +| `MAX_AFFINITY_ENTRIES` | `src/oauth/anthropic-routing.ts` | 48 | +| `anthropicSessionKeyFromParts` | `src/oauth/anthropic-routing.ts` | 877 | +| `promptCacheKeyIsSharedCohort` | `src/oauth/anthropic-routing.ts` | 883 | +| `MAX_CACHE_BREAKPOINTS` | `src/adapters/anthropic.ts` | 60 | + +The design is therefore current. Two things still stop this phase from opening, +and neither is a documentation gap: + +1. **Its three open assumptions are genuine product decisions, not research gaps.** + The affinity key shape, what to do when a `prompt_cache_key` looks like a shared + cohort, and whether to add a minimum-token cache gate all change observable + behaviour and none is settled by reading the code. They need a human answer. + Under an active goal the Interview is suppressed, so this phase cannot resolve + them from inside the loop. +2. **The Codex half is frozen.** `src/codex/routing.ts` carries three of the seven + anchors above and is owned by lane L3 for the dispatch round in flight. + +The Anthropic and generic halves are not frozen, so a narrower first slice exists: +unify the affinity key for those two kinds only, leaving the Codex thread-affinity +map on its current key until the freeze lifts. That slice still needs assumption 1 +answered, which is why this phase stays closed rather than being re-scoped now.