diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 5cfd93980a887..e3a052e68122f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1195,11 +1195,12 @@ function withCodexManagedFailure( function failureResetAt( failure: OrchestrationV2ProviderFailure, snapshot: CodexRateLimitSnapshot | undefined, + atMs: number, ) { // Sharing caps have no reset timestamp in the ordinary Codex allowance snapshot. return classifyCodexManagedError(failure.code)?.usageLimit ? null - : codexUsageLimitResetAt(snapshot); + : codexUsageLimitResetAt(snapshot, atMs); } interface ActiveCodexTurnContext { @@ -4691,8 +4692,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( mergeCodexRateLimits(previous, payload.rateLimits), ); const snapshot = yield* Ref.get(rateLimitSnapshot); + const atMs = DateTime.toEpochMillis(yield* DateTime.now); for (const item of (yield* Ref.get(limitedTurnItems)).values()) { - const resetAt = failureResetAt(item.failure, snapshot); + const resetAt = failureResetAt(item.failure, snapshot, atMs); if (resetAt === null || item.failure.resetAt != null) continue; const updated = { ...item, @@ -6114,7 +6116,11 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( failure.class === "usage_limit" ? { ...failure, - resetAt: failureResetAt(failure, yield* Ref.get(rateLimitSnapshot)), + resetAt: failureResetAt( + failure, + yield* Ref.get(rateLimitSnapshot), + DateTime.toEpochMillis(yield* DateTime.now), + ), } : failure, ...(input.providerRetry === undefined @@ -6154,7 +6160,11 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ...event.failure, resetAt: event.failure.resetAt ?? - failureResetAt(event.failure, yield* Ref.get(rateLimitSnapshot)), + failureResetAt( + event.failure, + yield* Ref.get(rateLimitSnapshot), + DateTime.toEpochMillis(yield* DateTime.now), + ), }, } : event; @@ -6187,7 +6197,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( event.failure.resetAt == null && // The account read only reports the ordinary allowance, never a sharing cap's reset. !classifyCodexManagedError(event.failure.code)?.usageLimit && - codexUsageLimitResetAt(yield* Ref.get(rateLimitSnapshot)) === null; + codexUsageLimitResetAt( + yield* Ref.get(rateLimitSnapshot), + DateTime.toEpochMillis(yield* DateTime.now), + ) === null; if (!awaitsReset) yield* publishRootTerminal(context, event); const continued = continuedRuns.get(context.providerTurnId) ?? [context]; if (event.status !== "completed") { diff --git a/apps/server/src/provider/codexUsageLimits.test.ts b/apps/server/src/provider/codexUsageLimits.test.ts index b560aba5d3c28..9b6e64d905707 100644 --- a/apps/server/src/provider/codexUsageLimits.test.ts +++ b/apps/server/src/provider/codexUsageLimits.test.ts @@ -300,16 +300,46 @@ describe("mergeCodexRateLimits", () => { import { codexUsageLimitResetAt } from "./codexUsageLimits.ts"; describe("codexUsageLimitResetAt", () => { + const stoppedAtMs = Date.parse("2033-05-18T03:00:00.000Z"); + it("waits for every exhausted window and never invents an unknown reset", () => { expect( - codexUsageLimitResetAt({ - primary: { usedPercent: 100, resetsAt: 2000000000 }, - secondary: { usedPercent: 100, resetsAt: 2000100000 }, - }), + codexUsageLimitResetAt( + { + primary: { usedPercent: 100, resetsAt: 2000000000 }, + secondary: { usedPercent: 100, resetsAt: 2000100000 }, + }, + stoppedAtMs, + ), ).toBe("2033-05-19T07:20:00.000Z"); - expect(codexUsageLimitResetAt({ primary: { usedPercent: 100 } })).toBeNull(); expect( - codexUsageLimitResetAt({ primary: { usedPercent: 50, resetsAt: 2000000000 } }), + codexUsageLimitResetAt( + { + primary: { usedPercent: 100, resetsAt: 2000000000 }, + secondary: { usedPercent: 99, resetsAt: 2000100000 }, + }, + stoppedAtMs, + ), + ).toBe("2033-05-18T03:33:20.000Z"); + expect( + codexUsageLimitResetAt({ primary: { usedPercent: 99, resetsAt: 2000000000 } }, stoppedAtMs), + ).toBe("2033-05-18T03:33:20.000Z"); + expect(codexUsageLimitResetAt({ primary: { usedPercent: 100 } }, stoppedAtMs)).toBeNull(); + expect( + codexUsageLimitResetAt({ primary: { usedPercent: 50, resetsAt: 2000000000 } }, stoppedAtMs), + ).toBeNull(); + }); + + it("ignores a window that has renewed since it was observed", () => { + const afterSessionResetMs = Date.parse("2033-05-18T04:00:00.000Z"); + expect( + codexUsageLimitResetAt( + { + primary: { usedPercent: 100, resetsAt: 2000000000 }, + secondary: { usedPercent: 40, resetsAt: 2000100000 }, + }, + afterSessionResetMs, + ), ).toBeNull(); }); }); diff --git a/apps/server/src/provider/codexUsageLimits.ts b/apps/server/src/provider/codexUsageLimits.ts index d4f9c621119df..eb235a168d331 100644 --- a/apps/server/src/provider/codexUsageLimits.ts +++ b/apps/server/src/provider/codexUsageLimits.ts @@ -186,12 +186,20 @@ export function mergeCodexRateLimits( }; } -/** All exhausted windows must reset before a continuation can run. */ +/** + * All exhausted windows must reset before a continuation can run. Codex reports whole + * percentages and can refuse a turn at 99%; windows whose reset has passed are stale. + */ export function codexUsageLimitResetAt( snapshot: CodexRateLimitSnapshot | undefined, + atMs: number, ): string | null { if (!snapshot) return null; - const windows = codexRateLimitsToWindows(snapshot).filter((window) => window.usedPercent >= 100); + const current = codexRateLimitsToWindows(snapshot).filter( + (window) => window.resetsAt === undefined || Date.parse(window.resetsAt) > atMs, + ); + const full = current.filter((window) => window.usedPercent >= 100); + const windows = full.length > 0 ? full : current.filter((window) => window.usedPercent >= 99); if (windows.length === 0 || windows.some((window) => !window.resetsAt)) return null; return windows.reduce( (latest, window) =>