From 11c92862ad8a3c1b13eb18ee7668fdb0bfae3753 Mon Sep 17 00:00:00 2001 From: Terry Tan Date: Sat, 26 Sep 2026 17:40:49 -0700 Subject: [PATCH] fix(codex): activate quota from saved deadlines --- .../docs/getting-started/how-it-works.mdx | 9 +- .../zh-cn/getting-started/how-it-works.mdx | 6 +- src/codex/quota-auto-refresh-state.ts | 6 +- src/codex/quota-auto-refresh.ts | 37 +++++-- structure/catalog.md | 8 +- structure/codex-home.md | 2 + structure/config.md | 6 +- structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/providers/openai-tiers.md | 13 ++- structure/runtime.md | 6 +- structure/subagents.md | 2 + ...-quota-auto-refresh-main-admission.test.ts | 5 +- .../codex-quota-auto-refresh.test.ts | 104 ++++++++++++++++++ 14 files changed, 179 insertions(+), 29 deletions(-) diff --git a/docs-site/src/content/docs/getting-started/how-it-works.mdx b/docs-site/src/content/docs/getting-started/how-it-works.mdx index c75ffed90e4..76b489f2812 100644 --- a/docs-site/src/content/docs/getting-started/how-it-works.mdx +++ b/docs-site/src/content/docs/getting-started/how-it-works.mdx @@ -51,8 +51,13 @@ account before the request is forwarded upstream. The rule is intentionally spli coalesces simultaneous windows into one request, and durably persists both reset timestamps to prevent duplicate work after restarts. Paused accounts and accounts requiring reauthentication are skipped. Activation captures successful response quota headers; - opted-in idle accounts also refresh stale quota metadata at most once every five minutes, - without needing an open dashboard. Observed reset boundaries are retained across restarts + known reset times are checked locally each minute without periodic quota queries, even when + the cached usage is old or the proxy restarts. Only missing reset times need a metadata query + after the five-minute freshness guard. Unresolved discovery and failed activations retry after + 5, 10, 20, 40, then at most every 60 minutes; these retry delays reset on proxy restart. + Successful response headers seed the next window without an extra query when available. + Dashboard refreshes and optional reset-notification polling remain independent. + Observed reset boundaries are retained across restarts until completed, so a moving idle-window timestamp cannot erase a pending activation. Metadata refresh uses the existing bounded authentication recovery; an inference 401 marks the rejected credential for reauthentication instead of repeatedly spending retries on it. diff --git a/docs-site/src/content/docs/zh-cn/getting-started/how-it-works.mdx b/docs-site/src/content/docs/zh-cn/getting-started/how-it-works.mdx index d234dd1ff5d..dbd75768d0a 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/how-it-works.mdx +++ b/docs-site/src/content/docs/zh-cn/getting-started/how-it-works.mdx @@ -38,7 +38,11 @@ Codex 使用 OpenAI **Responses API**。opencodex 接收通过 HTTP 与 Server-S 已报告的 5 小时及每周窗口;新添加账号不会自动启用。在 Pool 模式下,窗口到期后会通过对应账号 发送最小化、不保存的请求,并消耗少量额度;同时到期的窗口合并为一次请求。暂停、需要重新认证 的账号会被跳过,主账号硬锁限制也会得到遵守。成功响应的额度头会更新缓存;已启用且符合条件的 - 空闲账号还会每隔至少 5 分钟刷新过期的额度元数据,无需保持仪表盘打开。已观察到的到期时间会保留 + 空闲账号已有重置时间时,每分钟仅在本地检查是否到期,不会因缓存过期或代理重启而定期查询额度。 + 只有缺少重置时间时,才在五分钟新鲜度保护后补查。持续缺失信息或激活失败时,重试间隔依次为 + 5、10、20、40、60 分钟,并以 60 分钟封顶;重试间隔在代理重启后重新计算。 + 成功响应头提供下一轮时间时无需额外查询。仪表盘刷新及可选的重置通知轮询仍独立运行。 + 已观察到的到期时间会保留 至激活完成,重启或后续查询的时间变化不会丢失待处理窗口。元数据查询复用现有的有次数限制的认证 恢复逻辑;推理请求返回 401 时,被拒绝的凭据会标记为需要重新认证。失败日志仅记录不透明账号标签 和安全的状态原因。该功能独立于为传入请求选择账号的路由逻辑。 diff --git a/src/codex/quota-auto-refresh-state.ts b/src/codex/quota-auto-refresh-state.ts index 75ebe0db64e..149d9b97fc4 100644 --- a/src/codex/quota-auto-refresh-state.ts +++ b/src/codex/quota-auto-refresh-state.ts @@ -2,10 +2,12 @@ /** Completed/due markers use epoch milliseconds; persisted legacy markers may use seconds. */ export type CodexQuotaAutoRefreshWindows = { fiveHour?: number; weekly?: number }; +export type CodexQuotaRetry = { after: number; delay: number }; + export const completedByAccount = new Map(); -export const retryAfterByAccount = new Map(); +export const retryAfterByAccount = new Map(); export const scheduledByAccount = new Map(); -export const quotaRefreshAfterByAccount = new Map(); +export const quotaRefreshAfterByAccount = new Map(); /** Drop every activation record when its account is removed. */ export function forgetCodexQuotaAutoRefreshAccount(accountId: string): void { diff --git a/src/codex/quota-auto-refresh.ts b/src/codex/quota-auto-refresh.ts index cf21a46e169..53b8eb81cda 100644 --- a/src/codex/quota-auto-refresh.ts +++ b/src/codex/quota-auto-refresh.ts @@ -21,13 +21,14 @@ import { CodexWarmupError, codexWarmupFailureReason, warmCodexAccount } from "./ import { completedByAccount, retryAfterByAccount, scheduledByAccount, quotaRefreshAfterByAccount, resetCodexQuotaAutoRefreshStateForTests, - type CodexQuotaAutoRefreshWindows, + type CodexQuotaAutoRefreshWindows, type CodexQuotaRetry, } from "./quota-auto-refresh-state"; export type { CodexQuotaAutoRefreshWindows } from "./quota-auto-refresh-state"; export { forgetCodexQuotaAutoRefreshAccount } from "./quota-auto-refresh-state"; export const FIVE_HOUR_WINDOW_SECONDS = 5 * 60 * 60; const RETRY_MS = 5 * 60_000; +const MAX_RETRY_MS = 60 * 60_000; const CONCURRENCY = 4; export interface CodexQuotaAutoRefreshStatus { @@ -51,6 +52,21 @@ export interface CodexQuotaAutoRefreshRunDeps { let inFlight: Promise | null = null; +/** Back off unsuccessful discovery/activation without adding another timer. */ +function deferRetry(retries: Map, accountId: string, now: number): number { + const delay = Math.min((retries.get(accountId)?.delay ?? RETRY_MS / 2) * 2, MAX_RETRY_MS); + retries.set(accountId, { after: now + delay, delay }); + return delay; +} + +/** Every enabled window needs a retained, uncompleted deadline, not fresh usage percentages. */ +function hasScheduledWindows(config: OcxConfig, accountId: string): boolean { + const setting = config.codexQuotaAutoRefresh?.[accountId]; + const scheduled = scheduledByAccount.get(accountId); + return (!setting?.fiveHour || scheduled?.fiveHour !== undefined) + && (!setting?.weekly || scheduled?.weekly !== undefined); +} + /** Report upstream window availability separately from persisted spending intent. */ export function codexQuotaAutoRefreshStatus( config: OcxConfig, @@ -309,14 +325,19 @@ export async function runCodexQuotaAutoRefresh( // Capture before WHAM can move an idle window's reset into the future. rememberWindows(config, accountId, quotaFor(accountId)); const quota = quotaFor(accountId); - if ((!quota || now - quota.updatedAt >= RETRY_MS) - && (quotaRefreshAfterByAccount.get(accountId) ?? 0) <= now) { - quotaRefreshAfterByAccount.set(accountId, now + RETRY_MS); - try { await refresh(config, accountId); } catch { /* Retry metadata at the bounded cadence. */ } + // A known deadline remains actionable even when its usage snapshot is old. + // Only discover missing windows; never poll merely to keep percentages fresh. + if (hasScheduledWindows(config, accountId)) { + quotaRefreshAfterByAccount.delete(accountId); + } else if ((!quota || now - quota.updatedAt >= RETRY_MS) + && (quotaRefreshAfterByAccount.get(accountId)?.after ?? 0) <= now) { + deferRetry(quotaRefreshAfterByAccount, accountId, now); + try { await refresh(config, accountId); } catch { /* Retry missing metadata with backoff. */ } } if (!eligible(accountId)) return; rememberWindows(config, accountId, quotaFor(accountId)); - if ((retryAfterByAccount.get(accountId) ?? 0) > now) return; + if (hasScheduledWindows(config, accountId)) quotaRefreshAfterByAccount.delete(accountId); + if ((retryAfterByAccount.get(accountId)?.after ?? 0) > now) return; const windows = dueCodexQuotaAutoRefreshWindows(config, accountId, quotaFor(accountId), now); if (!windows) return; try { @@ -327,11 +348,11 @@ export async function runCodexQuotaAutoRefresh( persist(config, accountId, completed); rememberWindows(config, accountId, quotaFor(accountId)); } catch (error) { - retryAfterByAccount.set(accountId, now + RETRY_MS); + const delay = deferRetry(retryAfterByAccount, accountId, now); const account = config.codexAccounts?.find(candidate => candidate.id === accountId); const label = account ? codexAccountLogLabel(account) : "main"; console.warn(`[codex-quota-auto-refresh] ${label}: ${codexWarmupFailureReason(error)}; ${ - isAccountNeedsReauth(accountId) ? "reauthentication required" : "retry in five minutes" + isAccountNeedsReauth(accountId) ? "reauthentication required" : `retry in ${delay / 60_000} minutes` }`); } })); diff --git a/structure/catalog.md b/structure/catalog.md index d0d054768b5..0c79ecbc20c 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -1,5 +1,7 @@ # Model Catalog +Activation-owned metadata discovery no longer refreshes known deadlines merely because quota snapshots age. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). + Native result continuations and function-result injection follow [the mode-specific result and control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Explicit Codex CLI installation observation supplies no selected-runtime proof to catalog discovery or publication. See the [read-only observation contract](runtime.md#explicit-codex-cli-installation-observation). @@ -593,8 +595,6 @@ Subagent account previews and live routing share the [priority failback](provide Startup and explicit catalog synchronization in `src/codex/sync.ts` refresh the optional `src/providers/reasoning-metadata.ts` effort snapshot for supported destinations before catalog -gathering. Each sync waits at most two seconds for a fresh or shared fetch, then continues with -the existing snapshot; the fetch retains its own abort deadline. Routed effort reads in +gathering. Each sync waits at most two seconds for a fresh or shared fetch, then continues with the existing snapshot; the fetch retains its own abort deadline. Routed effort reads in `src/reasoning-effort.ts` use a snapshot immediately and request a best-effort background refresh -only when an existing snapshot answers with an expired ladder. Missing or corrupt snapshots do -not fetch on the request path; catalog sync owns their bootstrap. +only when an existing snapshot answers with an expired ladder. Missing or corrupt snapshots do not fetch on the request path; catalog sync owns their bootstrap. diff --git a/structure/codex-home.md b/structure/codex-home.md index 14a20e9c4c3..468718ae1bc 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -1,5 +1,7 @@ # Codex Home +Quota activation restores deadlines from OpenCodex settings; its retry backoff remains process-local. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). + Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). A lock in the Codex credential store is governed by [descriptor identity and age](catalog.md#accounts-namespaces-and-pool-rotation), so the mere presence of its filename is neither acquisition nor release authority. Failed path-identity probes leave the lock for stale recovery and preserve the refresh callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. diff --git a/structure/config.md b/structure/config.md index 98490672149..51317d840d5 100644 --- a/structure/config.md +++ b/structure/config.md @@ -1,5 +1,7 @@ # Config Surface +Quota activation reuses the existing next-reset fields without adding a polling configuration key. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). + Native function-result injection follows [the separate opt-in control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. @@ -589,9 +591,7 @@ being treated as a text model by one and an image target by the other. malformed persisted value is off. `src/config/schema/config-schema.ts` degrades a malformed hand edit to absence so an optional monitoring typo cannot discard providers or credentials. The live-write boundary runs `metricsExportConfigError` in `src/config/diagnostics.ts` before the degrading schema, -so wrong types and unknown nested fields are rejected rather than silently saved. Activation is read -when the server process creates its serve options and therefore requires restart; it adds no setting -to the live `/api/settings` mutation surface. +so wrong types and unknown nested fields are rejected rather than silently saved. Activation is read when the server process creates its serve options and therefore requires restart; it adds no setting to the live `/api/settings` mutation surface. `apiSurfaces` and `protocols` on `src/types/config.ts` are parsed by `src/protocols/settings.ts` only; [Protocol Paths](data-planes/protocol-paths.md#settings) owns their schema handling, meaning and the one writer (`PATCH /api/protocols/settings`), including why closing Messages also writes `claudeCode.enabled` through `commitClaudeCodeBlock` (`src/claude/claude-code-block.ts`, the sentinel-stamping block writer every management route uses). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 1a402478f97..a8e97b0821c 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,5 +1,7 @@ # GUI And Management API +Automatic activation retains its existing settings controls; dashboard quota queries remain independent. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). + The companion settings contract in `src/companion/` persists menu-bar and widget display preferences, while `src/server/management/companion-routes.ts` exposes those settings and the usage timeline assembled by `src/usage/timeline.ts` to local clients. Query, filter-echo and diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index b2f84c4a57c..e9c860c8b2f 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -1,5 +1,7 @@ # Docs And Release +The activation scheduling contract is covered by `tests/codex-integration/codex-quota-auto-refresh.test.ts`, including restart recovery and bounded retries. See the [quota activation contract](../providers/openai-tiers.md#public-provider-contract). + Automatic package-tree restart holds a releasable data-plane drain until its scheduled service-home check succeeds. A veto releases that fence; a committed shutdown uses the permanent drain latch. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 733cd1aec8c..00187126f80 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -204,12 +204,17 @@ existing minimal non-stored warmup through that exact account once the timestamp field-patches the completed timestamp. The next observed reset boundary is also retained in `nextFiveHourResetAt` / `nextWeeklyResetAt` until completed; later idle-window metadata cannot postpone it. Successful warmups publish quota headers under the captured credential/identity fence. -For opted-in accounts only, stale metadata is refreshed at most once per five minutes through -the existing WHAM recovery path, independently of dashboard traffic or reset notifications. +Known deadlines suppress activation-owned WHAM queries regardless of snapshot age, including +when only persisted deadlines survive a restart. Missing enabled-window deadlines use the existing +WHAM recovery path after the five-minute freshness guard; unresolved discovery backs off from +five minutes to an hour (5, 10, 20, 40, 60 minutes). Passive headers can satisfy discovery without +a query. Completed warmups seed the next deadlines from response headers; missing next-window +headers use the same discovery path. Retry delays are process-local; deadlines remain durable. +Dashboard queries and reset-notification polling are separate owners and retain their behavior. Inference 401s quarantine the rejected credential; failures log an opaque label and safe reason. Paused or reauthentication-required -accounts are skipped, simultaneous 5-hour/weekly resets share one warmup, transient failures retry -after five minutes, and account deletion removes its setting and completion markers. +accounts are skipped, simultaneous 5-hour/weekly resets share one warmup, transient activation failures +back off from five minutes to an hour, and account deletion removes settings and retry/completion state. Main-account hard-lock also gates these billable warmups. A policy/identity skip changes neither completion markers nor retry delay; quota reads remain available. Main refresh completes before shared credential ownership, then prepared credentials and restrictions are rechecked. Lifecycle diff --git a/structure/runtime.md b/structure/runtime.md index 11a8444d9f1..b3a940e8c7a 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,5 +1,7 @@ # Runtime +The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). + ## Resolved static model policy `src/router.ts` attaches one frozen `ResolvedModelPolicy` to every `RouteResult`. Policy/combo @@ -589,9 +591,7 @@ an unreadable current record is unknown, and a valid address is probed even when PID is gone. Lease delegation is passed only to stop and recovery children, never package manager children. A replacement refusal passes through owner-aware recovery: only the same CLI owner revives the stopped runtime; foreign ownership stays transferred and unknown ownership -remains a reported recovery requirement. Dashboard restart delegates the lease token to its repair child. Direct -start holds the same lease through bind plus PID and runtime-address publication. If listener -rollback cannot prove the socket closed, the process retains its lease until exit. +remains a reported recovery requirement. Dashboard restart delegates the lease token to its repair child. Direct start holds the same lease through bind plus PID and runtime-address publication. If listener rollback cannot prove the socket closed, the process retains its lease until exit. The registration is never deleted; `ocx service install` releases the marker only after the registration succeeds. diff --git a/structure/subagents.md b/structure/subagents.md index 3f6370e1fe5..40ec975f6bc 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -1,5 +1,7 @@ # Subagents And Multi-Agent Surface +Subagent quota priming remains separate from automatic activation scheduling. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). + Native result continuations and function-result injection follow [the mode-specific result and control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Explicit Codex CLI installation observation does not attest the runtime used by a subagent or change agent selection. See the [read-only observation contract](runtime.md#explicit-codex-cli-installation-observation). diff --git a/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts index 204436b3a87..946e23b56f3 100644 --- a/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts +++ b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts @@ -138,12 +138,13 @@ afterEach(async () => { }); describe("quota auto-refresh native-main admission", () => { - test("stale metadata prepares an expired main token before WHAM and activation", async () => { + test.each([false, true])("expired main token is prepared before activation (missing deadline: %s)", async missingDeadline => { const cfg = config(); writeMain(bearer(true)); const cached = getAccountQuota(MAIN); if (!cached) throw new Error("Expected cached main quota"); cached.updatedAt = now - 300_000; + if (missingDeadline) delete cached.shortResetAt; const fresh = bearer(); const calls = installFetch(async (url, init) => { if (url === tokenUrl) { @@ -157,7 +158,7 @@ describe("quota auto-refresh native-main admission", () => { return completedResponse(); }); await runCodexQuotaAutoRefresh(cfg, now, { persistCompleted: recordMarkers }); - expect(calls).toEqual([tokenUrl, whamUrl, responsesUrl]); + expect(calls).toEqual(missingDeadline ? [tokenUrl, whamUrl, responsesUrl] : [tokenUrl, responsesUrl]); expect(isAccountNeedsReauth(MAIN)).toBe(false); expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastFiveHourResetAt).toBe(RESET_MILLISECONDS); expect(getNativeMainProfileRequestCount()).toBe(0); diff --git a/tests/codex-integration/codex-quota-auto-refresh.test.ts b/tests/codex-integration/codex-quota-auto-refresh.test.ts index 375317d9a11..a733fba73c7 100644 --- a/tests/codex-integration/codex-quota-auto-refresh.test.ts +++ b/tests/codex-integration/codex-quota-auto-refresh.test.ts @@ -226,6 +226,110 @@ describe("Codex quota window auto refresh", () => { expect(warmups).toBe(0); }); + test.each(["pool-a", "__main__"])("known deadlines need no metadata polling for %s, including after restart", async accountId => { + let cfg = config(); + cfg.codexQuotaAutoRefresh = { [accountId]: { fiveHour: true, weekly: true } }; + writeFileSync(join(testHome, "config.json"), JSON.stringify(cfg)); + let observed: StoredAccountQuota | null = quota({ + shortResetAt: RESET_SECONDS + 18_000, weeklyResetAt: RESET_SECONDS + 18_000, + updatedAt: NOW - 600_000, + }); + let probes = 0; + let warmups = 0; + const deps = { + getQuota: () => observed, + refreshQuota: async () => { probes++; }, + warmAccount: async () => { + warmups++; + observed = quota({ shortResetAt: RESET_SECONDS + 36_000, weeklyResetAt: RESET_SECONDS + 604_800 }); + }, + }; + await runCodexQuotaAutoRefresh(cfg, NOW, deps); + resetCodexQuotaAutoRefreshForTests(); + cfg = loadConfig(); + observed = null; // Durable deadlines must work without an in-memory quota snapshot. + for (let elapsed = 60_000; elapsed < 18_000_000; elapsed += 60_000) { + await runCodexQuotaAutoRefresh(cfg, NOW + elapsed, deps); + } + expect(probes).toBe(0); + expect(warmups).toBe(0); + await runCodexQuotaAutoRefresh(cfg, NOW + 18_000_000, deps); + expect(warmups).toBe(1); + expect(probes).toBe(0); + expect(loadConfig().codexQuotaAutoRefresh?.[accountId]).toMatchObject({ + lastFiveHourResetAt: NOW + 18_000_000, lastWeeklyResetAt: NOW + 18_000_000, + nextFiveHourResetAt: NOW + 36_000_000, nextWeeklyResetAt: NOW + 604_800_000, + }); + await runCodexQuotaAutoRefresh(cfg, NOW + 18_060_000, deps); + expect(probes).toBe(0); + expect(warmups).toBe(1); + }); + + test("missing-window discovery backs off to an hour and stops when passive headers supply it", async () => { + const cfg = config(); + let observed = quota({ shortResetAt: RESET_SECONDS + 86_400, weeklyResetAt: undefined, updatedAt: NOW - 600_000 }); + let probes = 0; + const deps = { + getQuota: () => observed, + refreshQuota: async () => { probes++; }, // A successful read without the missing field also backs off. + warmAccount: async () => { throw new Error("not due"); }, + }; + let elapsed = 0; + await runCodexQuotaAutoRefresh(cfg, NOW, deps); + for (const delay of [5, 10, 20, 40, 60, 60]) { + await runCodexQuotaAutoRefresh(cfg, NOW + elapsed + delay * 60_000 - 1, deps); + const before = probes; + elapsed += delay * 60_000; + await runCodexQuotaAutoRefresh(cfg, NOW + elapsed, deps); + expect(probes).toBe(before + 1); + } + expect(probes).toBe(7); + observed = { ...observed, weeklyResetAt: RESET_SECONDS + 604_800 }; + await runCodexQuotaAutoRefresh(cfg, NOW + elapsed + 60_000, deps); + await runCodexQuotaAutoRefresh(cfg, NOW + elapsed + 3_600_000, deps); + expect(probes).toBe(7); + }); + + test("activation without next-window headers discovers the next deadline once", async () => { + const cfg = config(); + cfg.codexQuotaAutoRefresh = { "pool-a": { fiveHour: true } }; + let observed = quota(); + let probes = 0; + let warmups = 0; + const deps = { + getQuota: () => observed, + refreshQuota: async () => { + probes++; + observed = quota({ shortResetAt: RESET_SECONDS + 18_000 }); + }, + warmAccount: async () => { warmups++; }, + persistCompleted: recordMarkers, + }; + await runCodexQuotaAutoRefresh(cfg, NOW, deps); + await runCodexQuotaAutoRefresh(cfg, NOW + 300_000, deps); + await runCodexQuotaAutoRefresh(cfg, NOW + 600_000, deps); + expect(warmups).toBe(1); + expect(probes).toBe(1); + }); + + test("failed activations back off without probing known deadlines", async () => { + const cfg = config(); + let probes = 0; + let warmups = 0; + const deps = { + getQuota: () => quota({ updatedAt: NOW - 600_000 }), + refreshQuota: async () => { probes++; }, + warmAccount: async () => { warmups++; throw new Error("fixture failure"); }, + }; + await runCodexQuotaAutoRefresh(cfg, NOW, deps); + await runCodexQuotaAutoRefresh(cfg, NOW + 300_000, deps); + await runCodexQuotaAutoRefresh(cfg, NOW + 600_000, deps); + expect(warmups).toBe(2); + await runCodexQuotaAutoRefresh(cfg, NOW + 900_000, deps); + expect(warmups).toBe(3); + expect(probes).toBe(0); + }); + test("regression: inference 401 quarantines a time-valid bearer and stops retries", async () => { const cfg = config(); writePoolCredential();