From 233ce19e7b20439483efaba95896c54a72d1277d Mon Sep 17 00:00:00 2001 From: DIodide Date: Sun, 21 Jun 2026 01:44:15 -0400 Subject: [PATCH 1/2] fix(usage): surface the Claude account rate limit (session/week) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user on their own Claude subscription hit the session limit, Harness showed nothing in the Usage dialog — they had no clue. Two bugs: 1. Wrong parse shape. The frontend parser guessed a `buckets.five_hour.…` shape; the real `_meta._claude/rateLimit` is the SDK's flat `rate_limit_info` ({rateLimitType, status, resetsAt, utilization?}). Rewrote the parser to that shape (confirmed against claude-agent-acp@0.44.0 source) and the "Claude account limits" section to show the window label, %, a reset countdown, and a red "limit reached" banner on status=rejected. The gauge badge turns red when the account limit is hit. Handles resetsAt in seconds or ms. 2. The snapshot never persisted at the moment it mattered. `_claude/rateLimit` rides a cost-less `usage_update` (rate_limit_event), and a hard-limit turn fails silently with no result message — so the snapshot only ever rode a cost-bearing usage row and was dropped exactly when the limit was hit. Now persist it directly onto the credential (`agentCredentials.recordRateLimit`, new `lastRateLimit`/`lastRateLimitAt`) the instant it arrives, decoupled from the usage ledger; getMyAgentUsage prefers that account-level snapshot. Tests: +2 Convex (snapshot surfaces with zero usage; owner-gated), +5 frontend (rate_limit_info shapes: rejected, utilization, type labels, seconds/ms reset, null cases). FastAPI 323, Convex 195, web 234; biome clean, tsc 21/21. --- apps/web/src/components/usage-display.test.ts | 69 ++++++- apps/web/src/components/usage-display.tsx | 192 ++++++++++-------- .../convex-backend/convex/agentCredentials.ts | 22 ++ .../convex-backend/convex/agentUsage.test.ts | 34 ++++ packages/convex-backend/convex/agentUsage.ts | 7 +- packages/convex-backend/convex/schema.ts | 7 + .../app/services/agents/session_manager.py | 21 +- packages/fastapi/app/services/usage.py | 33 +++ 8 files changed, 298 insertions(+), 87 deletions(-) diff --git a/apps/web/src/components/usage-display.test.ts b/apps/web/src/components/usage-display.test.ts index a15ac2e..37e80cc 100644 --- a/apps/web/src/components/usage-display.test.ts +++ b/apps/web/src/components/usage-display.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { formatResetTime } from "./usage-display"; +import { accountUsageFromRateLimit, formatResetTime } from "./usage-display"; describe("formatResetTime", () => { beforeEach(() => { @@ -35,3 +35,70 @@ describe("formatResetTime", () => { expect(formatResetTime("2026-04-21T13:59:59Z")).toBe("1h 59m"); }); }); + +describe("accountUsageFromRateLimit (SDK rate_limit_info shape)", () => { + it("parses a hard limit (status rejected) with a seconds reset timestamp", () => { + const a = accountUsageFromRateLimit({ + rateLimitType: "five_hour", + status: "rejected", + resetsAt: 1771606800, // seconds + isUsingOverage: false, + }); + expect(a).toEqual({ + label: "Current session", + status: "rejected", + utilization: undefined, + resetsAtMs: 1771606800000, // ×1000 + }); + }); + + it("parses utilization + a millisecond reset timestamp", () => { + const a = accountUsageFromRateLimit({ + rateLimitType: "seven_day", + status: "allowed_warning", + utilization: 73.7, + resetsAt: 1771606800000, // already ms + }); + expect(a?.label).toBe("Current week"); + expect(a?.status).toBe("warning"); + expect(a?.utilization).toBeCloseTo(73.7); + expect(a?.resetsAtMs).toBe(1771606800000); + }); + + it("maps every known rateLimitType and clamps utilization", () => { + expect( + accountUsageFromRateLimit({ + rateLimitType: "seven_day_sonnet", + status: "allowed", + utilization: 150, + })?.label, + ).toBe("Current week (Sonnet)"); + expect( + accountUsageFromRateLimit({ + rateLimitType: "seven_day_sonnet", + status: "allowed", + utilization: 150, + })?.utilization, + ).toBe(100); // clamped + }); + + it("returns null for the normal allowed state with no number or reset", () => { + expect( + accountUsageFromRateLimit({ + rateLimitType: "five_hour", + status: "allowed", + }), + ).toBeNull(); + expect(accountUsageFromRateLimit(null)).toBeNull(); + expect(accountUsageFromRateLimit("nope")).toBeNull(); + }); + + it("falls back to a generic label for an unknown type", () => { + const a = accountUsageFromRateLimit({ + status: "rejected", + resetsAt: 1771606800, + }); + expect(a?.label).toBe("Claude account"); + expect(a?.status).toBe("rejected"); + }); +}); diff --git a/apps/web/src/components/usage-display.tsx b/apps/web/src/components/usage-display.tsx index 874faa4..7e80238 100644 --- a/apps/web/src/components/usage-display.tsx +++ b/apps/web/src/components/usage-display.tsx @@ -12,87 +12,97 @@ import { import { cn } from "@/lib/utils"; /** - * Best-effort extraction of the user's Claude-account quota utilization from the - * opaque upstream `_meta._claude/rateLimit` snapshot. The shape is upstream- - * defined (claude-agent-acp) and may change, so this scans the most likely field - * names and degrades to {} on no match — the UI then simply omits the section. + * The user's Claude-account rate-limit snapshot, parsed from the upstream + * `_meta._claude/rateLimit` — which is the SDK's `rate_limit_info`: + * { rateLimitType, status, resetsAt, utilization?, isUsingOverage, … } + * It describes the single most-restrictive window. `utilization` is omitted in + * the normal "allowed" low-usage state, so we may have only a status + reset. + * Defensive: returns null when the shape is unrecognizable. */ interface AccountUsage { - session?: number; // 5-hour window % - week?: number; // 7-day window % - weekSonnet?: number; // 7-day Sonnet-only window % + label: string; // human window name (e.g. "Current session") + status: "allowed" | "warning" | "rejected"; + utilization?: number; // 0–100, when the snapshot includes it + resetsAtMs?: number; // window reset, normalized to ms } -/** Clamp a best-effort percentage to [0, 100] so a surprising upstream value - * can't render an absurd label (e.g. "1700000000%"). */ -function clampPct(p: number): number { - return Math.max(0, Math.min(100, p)); -} +const RATE_LIMIT_LABELS: Record = { + five_hour: "Current session", + seven_day: "Current week", + seven_day_opus: "Current week (Opus)", + seven_day_sonnet: "Current week (Sonnet)", + overage: "Overage", +}; -function bucketPct(bucket: unknown): number | undefined { - if (!bucket || typeof bucket !== "object") return undefined; - const b = bucket as Record; - const u = - b.utilization_pct ?? - b.utilizationPct ?? - b.utilization ?? - b.used_pct ?? - b.usedPct ?? - b.percent ?? - b.pct; - // Accept 0–1 (fraction) or 0–100 (already a percent); clamp either way. - if (typeof u === "number") return clampPct(u <= 1 ? u * 100 : u); - if ( - typeof b.used === "number" && - typeof b.limit === "number" && - b.limit > 0 - ) { - return clampPct((b.used / b.limit) * 100); +/** Normalize a possibly-seconds or possibly-ms reset timestamp to ms. */ +function toResetMs(raw: unknown): number | undefined { + if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) { + return undefined; } - return undefined; + // < ~year 2286 in seconds ⇒ treat as seconds; otherwise already ms. + return raw < 1e12 ? raw * 1000 : raw; } -function accountUsageFromRateLimit(rateLimit: unknown): AccountUsage { - if (!rateLimit || typeof rateLimit !== "object") return {}; +export function accountUsageFromRateLimit( + rateLimit: unknown, +): AccountUsage | null { + if (!rateLimit || typeof rateLimit !== "object") return null; const rl = rateLimit as Record; - const src = ( - rl.buckets && typeof rl.buckets === "object" ? rl.buckets : rl - ) as Record; - const pick = (...keys: string[]) => { - for (const k of keys) { - const p = bucketPct(src[k]); - if (p !== undefined) return p; - } - return undefined; - }; + const rawStatus = typeof rl.status === "string" ? rl.status : "allowed"; + const status: AccountUsage["status"] = + rawStatus === "rejected" + ? "rejected" + : rawStatus.includes("warning") + ? "warning" + : "allowed"; + const type = typeof rl.rateLimitType === "string" ? rl.rateLimitType : ""; + const u = rl.utilization; + const utilization = + typeof u === "number" && Number.isFinite(u) + ? Math.max(0, Math.min(100, u)) + : undefined; + const resetsAtMs = toResetMs(rl.resetsAt); + // Nothing actionable to show: normal, no number, no reset. + if ( + status === "allowed" && + utilization === undefined && + resetsAtMs === undefined + ) { + return null; + } return { - session: pick("five_hour", "fiveHour", "session", "5h", "primary"), - week: pick("seven_day", "sevenDay", "week", "7d"), - weekSonnet: pick("seven_day_sonnet", "sevenDaySonnet"), + label: RATE_LIMIT_LABELS[type] ?? "Claude account", + status, + utilization, + resetsAtMs, }; } -/** - * The most-recent non-empty account utilization across the user's agent rows. - * getMyAgentUsage returns one row per credential (cost-sorted, not by recency), - * so we scan in lastTurnAt order to surface the credential whose turn is - * globally freshest rather than the highest-spend one. - */ -function latestAccountUsage(rows: AgentUsageRow[] | undefined): AccountUsage { +/** The freshest credential's account-limit snapshot (rows already carry the + * account-level rateLimit; pick the most recently active credential). */ +function latestAccountUsage( + rows: AgentUsageRow[] | undefined, +): AccountUsage | null { const byRecency = [...(rows ?? [])].sort( (a, b) => (b.lastTurnAt ?? 0) - (a.lastTurnAt ?? 0), ); for (const r of byRecency) { const a = accountUsageFromRateLimit(r.rateLimit); - if ( - a.session !== undefined || - a.week !== undefined || - a.weekSonnet !== undefined - ) { - return a; - } + if (a) return a; } - return {}; + return null; +} + +/** "in 5h 12m" until a reset timestamp (ms), or undefined when past/absent. */ +function resetsInLabel(resetsAtMs: number | undefined): string | undefined { + if (resetsAtMs === undefined) return undefined; + const diffMs = resetsAtMs - Date.now(); + if (diffMs <= 0) return undefined; + const hours = Math.floor(diffMs / 3_600_000); + const minutes = Math.floor((diffMs % 3_600_000) / 60_000); + if (hours >= 24) return `${Math.floor(hours / 24)}d ${hours % 24}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; } function ProgressBar({ @@ -451,17 +461,10 @@ export function UsageDisplay() { function AccountLimitsSection() { const { data } = useQuery(convexQuery(api.agentUsage.getMyAgentUsage, {})); const acct = latestAccountUsage(data as AgentUsageRow[] | undefined); - const bars: Array<{ label: string; pct: number }> = []; - if (acct.session !== undefined) { - bars.push({ label: "Current session (5h)", pct: acct.session }); - } - if (acct.week !== undefined) { - bars.push({ label: "Current week", pct: acct.week }); - } - if (acct.weekSonnet !== undefined) { - bars.push({ label: "Current week (Sonnet)", pct: acct.weekSonnet }); - } - if (bars.length === 0) return null; + if (!acct) return null; + + const resetsIn = resetsInLabel(acct.resetsAtMs); + const resetSub = resetsIn ? `Resets in ${resetsIn}` : undefined; return (
@@ -473,9 +476,25 @@ function AccountLimitsSection() { your subscription
- {bars.map((b) => ( - - ))} + {acct.status === "rejected" ? ( +
+ {acct.label} limit reached + {resetsIn ? ` · resets in ${resetsIn}` : ""}. Agent turns are paused + until it resets. +
+ ) : acct.utilization !== undefined ? ( + + ) : ( +

+ {acct.label} + {acct.status === "warning" ? " · approaching limit" : ""} + {resetsIn ? ` · resets in ${resetsIn}` : ""} +

+ )} ); } @@ -495,16 +514,25 @@ export function UsageBadge({ onClick }: { onClick?: () => void }) { const budgetPct = budget ? Math.max(budget.dailyPctUsed, budget.weeklyPctUsed) : 0; - // Prefer the real account quota (what actually limits an agent turn); fall - // back to the Harness budget. - const level = acct.session ?? acct.week ?? budgetPct; - const color = - level >= 90 + // A hit account limit is the loudest signal; otherwise prefer the real + // account quota % (what actually limits an agent turn), else the Harness + // budget. + const limited = acct?.status === "rejected"; + const level = acct?.utilization ?? budgetPct; + const color = limited + ? "text-red-400" + : level >= 90 ? "text-red-400" : level >= 70 ? "text-yellow-400" : "text-muted-foreground"; - const tip = level > 0 ? `Usage — ${Math.round(level)}% used` : "Usage"; + const tip = limited + ? `${acct?.label ?? "Account"} limit reached` + : acct?.utilization !== undefined + ? `${acct.label} — ${Math.round(acct.utilization)}% used` + : level > 0 + ? `Usage — ${Math.round(level)}% used` + : "Usage"; return ( diff --git a/packages/convex-backend/convex/agentCredentials.ts b/packages/convex-backend/convex/agentCredentials.ts index cb371ff..1c99167 100644 --- a/packages/convex-backend/convex/agentCredentials.ts +++ b/packages/convex-backend/convex/agentCredentials.ts @@ -200,3 +200,25 @@ export const touch = internalMutation({ if (row) await ctx.db.patch(args.credentialId, { lastUsedAt: Date.now() }); }, }); + +/** + * Store the latest Anthropic rate-limit snapshot for a credential (FastAPI via + * deploy key). Decoupled from the usage ledger so it lands even on a silent + * hard-limit turn that records no cost/tokens — that's exactly when the user + * most needs to see it. Owner re-checked. + */ +export const recordRateLimit = internalMutation({ + args: { + credentialId: v.id("agentCredentials"), + userId: v.string(), + rateLimit: v.any(), + }, + handler: async (ctx, args) => { + const row = await ctx.db.get(args.credentialId); + if (!row || row.userId !== args.userId) return; + await ctx.db.patch(args.credentialId, { + lastRateLimit: args.rateLimit, + lastRateLimitAt: Date.now(), + }); + }, +}); diff --git a/packages/convex-backend/convex/agentUsage.test.ts b/packages/convex-backend/convex/agentUsage.test.ts index fb386b3..6c2334c 100644 --- a/packages/convex-backend/convex/agentUsage.test.ts +++ b/packages/convex-backend/convex/agentUsage.test.ts @@ -321,6 +321,40 @@ describe("agentUsage.getMyAgentUsage", () => { expect(rows[0].rateLimit).toBeNull(); }); + it("surfaces the credential's recordRateLimit snapshot even with no usage rows", async () => { + const { raw, asUser } = makeT(); + const credId = await seedCredential(raw, "u-a", "work"); + // A hard-limit turn records no usage at all — only the rate-limit snapshot. + await raw.mutation(internal.agentCredentials.recordRateLimit, { + credentialId: credId, + userId: "u-a", + rateLimit: { + rateLimitType: "five_hour", + status: "rejected", + resetsAt: 1771606800, + }, + }); + const rows = await asUser("u-a").query(api.agentUsage.getMyAgentUsage, {}); + expect(rows[0].turns).toBe(0); // no usage rows + expect(rows[0].rateLimit).toMatchObject({ + rateLimitType: "five_hour", + status: "rejected", + }); + expect(rows[0].lastTurnAt).toBeGreaterThan(0); // reflects the snapshot time + }); + + it("recordRateLimit only patches the owner's credential", async () => { + const { raw } = makeT(); + const credId = await seedCredential(raw, "u-a", "work"); + await raw.mutation(internal.agentCredentials.recordRateLimit, { + credentialId: credId, + userId: "intruder", + rateLimit: { status: "rejected" }, + }); + const row = await raw.run(async (ctx) => ctx.db.get(credId)); + expect(row?.lastRateLimit).toBeUndefined(); + }); + it("cascade-deletes usage when the owning credential is removed", async () => { const { raw, asUser } = makeT(); const credId = await seedCredential(raw, "u-a", "work"); diff --git a/packages/convex-backend/convex/agentUsage.ts b/packages/convex-backend/convex/agentUsage.ts index 8f4a2f3..9945726 100644 --- a/packages/convex-backend/convex/agentUsage.ts +++ b/packages/convex-backend/convex/agentUsage.ts @@ -214,9 +214,12 @@ export const getMyAgentUsage = query({ todayTokens: acc.todayTokens, weekCostUsd: acc.weekCostUsd, weekTokens: acc.weekTokens, - lastTurnAt: acc.lastTurnAt, + lastTurnAt: Math.max(acc.lastTurnAt, cred.lastRateLimitAt ?? 0), lastModel: acc.lastModel, - rateLimit: acc.rateLimit, + // Prefer the account-level snapshot stored on the credential (lands + // even on a silent hard-limit turn); fall back to the per-turn + // ledger's last seen value for back-compat. + rateLimit: cred.lastRateLimit ?? acc.rateLimit, perModel: [...acc.perModel.entries()] .map(([model, m]) => ({ model, tokens: m.tokens, costUsd: m.costUsd })) .sort((a, b) => b.costUsd - a.costUsd), diff --git a/packages/convex-backend/convex/schema.ts b/packages/convex-backend/convex/schema.ts index dc2e531..ed3699c 100644 --- a/packages/convex-backend/convex/schema.ts +++ b/packages/convex-backend/convex/schema.ts @@ -283,6 +283,13 @@ export default defineSchema({ label: v.optional(v.string()), createdAt: v.number(), lastUsedAt: v.optional(v.number()), + // Latest Anthropic per-account rate-limit snapshot (the SDK's + // rate_limit_info: {rateLimitType, status, resetsAt, utilization?, …}). + // Account-level CURRENT state, not per-turn — updated whenever a + // rate_limit_event arrives (incl. a silent hard-limit turn that records no + // usage). Shape is upstream-defined so kept opaque. + lastRateLimit: v.optional(v.any()), + lastRateLimitAt: v.optional(v.number()), }) .index("by_user", ["userId"]) .index("by_user_agent", ["userId", "agent"]), diff --git a/packages/fastapi/app/services/agents/session_manager.py b/packages/fastapi/app/services/agents/session_manager.py index 5c6b343..565a007 100644 --- a/packages/fastapi/app/services/agents/session_manager.py +++ b/packages/fastapi/app/services/agents/session_manager.py @@ -2007,12 +2007,29 @@ async def _process_session_update(self, session: AgentSession, params: dict) -> event = normalize_session_update(update) if event is not None: # Capture the latest Anthropic rate-limit snapshot off ANY usage - # update (even one without cost), so the authoritative result-message - # row can carry the freshest quota state. + # update (even one without cost). A rate_limit_event arrives as a + # cost-less usage_update, and a hard-limit turn records no usage at + # all, so persist the snapshot directly onto the credential here + # (not just via the usage row) — that's when the user most needs to + # see the reset time. if event["event"] == "agent_usage": rl = (update.get("_meta") or {}).get("_claude/rateLimit") if rl is not None: session.last_rate_limit = rl + cred_id = session.harness.agent_credential_id + if cred_id: + from app.services.usage import record_agent_rate_limit + + rl_task = asyncio.create_task( + record_agent_rate_limit( + self._http_client(), + user_id=session.user_id, + agent_credential_id=cred_id, + rate_limit=rl, + ) + ) + self._usage_tasks.add(rl_task) + rl_task.add_done_callback(self._usage_tasks.discard) # Persist per-credential agent usage on the terminal usage_update # (the only one carrying cost). Thin: no cache tokens, cache-excluded # cost — for claude-code the SDK result message upgrades this row in diff --git a/packages/fastapi/app/services/usage.py b/packages/fastapi/app/services/usage.py index 4d6c2fb..6fbe1eb 100644 --- a/packages/fastapi/app/services/usage.py +++ b/packages/fastapi/app/services/usage.py @@ -255,3 +255,36 @@ async def record_agent_usage( logger.exception( "Failed to record agent usage for credential '%s'", agent_credential_id ) + + +async def record_agent_rate_limit( + http_client: httpx.AsyncClient, + *, + user_id: str, + agent_credential_id: str, + rate_limit: object, +) -> None: + """Persist the latest Anthropic rate-limit snapshot onto the credential. + + Decoupled from `record_agent_usage` so it lands even when the rate_limit + event carries no cost/tokens — including a silent hard-limit turn, which is + exactly when the user needs to see the reset time. Fire-and-forget. + """ + if not settings.convex_url or not settings.convex_deploy_key: + return + from app.services.convex import run_convex_mutation + + try: + await run_convex_mutation( + http_client, + "agentCredentials:recordRateLimit", + { + "credentialId": agent_credential_id, + "userId": user_id, + "rateLimit": rate_limit, + }, + ) + except Exception: + logger.exception( + "Failed to record rate limit for credential '%s'", agent_credential_id + ) From c2fc9f54edd337f7387f6baa78633bd97c535630 Mon Sep 17 00:00:00 2001 From: DIodide Date: Sun, 21 Jun 2026 12:42:37 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(usage):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20self-heal=20expired=20limit=20+=20minor=20robustness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [MED] The "limit reached" banner was sticky: the credential snapshot is preferred unconditionally and only updates on a status change, so after the window passively reset the red "turns are paused" banner stayed (false). accountUsageFromRateLimit now returns null once resetsAtMs is in the past — the section + gauge self-heal on the next render. - [LOW] UsageBadge level uses `||` not `??` so a 0% account utilization can't suppress a real Harness budget %. - [LOW] The gateway now only persists the rate-limit snapshot when it actually changed (dedup vs session.last_rate_limit) — no credential-row write on every repeat usage_update. +1 self-heal test (past reset → null); active-limit tests use future timestamps. FastAPI 323, web 11 (usage-display), biome clean, tsc 21/21. --- apps/web/src/components/usage-display.test.ts | 26 +++++++++++++++---- apps/web/src/components/usage-display.tsx | 10 ++++++- .../app/services/agents/session_manager.py | 6 ++++- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/usage-display.test.ts b/apps/web/src/components/usage-display.test.ts index 37e80cc..d72d8a2 100644 --- a/apps/web/src/components/usage-display.test.ts +++ b/apps/web/src/components/usage-display.test.ts @@ -37,18 +37,23 @@ describe("formatResetTime", () => { }); describe("accountUsageFromRateLimit (SDK rate_limit_info shape)", () => { + // Active windows must reset in the FUTURE (a past reset self-heals to null). + const futureSec = Math.floor(Date.now() / 1000) + 3600; // +1h, seconds + const futureMs = Date.now() + 3_600_000; // +1h, ms + const pastSec = Math.floor(Date.now() / 1000) - 3600; // -1h, seconds + it("parses a hard limit (status rejected) with a seconds reset timestamp", () => { const a = accountUsageFromRateLimit({ rateLimitType: "five_hour", status: "rejected", - resetsAt: 1771606800, // seconds + resetsAt: futureSec, isUsingOverage: false, }); expect(a).toEqual({ label: "Current session", status: "rejected", utilization: undefined, - resetsAtMs: 1771606800000, // ×1000 + resetsAtMs: futureSec * 1000, // seconds ×1000 }); }); @@ -57,12 +62,12 @@ describe("accountUsageFromRateLimit (SDK rate_limit_info shape)", () => { rateLimitType: "seven_day", status: "allowed_warning", utilization: 73.7, - resetsAt: 1771606800000, // already ms + resetsAt: futureMs, // already ms }); expect(a?.label).toBe("Current week"); expect(a?.status).toBe("warning"); expect(a?.utilization).toBeCloseTo(73.7); - expect(a?.resetsAtMs).toBe(1771606800000); + expect(a?.resetsAtMs).toBe(futureMs); }); it("maps every known rateLimitType and clamps utilization", () => { @@ -93,10 +98,21 @@ describe("accountUsageFromRateLimit (SDK rate_limit_info shape)", () => { expect(accountUsageFromRateLimit("nope")).toBeNull(); }); + it("self-heals: a rejected snapshot whose reset is already past returns null", () => { + // The window already reset; don't keep showing a stale 'limit reached'. + expect( + accountUsageFromRateLimit({ + rateLimitType: "five_hour", + status: "rejected", + resetsAt: pastSec, + }), + ).toBeNull(); + }); + it("falls back to a generic label for an unknown type", () => { const a = accountUsageFromRateLimit({ status: "rejected", - resetsAt: 1771606800, + resetsAt: futureSec, }); expect(a?.label).toBe("Claude account"); expect(a?.status).toBe("rejected"); diff --git a/apps/web/src/components/usage-display.tsx b/apps/web/src/components/usage-display.tsx index 7e80238..375470c 100644 --- a/apps/web/src/components/usage-display.tsx +++ b/apps/web/src/components/usage-display.tsx @@ -62,6 +62,13 @@ export function accountUsageFromRateLimit( ? Math.max(0, Math.min(100, u)) : undefined; const resetsAtMs = toResetMs(rl.resetsAt); + // The snapshot only updates on a status change, and a window that elapses + // passively emits no new event — so once its reset time is in the past the + // stored value is stale. Drop it (self-heal) rather than keep showing a + // "limit reached" banner for a window that already reset. + if (resetsAtMs !== undefined && resetsAtMs <= Date.now()) { + return null; + } // Nothing actionable to show: normal, no number, no reset. if ( status === "allowed" && @@ -518,7 +525,8 @@ export function UsageBadge({ onClick }: { onClick?: () => void }) { // account quota % (what actually limits an agent turn), else the Harness // budget. const limited = acct?.status === "rejected"; - const level = acct?.utilization ?? budgetPct; + // `||` not `??`: a 0% account utilization shouldn't hide a real budget %. + const level = acct?.utilization || budgetPct; const color = limited ? "text-red-400" : level >= 90 diff --git a/packages/fastapi/app/services/agents/session_manager.py b/packages/fastapi/app/services/agents/session_manager.py index 565a007..cd8e1b5 100644 --- a/packages/fastapi/app/services/agents/session_manager.py +++ b/packages/fastapi/app/services/agents/session_manager.py @@ -2015,9 +2015,13 @@ async def _process_session_update(self, session: AgentSession, params: dict) -> if event["event"] == "agent_usage": rl = (update.get("_meta") or {}).get("_claude/rateLimit") if rl is not None: + changed = rl != session.last_rate_limit session.last_rate_limit = rl cred_id = session.harness.agent_credential_id - if cred_id: + # Only persist when the snapshot actually changed — avoids a + # credential-row write on every usage_update that merely + # repeats the same rate-limit state. + if cred_id and changed: from app.services.usage import record_agent_rate_limit rl_task = asyncio.create_task(