From cf62b009116ad1365928a605b1f4887dcbc810e6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 18 Oct 2026 11:06:31 +0700 Subject: [PATCH] Keep last successful usage visible when refresh fails (#7246) * fix(usage): retain last successful data when polls fail Keep the latest poll outcome separate from its successful snapshot so errors remain visible without discarding known usage or advancing its freshness. Preserve attempt timestamps for response ordering and isolate retained values by scoped login set. * fix(usage): preserve history through discovery failures A failed account-store read does not confirm that an account was removed. Retain its existing host mapping until successful discovery replaces it, without carrying mappings across agent launch environments. --- packages/app/e2e/browser/usage-modal.spec.ts | 66 +++++- .../app/e2e/support/helpers/usage-login.ts | 11 + packages/app/src/usage/balance-bar.tsx | 10 +- packages/app/src/usage/card.tsx | 68 +++--- packages/app/src/usage/format.test.ts | 9 +- packages/app/src/usage/format.ts | 3 +- packages/app/src/usage/model.test.ts | 70 ++++++ packages/app/src/usage/model.ts | 43 +++- packages/app/src/usage/pinned.test.ts | 6 +- packages/app/src/usage/window-bar.tsx | 9 +- packages/cli/src/commands/usage/index.test.ts | 37 ++++ .../cli/src/commands/usage/presentation.ts | 73 +++++-- packages/protocol/src/messages.ts | 20 +- .../protocol/src/messages.wire-compat.test.ts | 38 ++++ .../plugins/usage-sources/index.test.ts | 205 ++++++++++++++++++ .../src/server/plugins/usage-sources/index.ts | 70 +++++- 16 files changed, 645 insertions(+), 93 deletions(-) diff --git a/packages/app/e2e/browser/usage-modal.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts index a923971d09..f51c0ad510 100644 --- a/packages/app/e2e/browser/usage-modal.spec.ts +++ b/packages/app/e2e/browser/usage-modal.spec.ts @@ -16,6 +16,7 @@ import { } from "../support/helpers/usage-reports"; import { installLoginUsage, + expectLoginUsageFreshness, installCodexWindowUsage, expectCodexReportedWindows, refreshLoginUsage, @@ -588,6 +589,69 @@ async function expectLoginErrors(page: Page, lines: string[]) { async function expectLoginUsageOnly(page: Page, percentage: string) { const card = page.getByTestId("usage-report-login-journey:account"); - await expect(card.getByText(percentage, { exact: true })).toBeVisible(); + await expect(card.getByText(new RegExp(`^${percentage}(?:$| ·)`))).toBeVisible(); await expect(card.getByTestId("usage-login-error")).toHaveCount(0); } + +for (const compact of [false, true]) { + test(`failed polls retain usage and successful freshness (${compact ? "footer" : "tooltip"})`, async ({ + page, + }) => { + const fixture = await installLoginUsage({ status: "error", error: "Initial failure" }); + try { + await page.setViewportSize( + compact ? { width: 390, height: 844 } : { width: 1440, height: 900 }, + ); + await gotoAppShell(page); + if (compact) await openCompactSidebar(page); + await openUsageFromIcon(page); + await expectLoginErrors(page, ["Claude: Initial failure"]); + await expectLoginUsageFreshness(page, "No successful update", compact); + + await fixture.setReport({ + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct: 42, resetsAt: "2000-01-01T00:00:00Z" }], + }); + // Move only the browser clock; real daemon fetches continue using the host clock. + await page.clock.setFixedTime(new Date(Date.now() + 3_900_000)); + await refreshLoginUsage(page); + await expectLoginUsageOnly(page, "42%"); + await expectLoginUsageFreshness(page, "Updated 1h ago", compact); + + await fixture.setReport({ status: "error", error: "Rate limited" }); + await refreshLoginUsage(page); + await expectRetainedLoginUsage(page, "Claude: Rate limited"); + await expectLoginUsageFreshness(page, "Last updated 1h ago", compact); + await refreshLoginUsage(page); + await expectRetainedLoginUsage(page, "Claude: Rate limited"); + await expectLoginUsageFreshness(page, "Last updated 1h ago", compact); + + await fixture.setReport({ + status: "unavailable", + problem: { kind: "no_quota", detail: "No active quota" }, + }); + await refreshLoginUsage(page); + await expectRetainedLoginUsage(page, "Claude: No active quota"); + await expectLoginUsageFreshness(page, "Last updated 1h ago", compact); + + await fixture.setReport({ + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct: 43 }], + }); + await page.clock.setFixedTime(new Date(Date.now() + 150_000)); + await refreshLoginUsage(page); + await expectLoginUsageOnly(page, "43%"); + await expect(page.getByText("42%", { exact: true })).toHaveCount(0); + await expectLoginUsageFreshness(page, "Updated 2m ago", compact); + } finally { + await fixture.cleanup(); + } + }); +} + +async function expectRetainedLoginUsage(page: Page, message: string) { + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByText(/^42%(?:$| ·)/)).toBeVisible(); + await expect(card.getByTestId("usage-login-error")).toHaveText(message); + await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible(); +} diff --git a/packages/app/e2e/support/helpers/usage-login.ts b/packages/app/e2e/support/helpers/usage-login.ts index 6129f0259e..25ee01d5d5 100644 --- a/packages/app/e2e/support/helpers/usage-login.ts +++ b/packages/app/e2e/support/helpers/usage-login.ts @@ -135,3 +135,14 @@ export async function expectCodexReportedWindows(page: Page, shape: "seven-day-o ); } } + +export async function expectLoginUsageFreshness(page: Page, text: string, compact: boolean) { + const card = page.getByTestId("usage-report-login-journey:account"); + if (compact) { + await expect(card.getByTestId("usage-freshness")).toHaveText(text); + return; + } + await card.getByRole("button", { name: "Refresh Claude", exact: true }).hover(); + await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText(text); + await page.mouse.move(0, 0); +} diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index 751193eec0..38b2f0804a 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -41,11 +41,17 @@ function fillToneStyle(tone: UsageTone) { } } -export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { +export function UsageBalanceBar({ + balance, + retained = false, +}: { + balance: UsageBalance; + retained?: boolean; +}) { const { i18n } = useTranslation(); const { amountText, usedPct } = resolveBalance(balance, i18n.language); const tone = balance.tone ?? "default"; - const resetLabel = formatResetLabel(balance.resetsAt); + const resetLabel = formatResetLabel(balance.resetsAt, retained); const fillStyle = useMemo>( () => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }], diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx index a1936e0f97..562f3805a1 100644 --- a/packages/app/src/usage/card.tsx +++ b/packages/app/src/usage/card.tsx @@ -16,7 +16,7 @@ import { useCompactTimeAgo } from "@/hooks/use-time-ago"; import { UsageBalanceBar } from "./balance-bar"; import { usageCopy } from "./copy"; import type { UsageDisplay } from "./display"; -import { formatUsageFreshness, type UsageRefresh } from "./model"; +import { formatUsageFreshness, resolveUsageReport, type UsageRefresh } from "./model"; import { useReportRefresh } from "./queries"; import { UsageSourceIcon } from "./source-icon"; import type { UsageReport, UsageReportEntry, UsageWindow } from "./types"; @@ -27,31 +27,6 @@ function statusText(report: UsageReport): string | null { return report.status === "error" ? "Error" : "Unavailable"; } -function reportContent(report: UsageReport) { - if (report.status === "available") - return { - windows: report.windows, - balances: report.balances ?? [], - details: report.details ?? [], - message: undefined, - }; - const message = - report.status === "unavailable" ? usageCopy.problem(report.problem) : report.error; - return { windows: [], balances: [], details: [], message }; -} - -function reportMessages(entry: UsageReportEntry): string[] { - if (entry.report.status === "available") return []; - if (entry.loginErrors) - return entry.loginErrors.map( - (login) => `${login.harness}: ${reportContent(login.report).message}`, - ); - // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0. - return [ - entry.report.status === "error" ? entry.report.error : usageCopy.problem(entry.report.problem), - ]; -} - const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); @@ -81,8 +56,8 @@ export function UsageCard({ const usage = entry.report; const status = statusText(usage); const footer = entry.account.label ?? null; - const { windows, balances, details } = reportContent(usage); - const messages = reportMessages(entry); + const { windows, balances, details, planLabel, successfulAt, retained, messages } = + resolveUsageReport(entry); const containerStyle = useMemo( () => [styles.container, compact ? styles.containerCompact : styles.containerPadded], @@ -104,9 +79,7 @@ export function UsageCard({ {entry.sourceLabel} - {usage.status === "available" && usage.planLabel ? ( - - ) : null} + {planLabel ? : null} {status ? ( @@ -117,7 +90,8 @@ export function UsageCard({ {refreshable ? ( ))} {balances.map((balance) => ( - + ))} ) : null} @@ -170,7 +145,8 @@ export function UsageCard({ {showsFreshnessInline ? ( @@ -192,7 +168,9 @@ function CardWindowBar({ window, display, pinnable, + retained, }: { + retained: boolean; entry: UsageReportEntry; window: UsageWindow; display: UsageDisplay; @@ -207,6 +185,7 @@ function CardWindowBar({ return ( void; compact: boolean; @@ -236,12 +217,13 @@ function UsageRefreshButton({ const freshness = useMemo( () => ( ), - [fetchedAt], + [successfulAt, retained], ); return ( `, not the card. */ function UsageFreshness({ - fetchedAt, + successfulAt, + retained, style, testID, }: { - fetchedAt: string; + successfulAt: string | null; + retained: boolean; style: StyleProp; testID: string; }) { - const elapsed = useCompactTimeAgo(new Date(fetchedAt)); + const elapsed = useCompactTimeAgo(successfulAt ? new Date(successfulAt) : null); return ( - {formatUsageFreshness(elapsed)} + {successfulAt ? formatUsageFreshness(elapsed, retained) : "No successful update"} ); } diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index 88fe227ae6..22529c8d71 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { formatAmount } from "./format"; +import { formatAmount, formatResetLabel } from "./format"; describe("formatAmount", () => { it("groups thousands in the app's language", () => { @@ -13,3 +13,10 @@ describe("formatAmount", () => { expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US"); }); }); + +it("retained reset times that have passed do not claim the quota is resetting", () => { + const past = "2000-01-01T00:00:00.000Z"; + expect(formatResetLabel(past, true)).toBe("Reset time passed"); + expect(formatResetLabel(past)).toBe("resetting now"); + expect(formatResetLabel(null, true)).toBeNull(); +}); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index 5adef2154d..e13a550e7b 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -27,10 +27,11 @@ function relativeDuration(iso: string): string | null { return `${diffMinutes}m`; } -export function formatResetLabel(iso: string | null | undefined): string | null { +export function formatResetLabel(iso: string | null | undefined, retained = false): string | null { if (!iso) return null; const rel = relativeDuration(iso); if (!rel) return null; + if (rel === "now" && retained) return "Reset time passed"; return rel === "now" ? "resetting now" : `resets ${rel}`; } diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index a2c493619d..13da42a6eb 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + resolveUsageReport, displayPercent, formatUsageFreshness, resolveAgentUsageView, @@ -325,3 +326,72 @@ describe("resolveAgentUsageView", () => { ).toEqual({ kind: "error", message: "Unable to load usage: Unknown agent" }); }); }); + +it("resolves retained usage and successful time without inventing old-daemon history", () => { + const success = entry({ windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] }); + const failure: UsageReportEntry = { + ...success, + fetchedAt: "2026-01-01T00:06:00.000Z", + report: { status: "error", error: "Rate limited" }, + lastSuccessful: { + fetchedAt: success.fetchedAt, + report: success.report as Extract, + }, + }; + expect(resolveUsageReport(failure)).toEqual({ + windows: success.report.status === "available" ? success.report.windows : [], + balances: [], + details: [], + planLabel: null, + successfulAt: success.fetchedAt, + retained: true, + messages: ["Rate limited"], + }); + expect(resolveUsageReport({ ...failure, lastSuccessful: undefined })).toEqual({ + windows: [], + balances: [], + details: [], + planLabel: null, + successfulAt: null, + retained: false, + messages: ["Rate limited"], + }); + expect(resolveUsageReport(success)).toEqual({ + windows: success.report.status === "available" ? success.report.windows : [], + balances: [], + details: [], + planLabel: null, + successfulAt: success.fetchedAt, + retained: false, + messages: [], + }); +}); + +it("orders failed polls by attempt time even when successful data is older", () => { + const success = entry({}); + if (success.report.status !== "available") throw new Error("fixture must be available"); + const failure: UsageReportEntry = { + ...success, + fetchedAt: "2026-01-01T00:06:00.000Z", + report: { status: "error", error: "Rate limited" }, + lastSuccessful: { fetchedAt: success.fetchedAt, report: success.report }, + }; + expect(upsertReport([failure], success)).toEqual([failure]); + expect(settleReports([failure], [success])).toEqual([failure]); + expect(upsertReport([success], failure)).toEqual([failure]); +}); + +it("available and retained usage show the same successful completion time", () => { + const success: UsageReportEntry = { ...entry({}), successfulAt: "2026-01-01T00:00:15.000Z" }; + if (success.report.status !== "available") throw new Error("fixture must be available"); + expect(resolveUsageReport(success).successfulAt).toBe("2026-01-01T00:00:15.000Z"); + const failure: UsageReportEntry = { + ...success, + successfulAt: undefined, + fetchedAt: "2026-01-01T00:06:00.000Z", + report: { status: "error", error: "Rate limited" }, + lastSuccessful: { fetchedAt: success.successfulAt!, report: success.report }, + }; + expect(resolveUsageReport(failure).successfulAt).toBe(resolveUsageReport(success).successfulAt); + expect(upsertReport([failure], success)).toEqual([failure]); +}); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index ed8a39163a..1f1c82cce3 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -1,7 +1,7 @@ import { formatCompactTimeAgoAsProse } from "@/utils/time"; import { usageCopy } from "./copy"; import type { UsageDisplayAs } from "./preferences"; -import type { UsageReportEntry, UsageView, UsageWindow } from "./types"; +import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types"; export function usedPercent(window: UsageWindow): number | null { if (window.usedPct != null) return window.usedPct; @@ -32,8 +32,8 @@ export function usageWindowRowLabel(input: { } /** When a report was fetched, from its compact relative time: "Updated 3m ago". */ -export function formatUsageFreshness(compactTimeAgo: string): string { - return `${usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`; +export function formatUsageFreshness(compactTimeAgo: string, retained = false): string { + return `${retained ? "Last updated" : usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`; } /** A user-requested refresh of one report. The previous report stays on screen throughout. */ @@ -192,3 +192,40 @@ export function resolveUsageModalHostId(choice: UsageHostChoice): string | null const picked = connected.find((host) => host.serverId === choice.pickedServerId); return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null; } + +/** Current outcome owns messages; only a successful poll owns values and freshness. */ +export function resolveUsageReport(entry: UsageReportEntry) { + const available = entry.report.status === "available"; + // COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0. + // Older daemons cannot provide history; a failed attempt is never a successful update. + const snapshot = available ? null : (entry.lastSuccessful ?? null); + const content = entry.report.status === "available" ? entry.report : (snapshot?.report ?? null); + const messages: string[] = []; + if (!available) { + if (entry.loginErrors) + messages.push( + ...entry.loginErrors.map((login) => `${login.harness}: ${usageProblemText(login.report)}`), + ); + // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0. + else messages.push(usageProblemText(entry.report)); + } + return { + windows: content?.windows ?? [], + balances: content?.balances ?? [], + details: content?.details ?? [], + planLabel: content?.planLabel ?? null, + // COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0. + // Old daemons expose only attempt time for available reports, never for failures. + successfulAt: available + ? (entry.successfulAt ?? entry.fetchedAt) + : (snapshot?.fetchedAt ?? null), + retained: snapshot !== null, + messages, + }; +} + +function usageProblemText(report: UsageReport): string { + if (report.status === "error") return report.error; + if (report.status === "unavailable") return usageCopy.problem(report.problem); + return ""; +} diff --git a/packages/app/src/usage/pinned.test.ts b/packages/app/src/usage/pinned.test.ts index 9965251595..8c2c2e58fd 100644 --- a/packages/app/src/usage/pinned.test.ts +++ b/packages/app/src/usage/pinned.test.ts @@ -257,9 +257,12 @@ describe("resolvePinnedUsage", () => { }); }); -it("keeps unavailable and error reports out of the sidebar summary", () => { +it("keeps unavailable and error reports out of the sidebar summary even with retained data", () => { + if (claude.report.status !== "available" || codex.report.status !== "available") + throw new Error("fixtures must be available"); const expired: UsageReportEntry = { ...claude, + lastSuccessful: { fetchedAt: claude.fetchedAt, report: claude.report }, report: { status: "unavailable", problem: { kind: "expired", expiresAt: "2026-10-01T00:00:00.000Z", refreshedBy: "claude" }, @@ -267,6 +270,7 @@ it("keeps unavailable and error reports out of the sidebar summary", () => { }; const failed: UsageReportEntry = { ...codex, + lastSuccessful: { fetchedAt: codex.fetchedAt, report: codex.report }, report: { status: "error", error: "Store deleted" }, }; expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]); diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 5260a1c44b..79ea6b9f2d 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -26,7 +26,9 @@ export function UsageWindowBar({ onTogglePin, pinLabel, pinTestID, + retained = false, }: { + retained?: boolean; window: UsageWindow; displayAs: UsageDisplayAs; /** Whether the row pins the window to the sidebar. When false the row is a plain bar. */ @@ -40,10 +42,13 @@ export function UsageWindowBar({ const shownPct = displayPercent(window, displayAs); const tone = windowTone(window); - const isAtRisk = window.runsOutAt != null && window.shortfallPct != null; + const isAtRisk = + window.runsOutAt != null && + window.shortfallPct != null && + (!retained || Date.parse(window.runsOutAt) > Date.now()); const trailing = isAtRisk ? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim() - : formatResetLabel(window.resetsAt); + : formatResetLabel(window.resetsAt, retained); const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—"; const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]); diff --git a/packages/cli/src/commands/usage/index.test.ts b/packages/cli/src/commands/usage/index.test.ts index 9c2e3e8bbf..d6d5b62ee1 100644 --- a/packages/cli/src/commands/usage/index.test.ts +++ b/packages/cli/src/commands/usage/index.test.ts @@ -277,3 +277,40 @@ describe("usage CLI registration", () => { expect(usage.commands[1]!.helpInformation()).toContain(""); }); }); + +it("shows retained values with current failures and separates successful time from latest attempt", async () => { + const success: UsageReportEntry = { ...reports[0]!, successfulAt: "2026-10-09T12:00:15Z" }; + const current = await runUsageCommand( + options, + { kind: "inspect", id: success.id }, + host([success]).connect, + ); + expect(render(current)).toContain("Latest attempt: 2026-10-09T12:00:00Z"); + expect(render(current)).toContain("Last successful: 2026-10-09T12:00:15Z"); + const currentList = await runUsageCommand(options, { kind: "list" }, host([success]).connect); + expect(render(currentList)).toContain("Last successful: 2026-10-09T12:00:15Z"); + if (success.report.status !== "available") throw new Error("fixture must be available"); + const retained: UsageReportEntry = { + ...success, + fetchedAt: "2026-10-09T12:06:00Z", + report: { status: "error", error: "Rate limited" }, + successfulAt: undefined, + lastSuccessful: { fetchedAt: success.successfulAt!, report: success.report }, + }; + const daemon = host([retained]); + const listed = await runUsageCommand(options, { kind: "list" }, daemon.connect); + expect(render(listed)).toContain("Retained: Five hours: 12.5% used"); + expect(render(listed)).toContain("Last successful: 2026-10-09T12:00:15Z"); + expect(render(listed)).toContain("Rate limited"); + expect(JSON.parse(render(listed, { format: "json" }))).toEqual([retained]); + const inspected = await runUsageCommand( + options, + { kind: "inspect", id: retained.id }, + daemon.connect, + ); + expect(render(inspected)).toContain("Latest attempt: 2026-10-09T12:06:00Z"); + expect(render(inspected)).toContain("Last successful: 2026-10-09T12:00:15Z"); + expect(render(inspected)).toContain("Retained usage:"); + expect(render(inspected)).toContain("Five hours: 12.5% used"); + expect(render(inspected)).toContain("Reason: Rate limited"); +}); diff --git a/packages/cli/src/commands/usage/presentation.ts b/packages/cli/src/commands/usage/presentation.ts index ecc4aba405..2ecadafc59 100644 --- a/packages/cli/src/commands/usage/presentation.ts +++ b/packages/cli/src/commands/usage/presentation.ts @@ -44,8 +44,18 @@ function summary(entry: UsageReportEntry): string[] { const lines = [failureText(report)]; for (const login of entry.loginErrors ?? []) lines.push(`${login.harness}: ${failureText(login.report)}`); + if (entry.lastSuccessful) { + lines.push(`Last successful: ${entry.lastSuccessful.fetchedAt}`); + lines.push( + ...availableSummary(entry.lastSuccessful.report).map((line) => `Retained: ${line}`), + ); + } else lines.push("No successful update"); return lines; } + return [...availableSummary(report), `Last successful: ${successfulTime(entry)}`]; +} + +function availableSummary(report: Extract): string[] { const lines = report.windows.map(windowText); lines.push(...(report.balances ?? []).map(balanceText)); return lines.length ? lines : ["-"]; @@ -68,7 +78,7 @@ const summarySchema: OutputSchema = { { header: "ACCOUNT", field: "account" }, { header: "STATUS", field: "status" }, { header: "USAGE", field: "usage" }, - { header: "FETCHED", field: "fetched" }, + { header: "FETCHED (LATEST ATTEMPT)", field: "fetched" }, ], }; @@ -79,30 +89,15 @@ function inspect(entry: UsageReportEntry): string { `Source ID: ${entry.sourceId}`, `Account: ${value(entry.account.label)}`, `Status: ${entry.report.status}`, - `Fetched: ${value(entry.fetchedAt)}`, + `Latest attempt: ${value(entry.fetchedAt)}`, ]; - const { report } = entry; - if (report.status === "available") { - lines.push(`Plan: ${value(report.planLabel)}`, "Windows:"); - if (!report.windows.length) lines.push(" -"); - for (const window of report.windows) { - lines.push(` ${windowText(window)} [${window.id}]`, ` Resets: ${value(window.resetsAt)}`); - if (window.runsOutAt != null) lines.push(` Runs out: ${window.runsOutAt}`); - if (window.shortfallPct != null) lines.push(` Shortfall: ${percent(window.shortfallPct)}`); - } - lines.push("Balances:"); - if (!report.balances?.length) lines.push(" -"); - for (const balance of report.balances ?? []) { - lines.push( - ` ${balanceText(balance)} [${balance.id}]`, - ` Resets: ${value(balance.resetsAt)}`, - ); - } - lines.push("Details:"); - if (!report.details?.length) lines.push(" -"); - for (const detail of report.details ?? []) lines.push(` ${detail.label}: ${detail.value}`); - } else { - lines.push(`Reason: ${failureText(report)}`); + const report = entry.report.status === "available" ? entry.report : entry.lastSuccessful?.report; + const successfulAt = successfulTime(entry); + lines.push(successfulAt ? `Last successful: ${successfulAt}` : "No successful update"); + if (entry.report.status !== "available") lines.push(`Reason: ${failureText(entry.report)}`); + if (report) { + if (entry.report.status !== "available") lines.push("Retained usage:"); + lines.push(...availableDetails(report)); } if (entry.loginErrors?.length) { lines.push("Login failures:"); @@ -135,3 +130,33 @@ export const usageSchema: OutputSchema = { return renderTable({ type: "list", data: rows, schema: summarySchema }, options); }, }; + +function availableDetails(report: Extract): string[] { + const lines: string[] = []; + lines.push(`Plan: ${value(report.planLabel)}`, "Windows:"); + if (!report.windows.length) lines.push(" -"); + for (const window of report.windows) { + lines.push(` ${windowText(window)} [${window.id}]`, ` Resets: ${value(window.resetsAt)}`); + if (window.runsOutAt != null) lines.push(` Runs out: ${window.runsOutAt}`); + if (window.shortfallPct != null) lines.push(` Shortfall: ${percent(window.shortfallPct)}`); + } + lines.push("Balances:"); + if (!report.balances?.length) lines.push(" -"); + for (const balance of report.balances ?? []) { + lines.push( + ` ${balanceText(balance)} [${balance.id}]`, + ` Resets: ${value(balance.resetsAt)}`, + ); + } + lines.push("Details:"); + if (!report.details?.length) lines.push(" -"); + for (const detail of report.details ?? []) lines.push(` ${detail.label}: ${detail.value}`); + return lines; +} + +function successfulTime(entry: UsageReportEntry): string | undefined { + // COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0. + // Only available reports from older daemons can use their attempt timestamp as freshness. + if (entry.report.status === "available") return entry.successfulAt ?? entry.fetchedAt; + return entry.lastSuccessful?.fetchedAt; +} diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index c8fe60ff67..a7a2182d94 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -6306,14 +6306,15 @@ export const UsageProblemSchema = z.discriminatedUnion("kind", [ }), z.object({ kind: z.literal("no_quota"), detail: z.string() }), ]); +const AvailableUsageReportSchema = z.object({ + status: z.literal("available"), + planLabel: z.string().optional(), + windows: z.array(ProviderUsageWindowSchema), + balances: z.array(ProviderUsageBalanceSchema).optional(), + details: z.array(ProviderUsageDetailSchema).optional(), +}); export const UsageReportSchema = z.discriminatedUnion("status", [ - z.object({ - status: z.literal("available"), - planLabel: z.string().optional(), - windows: z.array(ProviderUsageWindowSchema), - balances: z.array(ProviderUsageBalanceSchema).optional(), - details: z.array(ProviderUsageDetailSchema).optional(), - }), + AvailableUsageReportSchema, z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }), z.object({ status: z.literal("error"), error: z.string() }), ]); @@ -6325,6 +6326,11 @@ export const UsageReportEntrySchema = z.object({ sourceLabel: z.string(), icon: z.string().optional(), report: UsageReportSchema, + // Successful completion time; fetchedAt remains the attempt timestamp for ordering. + successfulAt: z.string().optional(), + lastSuccessful: z + .object({ fetchedAt: z.string(), report: AvailableUsageReportSchema }) + .optional(), loginErrors: z .array( z.object({ diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index 2298d680a4..6903aa2cd1 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { z } from "zod"; import { + UsageReportEntrySchema, AgentSnapshotPayloadSchema, AgentTimelineItemPayloadSchema, ServerInfoStatusPayloadSchema, @@ -436,3 +437,40 @@ test("usage login errors are additive and older reports still parse", () => { expect(SessionOutboundMessageSchema.parse(newMessage)).toEqual(newMessage); expect(legacy.parse(newMessage)).toEqual(oldMessage); }); + +test("usage entries accept older failures and additive successful snapshots", () => { + const oldEntry = { + id: "source:account", + sourceId: "source", + sourceLabel: "Source", + account: {}, + fetchedAt: "2026-10-17T10:06:00Z", + report: { status: "error", error: "Rate limited" }, + }; + expect(UsageReportEntrySchema.parse(oldEntry)).toEqual(oldEntry); + const retained = { + ...oldEntry, + lastSuccessful: { + fetchedAt: "2026-10-17T10:00:00Z", + report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] }, + }, + }; + expect(UsageReportEntrySchema.parse(retained)).toEqual(retained); + const available = { + ...oldEntry, + report: retained.lastSuccessful.report, + successfulAt: "2026-10-17T10:00:15Z", + }; + expect(UsageReportEntrySchema.parse(available)).toEqual(available); + expect( + UsageReportEntrySchema.omit({ lastSuccessful: true, successfulAt: true }).parse(available), + ).toEqual({ ...oldEntry, report: available.report }); + // An older client's entry shape strips the additive snapshot and still sees the failure. + expect(UsageReportEntrySchema.omit({ lastSuccessful: true }).parse(retained)).toEqual(oldEntry); + expect( + UsageReportEntrySchema.safeParse({ + ...oldEntry, + lastSuccessful: { fetchedAt: oldEntry.fetchedAt, report: oldEntry.report }, + }).success, + ).toBe(false); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts index eeea242788..f0173691c8 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -556,3 +556,208 @@ test("a hung login records its own timeout and still tries the next login", asyn { harness: "OpenCode", report: { status: "error", error: "Login rejected" } }, ]); }); + +test("retains the last successful poll across failures, cache expiry, and recovery", async () => { + let now = 0; + let report: unknown = { + status: "available", + windows: [{ id: "week", label: "Weekly", usedPct: 42 }], + }; + const registry = new UsageSourceRegistry(() => now); + registry.register( + source({ + id: "quota", + discover: async () => [{ key: "account", input: {} }], + fetch: async () => report, + }), + ); + registry.register(source({ id: "other", discover: async () => [{ key: "account", input: {} }] })); + const [success] = await registry.listReports(); + now = 360_000; + // An unrelated write evicts the expired fetch result, but must not discard its snapshot. + await registry.listReports({ reportIds: ["other:account"], forceRefresh: true }); + report = { status: "error", error: "Rate limited" }; + const [failure] = await registry.listReports({ reportIds: ["quota:account"] }); + expect(failure).toMatchObject({ + fetchedAt: new Date(now).toISOString(), + report, + lastSuccessful: { fetchedAt: success!.fetchedAt, report: success!.report }, + }); + now += 360_000; + const [repeated] = await registry.listReports({ reportIds: ["quota:account"] }); + expect(repeated?.lastSuccessful).toEqual(failure?.lastSuccessful); + report = { status: "unavailable", problem: { kind: "no_quota", detail: "No quota" } }; + const [unavailable] = await registry.listReports({ + reportIds: ["quota:account"], + forceRefresh: true, + }); + expect(unavailable?.lastSuccessful).toEqual(failure?.lastSuccessful); + now += 1000; + report = { status: "available", windows: [{ id: "week", label: "Weekly", usedPct: 43 }] }; + const [recovered] = await registry.listReports({ + reportIds: ["quota:account"], + forceRefresh: true, + }); + expect(recovered?.report).toEqual(report); + expect(recovered?.lastSuccessful).toBeUndefined(); + expect(recovered?.fetchedAt).toBe(new Date(now).toISOString()); +}); + +test("successful history is isolated by login set and pruned when identities retire", async () => { + let login = "old"; + let present = true; + let available = true; + const registry = new UsageSourceRegistry(); + const fixture = source({ + id: "source", + discover: async () => (present ? [{ key: "same", input: login }] : []), + fetch: async () => + available ? { status: "available", windows: [] } : { status: "error", error: "failed" }, + }); + registry.register(fixture); + await registry.listReports(); + available = false; + login = "new"; + expect((await registry.listReports({ forceRefresh: true }))[0]?.lastSuccessful).toBeUndefined(); + login = "old"; + expect((await registry.listReports({ forceRefresh: true }))[0]?.lastSuccessful).toBeUndefined(); + available = true; + await registry.listReports({ forceRefresh: true }); + present = false; + expect(await registry.listReports()).toEqual([]); + present = true; + available = false; + expect((await registry.listReports())[0]?.lastSuccessful).toBeUndefined(); + available = true; + await registry.listReports({ forceRefresh: true }); + registry.unregister("source"); + registry.register(fixture); + available = false; + expect((await registry.listReports())[0]?.lastSuccessful).toBeUndefined(); +}); + +test("host fallback history never supplies an agent's failed login, and removed agents lose history", async () => { + const sessions = new Map([["agent", { provider: "codex", env: {}, sessionKey: "one" }]]); + let available = true; + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => sessions.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + registry.register({ + id: "source", + label: "Source", + discover: async (scope) => + (scope.kind === "global" ? ["host", "agent"] : ["agent"]).map((input) => ({ + key: "same", + input, + })), + fetch: async () => + available ? { status: "available", windows: [] } : { status: "error", error: "failed" }, + }); + await registry.listReports(); + available = false; + expect((await registry.listReports({ agentId: "agent" }))[0]?.lastSuccessful).toBeUndefined(); + available = true; + const [success] = await registry.listReports({ agentId: "agent", forceRefresh: true }); + available = false; + expect( + (await registry.listReports({ agentId: "agent", forceRefresh: true }))[0]?.lastSuccessful + ?.report, + ).toEqual(success?.report); + sessions.delete("agent"); + await registry.listReports(); + sessions.set("agent", { provider: "codex", env: {}, sessionKey: "two" }); + expect((await registry.listReports({ agentId: "agent" }))[0]?.lastSuccessful).toBeUndefined(); +}); + +test("a timed out poll retains successful values and late completion cannot replace the failure", async () => { + let now = 1000; + let finish!: (value: unknown) => void; + let slow = false; + const registry = new UsageSourceRegistry(() => now, 300_000, undefined, undefined, 10); + registry.register( + source({ + id: "source", + discover: async () => [{ key: "same", input: {} }], + fetch: async () => + slow + ? new Promise((resolve) => { + finish = resolve; + }) + : { status: "available", windows: [] }, + }), + ); + const [success] = await registry.listReports(); + now = 2000; + slow = true; + const [failure] = await registry.listReports({ forceRefresh: true }); + expect(failure?.report).toEqual({ status: "error", error: "Usage fetch timed out" }); + expect(failure?.lastSuccessful).toEqual({ + fetchedAt: success?.fetchedAt, + report: success?.report, + }); + finish({ status: "available", windows: [{ id: "late", label: "Late" }] }); + expect((await registry.listReports())[0]).toEqual(failure); +}); + +test("attempt ordering keeps its timestamp while retained freshness records successful completion", async () => { + let now = 1000; + let available = true; + const registry = new UsageSourceRegistry(() => now); + registry.register( + source({ + id: "source", + discover: async () => [{ key: "same", input: {} }], + fetch: async () => { + if (available) now = 2000; + return available + ? { status: "available", windows: [] } + : { status: "error", error: "failed" }; + }, + }), + ); + const [success] = await registry.listReports(); + expect(success?.fetchedAt).toBe(new Date(1000).toISOString()); + expect(success?.successfulAt).toBe(new Date(2000).toISOString()); + available = false; + now = 3000; + const [failure] = await registry.listReports({ forceRefresh: true }); + expect(failure?.fetchedAt).toBe(new Date(3000).toISOString()); + expect(failure?.lastSuccessful?.fetchedAt).toBe(success?.successfulAt); +}); + +test("failed discovery preserves history until discovery confirms account removal", async () => { + let discoveryFails = false; + let present = true; + let available = true; + const registry = new UsageSourceRegistry(() => 1000, 300_000, { warn: () => {} }); + registry.register( + source({ + id: "source", + discover: async () => { + if (discoveryFails) throw new Error("Login store temporarily unavailable"); + return present ? [{ key: "account", input: {} }] : []; + }, + fetch: async () => + available + ? { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] } + : { status: "error", error: "Rate limited" }, + }), + ); + const [success] = await registry.listReports(); + available = false; + discoveryFails = true; + const [duringDiscoveryFailure] = await registry.listReports({ forceRefresh: true }); + expect(duringDiscoveryFailure?.lastSuccessful).toEqual({ + fetchedAt: success?.successfulAt, + report: success?.report, + }); + discoveryFails = false; + const [afterDiscoveryRecovery] = await registry.listReports({ forceRefresh: true }); + expect(afterDiscoveryRecovery?.lastSuccessful).toEqual(duringDiscoveryFailure?.lastSuccessful); + expect(afterDiscoveryRecovery?.report).toEqual({ status: "error", error: "Rate limited" }); + present = false; + expect(await registry.listReports()).toEqual([]); + present = true; + expect((await registry.listReports())[0]?.lastSuccessful).toBeUndefined(); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts index c0c9d781e5..e9e2e9c8af 100644 --- a/packages/server/src/server/plugins/usage-sources/index.ts +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -56,6 +56,7 @@ export class UsageSourceRegistry { private defaults = new Map(); private readonly byAgent = new Map(); private readonly cache = new Map(); + private readonly successful = new Map>(); private readonly pending = new Map>(); constructor( @@ -81,6 +82,8 @@ export class UsageSourceRegistry { } for (const key of this.known.keys()) if (key.startsWith(`${id}:`)) this.known.delete(key); for (const key of this.cache.keys()) if (key.startsWith(`${id}:`)) this.cache.delete(key); + for (const key of this.successful.keys()) + if (key.startsWith(`${id}:`)) this.successful.delete(key); for (const key of this.pending.keys()) if (key.startsWith(`${id}:`)) this.pending.delete(key); } @@ -96,7 +99,7 @@ export class UsageSourceRegistry { } else if (options.reportIds !== undefined) { ids = options.reportIds; } else { - this.defaults = await this.discover({ kind: "global" }); + this.defaults = await this.discover({ kind: "global" }, this.defaults); this.mergeKnown(); ids = [...this.known.keys()]; } @@ -124,7 +127,11 @@ export class UsageSourceRegistry { private async discoverAgent(agentId: string): Promise { if (!this.agents.hasAgent(agentId)) throw new Error(`Unknown agent: ${agentId}`); const session = this.agents.usageSession(agentId); - if (!session) return []; + if (!session) { + this.byAgent.delete(agentId); + this.mergeKnown(); + return []; + } const previous = this.byAgent.get(agentId); const sameScope = previous?.sessionKey === session.sessionKey && @@ -168,9 +175,26 @@ export class UsageSourceRegistry { } } } + // Fetch freshness expires independently of successful snapshots. Retain only live identities. + const active = this.activeCacheKeys(); + for (const key of this.successful.keys()) if (!active.has(key)) this.successful.delete(key); + for (const key of this.cache.keys()) if (!active.has(key)) this.cache.delete(key); } - private async discover(scope: UsageScope): Promise> { + private activeCacheKeys(): Set { + return new Set( + [ + this.known, + this.defaults, + ...[...this.byAgent.values()].map((agent) => agent.reports), + ].flatMap((reports) => [...reports].map(([id, report]) => cacheIdentity(id, report))), + ); + } + + private async discover( + scope: UsageScope, + previous: ReadonlyMap = new Map(), + ): Promise> { const discovered = await Promise.all( [...this.sources.values()].map(async (source) => { const reports = new Map(); @@ -197,6 +221,11 @@ export class UsageSourceRegistry { } } catch (error) { this.logger.warn({ sourceId: source.id, err: error }, "Usage source discovery failed"); + // A failed store read does not confirm removal. Session discovery has no previous + // mapping because a resumed agent may use a different launch environment. + for (const [id, report] of previous) { + if (report.source === source) reports.set(id, report); + } } return reports; }), @@ -235,25 +264,42 @@ export class UsageSourceRegistry { private fetchId(id: string, known: KnownReport, forceRefresh = false): Promise { // Account identity groups cards; the ordered login set identifies a fetch result. - const cacheKey = `${id}:${createHash("sha256") - .update(JSON.stringify([known.label, known.logins.map(loginKey)])) - .digest("hex")}`; + const cacheKey = cacheIdentity(id, known); const cached = this.cache.get(cacheKey); if (!forceRefresh && cached && this.now() - cached.at < this.ttlMs) return Promise.resolve(cached.entry); const pending = this.pending.get(cacheKey); if (pending) return pending; const request = (async () => { + const fetchedAt = new Date(this.now()).toISOString(); + const outcome = await this.fetchWithFallback(known); + const completedAt = new Date(this.now()).toISOString(); const entry: UsageReportEntry = { id, sourceId: known.source.id, sourceLabel: known.source.label, icon: known.source.icon, account: { label: known.label }, - fetchedAt: new Date(this.now()).toISOString(), - ...(await this.fetchWithFallback(known)), + fetchedAt, + ...outcome, }; - if (this.sources.get(known.source.id) === known.source) this.writeCache(cacheKey, entry); + if (entry.report.status === "available") { + entry.successfulAt = completedAt; + } else { + entry.lastSuccessful = this.successful.get(cacheKey); + } + if ( + this.sources.get(known.source.id) === known.source && + this.activeCacheKeys().has(cacheKey) + ) { + if (entry.report.status === "available") { + this.successful.set(cacheKey, { + fetchedAt: completedAt, + report: entry.report, + }); + } + this.writeCache(cacheKey, entry); + } return entry; })(); this.pending.set(cacheKey, request); @@ -346,3 +392,9 @@ function loginKey(login: Login): string { : value, ); } + +function cacheIdentity(id: string, known: KnownReport): string { + return `${id}:${createHash("sha256") + .update(JSON.stringify([known.label, known.logins.map(loginKey)])) + .digest("hex")}`; +}