Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,32 @@ When an account leaves pool selection, the reason travels with the decision inst

A main-account refresh that does not complete still answers `503` with `Retry-After`, because a retry may still succeed. The message now adds that a failure which persists means the main account needs reauthentication, rather than only asking for another attempt.

### Optional idle-window steering

`codexPool.startIdleWindows` is an optional boolean and defaults to `false`. When enabled, a new
unbound real request may be placed on an eligible account whose observed short quota window is at
0% with evidence that its five-hour clock has not started. This check runs after conversation and family affinity, so an existing
binding remains authoritative; an explicit account pin or manual account preference also wins.
Independent model quota scopes are skipped, and the shared active cursor is unchanged. After the
idle-window choice, normal strategy selection resumes for other conversations.

The observation must be no older than five minutes, and the short window must explicitly be
18,000 seconds. Its reset must be observed within 60 seconds of `observation + 5h` (rather than an already ticking or elapsed reset). A synchronous,
process-local reservation prevents duplicate selections for the same account and window. The
reservation deadline is at least 5h plus one minute after selection; a fresh observation is
required after that deadline before the same account can be steered again. Reservations are cleared when the proxy
process restarts and are never persisted to disk.

Enable it in your existing configuration:

```json
{
"codexPool": { "startIdleWindows": true }
}
```

If no account meets the criteria, ordinary routing applies. This feature only steers an actual incoming request. It creates no synthetic request and no timer.

### Keeping a downgraded account out of rotation

`codexPool.excludedPlans` lists plan keys that automatic pool selection skips, matched case-insensitively against the plan stored on each account. It is absent by default, so an existing install rotates exactly as before.
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,7 @@
"codex-pool-request-owned-main.test.ts": "codex-integration",
"codex-pool-rotation.test.ts": "codex-integration",
"codex-priority-failback.test.ts": "codex-integration",
"codex-idle-window.test.ts": "codex-integration",
"codex-prompt-adopt.test.ts": "codex-integration",
"codex-prompt-base-variants.test.ts": "codex-integration",
"codex-prompt-journal.test.ts": "codex-integration",
Expand Down
10 changes: 10 additions & 0 deletions src/codex/routing.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { clearIdleWindowSteering, pickIdleWindowAccount } from "./routing/idle-window";
import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch";
import { codexQuotaHasFreshUsage } from "./quota-observation-freshness";
import { saveConfigPreservingClaudeCode } from "../config";
Expand Down Expand Up @@ -234,6 +235,7 @@ export function clearCodexUpstreamHealth(): void {
// reset points. Leaving them behind lets a selection from one context suppress the
// automatic cursor in the next one.
clearAllManualPreferences();
clearIdleWindowSteering();
clearUpstreamHealthState();
forgetRuntimeActiveCodexAccount();
// The reconcile watermark is part of this state, not something that outlives it. Keeping
Expand Down Expand Up @@ -786,6 +788,10 @@ export function previewCodexAccountForRequest(
if (lineagePreview) return lineagePreview.accountId;
}

const idlePick = !entry && !peekPendingReleaseReason(threadId)
&& !(threadId && getModelDetourAffinity(threadId, modelId, quotaScope))
? pickIdleWindowAccount(config, threadId, now, false, quotaScope, selectionOptions) : null;
if (idlePick) return idlePick;
const strategyPick = pickUnboundStrategyAccount(
config,
threadId,
Expand Down Expand Up @@ -881,6 +887,7 @@ export function resolveCodexAccountForThreadDetailed(
// cold one: every branch that follows -- detour reuse, transient hold, quota re-eval --
// should treat it as the continuing conversation it is. No-op on a fresh process.
if (threadId) adoptLegacyLineageAffinity(threadId, lineage, now, quotaScope, modelId);
const hadModelAffinity = !!(threadId && getModelDetourAffinity(threadId, modelId, quotaScope));

if (threadId && modelScopedSelection) {
const detourEntry = getModelDetourAffinity(threadId, modelId, quotaScope);
Expand Down Expand Up @@ -1088,6 +1095,9 @@ export function resolveCodexAccountForThreadDetailed(
}
}

const idlePick = !entry && !releaseReason && !hadModelAffinity
? pickIdleWindowAccount(config, threadId, now, true, quotaScope, selectionOptions) : null;
if (idlePick) return { status: "selected", accountId: idlePick, affinity: affinityAfterRelease(threadId, releaseReason) };
// 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;
// otherwise RR/fill-first can immediately re-pick a known failing account even when another
Expand Down
58 changes: 58 additions & 0 deletions src/codex/routing/idle-window.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { OcxConfig } from "../../types";
import type { CodexAccountUsabilityOptions } from "../account-usability";
import { pinnedCodexAccountId } from "../account-priority";
import { getAccountQuota, resetAtToMs } from "../quota";
import { manualPreferenceBlocks } from "./active-account";
import { codexPoolKeyForScope, isIndependentCodexQuotaScope, type CodexQuotaScope } from "./health-store";
import { getEligiblePoolAccounts, hasCodexQuotaHeadroom } from "./selection";
import { bindThreadAffinity } from "./thread-affinity";

const WINDOW_MS = 5 * 60 * 60_000;
const TOLERANCE_MS = 60_000;
const FRESHNESS_MS = 5 * 60_000;
// Reservation is synchronous, so concurrent resolves cannot start the same window twice.
// Keep the deadline fixed: idle observations may slide their reset forward on every poll.
const steeredUntil = new Map<string, number>();

export function clearIdleWindowSteering(): void {
steeredUntil.clear();
}

/** Only called for first placement, after conversation/family affinity has had precedence. */
export function pickIdleWindowAccount(
config: OcxConfig,
threadId: string | null,
now: number,
commit: boolean,
quotaScope?: CodexQuotaScope,
selectionOptions?: CodexAccountUsabilityOptions,
): string | null {
if (config.codexPool?.startIdleWindows !== true || isIndependentCodexQuotaScope(quotaScope)
|| pinnedCodexAccountId(config) !== undefined) return null;
const eligible = getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions, true);
for (const id of eligible) {
if (manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), id)
|| selectionOptions?.deniedModelAccountIds?.has(id)
|| !hasCodexQuotaHeadroom(config, id, selectionOptions, now)) continue;
const quota = getAccountQuota(id);
const observed = quota?.shortObservedAt;
const reset = quota?.shortResetAt;
if (quota?.shortPercent !== 0 || quota.shortWindowSeconds !== WINDOW_MS / 1000
|| observed === undefined || !Number.isFinite(observed)
|| reset === undefined || !Number.isFinite(reset)
|| now < observed || now - observed > FRESHNESS_MS) continue;
const resetMs = resetAtToMs(reset);
const previous = steeredUntil.get(id);
if (Math.abs(resetMs - observed - WINDOW_MS) > TOLERANCE_MS || resetMs <= now
|| (previous !== undefined && (now <= previous || observed <= previous))) continue;
if (commit) {
for (const [accountId, until] of steeredUntil) {
if (until < now - FRESHNESS_MS) steeredUntil.delete(accountId);
}
steeredUntil.set(id, Math.max(resetMs, now + WINDOW_MS) + TOLERANCE_MS);
if (threadId) bindThreadAffinity(threadId, id, now, quotaScope);
}
return id;
}
return null;
}
1 change: 1 addition & 0 deletions src/config/schema/leaf-validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,7 @@ export const clientConnectionSchema = z.object({
*/
export const codexPoolSchema = z.object({
excludedPlans: z.array(z.string().trim().min(1)).optional(),
startIdleWindows: z.boolean().optional(),
}).strict();

/**
Expand Down
2 changes: 2 additions & 0 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1514,6 +1514,8 @@ export interface OcxWebSearchSidecarConfig {
* explicit account selection. Only automatic rotation skips it.
*/
export interface OcxCodexPoolConfig {
/** Start idle Codex windows when the pool is initialized. */
startIdleWindows?: boolean;
/**
* Plan keys ordinary rotation skips, matched case-insensitively against the plan stored on each
* account. Absent or empty means no policy.
Expand Down
2 changes: 1 addition & 1 deletion structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ Client connection metadata stores a stable `apiKeyId` and a non-secret rotation
Codex display-cache expiry, retained blocking main-policy evidence, and reset history follow the
[quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history).

`codexPool.excludedPlans` is interpreted only by automatic selection; its all-excluded and explicit-route behavior follows the [plan exclusion contract](providers/openai-accounts.md#automatic-pool-plan-exclusions).
`codexPool.excludedPlans` is interpreted only by automatic selection; its all-excluded and explicit-route behavior follows the [plan exclusion contract](providers/openai-accounts.md#automatic-pool-plan-exclusions). Optional `codexPool.startIdleWindows` defaults off and follows the [idle-window steering contract](providers/openai-accounts.md#idle-window-steering), using real new requests to start observed idle 5-hour windows.

Connected CLI usage follows the [client-scoped hub usage contract](dashboard-and-usage.md#usage-accounting); local management and account data remain separate.

Expand Down
18 changes: 18 additions & 0 deletions structure/providers/openai-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,24 @@ switch accounts, reset threads, or mutate affinity.

> Decision record: [ADR-0089](../decisions/ADR-0089-process-local-affinity-diagnostics.md)

## Idle-window steering

`codexPool.startIdleWindows` is an optional boolean that defaults to `false`. When it is `true`,
`src/codex/routing/idle-window.ts` may steer a new unbound real request after conversation and
family affinity have been checked. An explicit account pin and manual account preference take
precedence. Independent model quota scopes are excluded. The selection does not move the shared
active cursor, and normal strategy selection continues for other conversations.

An account is eligible only when its observed short quota is exactly 0%, its short window is
explicitly 18,000 seconds, the observation is no older than five minutes, and the observed reset
is within 60 seconds of `observation + 5h`. The synchronous process-local reservation prevents
duplicate selection of the same window. Its deadline is at least five hours and one minute after
selection; an observation after that deadline is required before that account can be steered again. The reservation
map is cleared on proxy restart and has no disk persistence.

This is request steering only. It sends no synthetic request and starts no timer. The ordinary
strategy path remains responsible for subsequent unbound selections.

## Account identity and store concurrency

Pool mode needs stable public names and a store that survives concurrent refresh:
Expand Down
149 changes: 149 additions & 0 deletions tests/codex-integration/codex-idle-window.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { createTempHome, type TempHome } from "../helpers/temp-home";
import { flushConfigDirHardeningForTests } from "../../src/config/paths";
import { markAccountNeedsReauth, clearAccountNeedsReauth } from "../../src/codex/account-runtime-state";
import { getDefaultConfig } from "../../src/config/proxy-env";
import { saveCodexAccountCredential } from "../../src/codex/account-store";
import { clearAccountQuota, getAccountQuota, setAccountQuotaFromParsed } from "../../src/codex/quota";
import { clearPoolRotationState } from "../../src/codex/pool-rotation";
import { clearCodexUpstreamHealth, clearThreadAccountMap, previewCodexAccountForRequest,
resolveCodexAccountForThread, resolveCodexAccountForThreadDetailed, resetCodexRoutingForManualSelection } from "../../src/codex/routing";
import { bindThreadAffinity, bindModelDetourAffinity } from "../../src/codex/routing/thread-affinity";
import type { OcxConfig } from "../../src/types";
import type { StoredAccountQuota } from "../../src/codex/quota-types";

const WINDOW = 5 * 60 * 60_000;
let now: number;
let home: TempHome;
let config: OcxConfig;
function idle(overrides: Partial<StoredAccountQuota> = {}) {
setAccountQuotaFromParsed("idle-b", { weeklyPercent: 20, shortPercent: 0,
shortWindowSeconds: 18000, shortResetAt: now + WINDOW });
Object.assign(getAccountQuota("idle-b")!, { shortObservedAt: now }, overrides);
}
function resolve(thread: string | null = null, at = now) {
return resolveCodexAccountForThread(thread, config, at);
}
beforeEach(() => {
home = createTempHome("ocx-idle-window-");
now = Date.now();
clearAccountQuota(); clearCodexUpstreamHealth(); clearThreadAccountMap(); clearPoolRotationState();
config = { ...getDefaultConfig(), activeCodexAccountId: "idle-a", accountPoolStrategy: "quota",
autoSwitchThreshold: 80, codexPool: { startIdleWindows: true },
codexAccounts: ["idle-a", "idle-b"].map(id => ({ id, email: `${id}@example.test`, isMain: false })) };
for (const id of ["idle-a", "idle-b"]) saveCodexAccountCredential(id, {
chatgptAccountId: `test-account-${id}`, accessToken: `test-${id}`, refreshToken: `test-refresh-${id}`, expiresAt: now + 2 * WINDOW,
});
setAccountQuotaFromParsed("idle-a", { weeklyPercent: 10 });
idle();
});
afterEach(async () => {
clearAccountQuota(); clearCodexUpstreamHealth(); clearThreadAccountMap(); clearPoolRotationState();
clearAccountNeedsReauth("idle-b");
await flushConfigDirHardeningForTests();
home.remove();
});

test.each([undefined, false])("disabled (%s) keeps ordinary selection", enabled => {
config.codexPool = enabled === undefined ? undefined : { startIdleWindows: enabled };
expect(resolve()).toBe("idle-a");
});
test("starts one real new conversation without changing the shared selection", () => {
expect(resolve("new")).toBe("idle-b");
expect(config.activeCodexAccountId).toBe("idle-a");
expect(resolve("other")).toBe("idle-a");
expect(resolve("new")).toBe("idle-b");
});
test("preview is read-only and agrees with resolve", () => {
for (let i = 0; i < 2; i++) expect(previewCodexAccountForRequest("new", config, now)).toBe("idle-b");
expect(resolve("new")).toBe("idle-b");
expect(previewCodexAccountForRequest("other", config, now)).toBe("idle-a");
});
test("bound conversations stay on their account", () => {
bindThreadAffinity("bound", "idle-a", now);
expect(resolve("bound")).toBe("idle-a");
expect(resolve("new")).toBe("idle-b");
});
test("manual pin and unspent manual preference outrank idle steering", () => {
config.activeCodexAccountPinned = "idle-a";
expect(resolve()).toBe("idle-a");
delete config.activeCodexAccountPinned;
resetCodexRoutingForManualSelection("idle-a");
expect(resolve()).toBe("idle-a");
});
test("sliding idle resets do not rearm steering, but a new window does", () => {
expect(resolve()).toBe("idle-b");
idle({ shortObservedAt: now + 120000, shortResetAt: now + WINDOW + 120000 });
expect(resolve(null, now + 120000)).toBe("idle-a");
now += WINDOW + 120001;
idle();
expect(resolve()).toBe("idle-b");
});
test.each([
{ shortPercent: 1 }, { shortPercent: undefined }, { shortWindowSeconds: 3600 },
{ shortWindowSeconds: undefined }, { shortResetAt: undefined }, { shortResetAt: NaN },
{ shortObservedAt: undefined },
])("incomplete/non-idle evidence falls back: %j", patch => {
idle(patch);
expect(resolve()).toBe("idle-a");
});
test("elapsed, ticking, stale, and future observations fail closed", () => {
for (const patch of [
{ shortResetAt: now - 1 }, { shortResetAt: now + WINDOW - 60001 },
{ shortResetAt: now + WINDOW + 60001 },
{ shortObservedAt: now - 300001, shortResetAt: now - 300001 + WINDOW },
{ shortObservedAt: now + 1, shortResetAt: now + 1 + WINDOW },
]) { idle(patch); expect(resolve()).toBe("idle-a"); }
});
test("seconds timestamps and the tolerance boundary are accepted", () => {
idle({ shortResetAt: (now + WINDOW - 60000) / 1000 });
expect(resolve()).toBe("idle-b");
});
test("paused and drained accounts are ineligible", () => {
config.pausedCodexAccountIds = ["idle-b"];
expect(resolve()).toBe("idle-a");
config.pausedCodexAccountIds = [];
idle({ weeklyPercent: 99 });
expect(resolve()).toBe("idle-a");
});
test.each(["round-robin", "fill-first", "reset-first"] as const)("%s resumes after steering", strategy => {
config.accountPoolStrategy = strategy;
expect(resolve()).toBe("idle-b");
idle({ shortPercent: 1, shortResetAt: undefined });
expect(resolve()).toBe("idle-a");
});

test("family affinity takes precedence over idle placement", () => {
bindThreadAffinity("parent", "idle-a", now);
const lineage = { conversationKey: "child", rootSessionKey: "root",
parentConversationKey: "parent", siblingConversationKeys: [] };
expect(previewCodexAccountForRequest("child", config, now, undefined, undefined, undefined, lineage)).toBe("idle-a");
expect(resolveCodexAccountForThread("child", config, now, undefined, lineage)).toBe("idle-a");
expect(resolve("unrelated")).toBe("idle-b");
});
test("model detour affinity is never diverted", () => {
bindModelDetourAffinity("bound", "idle-a", now, "test-model");
const options = { modelEligibleAccountIds: new Set(["idle-a", "idle-b"]) };
expect(previewCodexAccountForRequest("bound", config, now, undefined, options, "test-model")).toBe("idle-a");
expect(resolveCodexAccountForThreadDetailed("bound", config, now, undefined, options, "test-model").accountId).toBe("idle-a");
expect(resolve()).toBe("idle-b");
});
test("independent quota scopes do not use shared-window evidence", () => {
expect(previewCodexAccountForRequest(null, config, now, "reserve")).toBe("idle-a");
expect(resolveCodexAccountForThread(null, config, now, "reserve")).toBe("idle-a");
expect(resolve()).toBe("idle-b");
});
test("model-ineligible and reauthentication accounts cannot be steered", () => {
const options = { modelEligibleAccountIds: new Set(["idle-a"]) };
expect(resolveCodexAccountForThreadDetailed(null, config, now, undefined, options).accountId).toBe("idle-a");
markAccountNeedsReauth("idle-b");
expect(resolve()).toBe("idle-a");
});
test("plan exclusions still apply", () => {
config.codexAccounts![1]!.plan = "free";
config.codexPool!.excludedPlans = ["free"];
expect(resolve()).toBe("idle-a");
});
test("same-tick unbound requests reserve an idle account only once", () => {
expect(Array.from({ length: 4 }, () => resolve())).toEqual(["idle-b", "idle-a", "idle-a", "idle-a"]);
});
19 changes: 19 additions & 0 deletions tests/config/config-load-degrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,25 @@ test("config validation accepts only safe provider model display names", () => {
if (!invalid.ok) expect(invalid.error).toContain("modelDisplayNames");
});

test("config validation accepts the optional codex pool idle-window setting", () => {
const defaults = getDefaultConfig();
const enabled = validateConfigCandidate({
...defaults,
codexPool: { startIdleWindows: true },
});
expect(enabled).toMatchObject({
ok: true,
config: { codexPool: { startIdleWindows: true } },
});

const invalid = validateConfigCandidate({
...defaults,
codexPool: { startIdleWindows: "true" },
});
expect(invalid.ok).toBe(false);
if (!invalid.ok) expect(invalid.error).toContain("codexPool.startIdleWindows");
});

test("load keeps a provider and valid labels when one hand edited label is invalid", () => {
writeCandidate({
"grok-4.6": " Grok 4.6 ",
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,7 @@
"codex-pool-request-owned-main.test.ts": "codex-integration",
"codex-pool-rotation.test.ts": "codex-integration",
"codex-priority-failback.test.ts": "codex-integration",
"codex-idle-window.test.ts": "codex-integration",
"codex-prompt-adopt.test.ts": "codex-integration",
"codex-prompt-base-variants.test.ts": "codex-integration",
"codex-prompt-journal.test.ts": "codex-integration",
Expand Down
Loading