diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index e9d5f6e604f31..0bf1039647467 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1011,6 +1011,57 @@ describe("pooled account columns", () => { expect(accounts).toEqual(snapshot); }); + it("treats OpenCode Go session and weekly quota as spent while the monthly quota is", () => { + const go = (key: string, rolling: number, weeklyUsed: number, monthly: number) => ({ + ...account(key, [ + { ...window, id: "go_rolling", usedPercent: rolling }, + { ...weekly, id: "go_weekly", usedPercent: weeklyUsed }, + { + ...window, + id: "go_monthly", + kind: "monthly", + usedPercent: monthly, + windowDurationMins: undefined, + resetsAt: "2026-09-20T12:00:00.000Z", + }, + ]), + driver: ProviderDriverKind.make("opencode"), + }); + const [pool] = collectLimitPools([go("a", 1, 24, 20), go("b", 0, 0, 100)], now); + expect + .soft(pool!.windows.map((row) => row.columns[1]?.window?.usedPercent)) + .toEqual([100, 100, 100]); + expect(pool!.windows.map((row) => row.remainingPercent)).toEqual([50, 38, 40]); + }); + + it("holds an OpenCode Go session spent until the last spent longer quota resets", () => { + const monthlyReset = "2026-09-20T12:00:00.000Z"; + const weeklyLater = "2026-09-25T12:00:00.000Z"; + const go = (key: string, monthlyUsed: number, weeklyReset = "2026-09-05T12:00:00.000Z") => ({ + ...account(key, [ + { ...window, id: "go_rolling", usedPercent: 0 }, + { ...weekly, id: "go_weekly", usedPercent: 100, resetsAt: weeklyReset }, + { + ...window, + id: "go_monthly", + kind: "monthly", + usedPercent: monthlyUsed, + windowDurationMins: undefined, + resetsAt: monthlyReset, + }, + ]), + driver: ProviderDriverKind.make("opencode"), + }); + const sessions = [go("weekly", 40), go("both", 100), go("weekly-later", 100, weeklyLater)].map( + (account) => collectLimitPools([account], now)[0]!.windows[0]!, + ); + expect.soft(sessions.map((session) => session.remainingPercent)).toEqual([0, 0, 0]); + expect(sessions.slice(1).map((session) => session.resets[0]?.at)).toEqual([ + Date.parse(monthlyReset), + Date.parse(weeklyLater), + ]); + }); + it("keeps session columns across rows with opposite reset and usage orders", () => { const accounts = [ account("a", [ diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 1707deb735a49..308c138a954b7 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -511,45 +511,66 @@ function poolKey(driver: ServerProvider["driver"], window: ServerProviderUsageWi return driver === "codex" ? window.kind : `${window.kind}:${window.id}`; } +// A spent blocker window leaves no usable quota in the windows it blocks. +const BLOCKS = new Map< + string, + (blocker: ServerProviderUsageWindow, reported: ServerProviderUsageWindow) => boolean +>([ + ["claudeAgent", (blocker, reported) => reported.kind === "session" && blocker.id === "seven_day"], + ["codex", (blocker, reported) => reported.kind === "session" && blocker.kind === "weekly"], + [ + "opencode", + (blocker, reported) => + blocker.id === "go_monthly" + ? reported.id === "go_rolling" || reported.id === "go_weekly" + : blocker.id === "go_weekly" && reported.id === "go_rolling", + ], +]); + function poolMember(account: LimitAccount, reported: ServerProviderUsageWindow, now: number) { - const spentWeekly = - reported.kind === "session" - ? account.limits.windows.find( - (window) => - window.usedPercent >= 100 && - (account.driver === "claudeAgent" - ? window.id === "seven_day" - : account.driver === "codex" && window.kind === "weekly"), + const blocks = BLOCKS.get(account.driver); + // With several spent blockers, quota returns only when the last of them resets. + const blocker = blocks + ? account.limits.windows + .filter((window) => window.usedPercent >= 100 && blocks(window, reported)) + .reduce( + (latest, window) => + latest && + (resetMillis(latest) ?? Number.POSITIVE_INFINITY) >= + (resetMillis(window) ?? Number.POSITIVE_INFINITY) + ? latest + : window, + undefined, ) - : undefined; - if (!spentWeekly) return { account, window: reported, refillPercent: reported.usedPercent }; + : undefined; + if (!blocker) return { account, window: reported, refillPercent: reported.usedPercent }; - const weeklyReset = resetMillis(spentWeekly); - const sessionReset = resetMillis(reported); + const blockerReset = resetMillis(blocker); + const reportedReset = resetMillis(reported); const cutoff = Math.max(now, Date.parse(account.limits.checkedAt)); let resetsAt: string | undefined; let refillPercent = 0; // A stale or missing clock cannot tell us when blocked quota becomes usable. if ( - weeklyReset !== null && - sessionReset !== null && - weeklyReset > cutoff && - sessionReset > cutoff + blockerReset !== null && + reportedReset !== null && + blockerReset > cutoff && + reportedReset > cutoff ) { - if (sessionReset <= weeklyReset) { - resetsAt = spentWeekly.resetsAt; + if (reportedReset <= blockerReset) { + resetsAt = blocker.resetsAt; refillPercent = 100; } else if (reported.usedPercent >= 100) { resetsAt = reported.resetsAt; refillPercent = 100; } else { - resetsAt = spentWeekly.resetsAt; + resetsAt = blocker.resetsAt; refillPercent = 100 - Math.max(0, Math.min(100, reported.usedPercent)); } } return { account, - // Suppression is not session spending, so it has no session pace clock. + // Suppression is not spending in the reported window, so it has no pace clock. window: { ...reported, usedPercent: 100, resetsAt, windowDurationMins: undefined }, refillPercent, };