From feaaa0dd764aae6f70f63c3210f411957ef9488e Mon Sep 17 00:00:00 2001 From: prakersh Date: Fri, 23 Oct 2026 16:24:22 +0530 Subject: [PATCH] fix(menubar): stop a removed Anthropic quota from pinning the card age After a Max -> Pro downgrade the usage API stopped reporting a per-model weekly quota. The menubar remembered its last-seen time for up to 24h, so the Anthropic card read "20h" while every quota it showed was seconds old. A quota missing from the payload is now dropped at once when a newer API poll came back without it - API polls carry every quota the plan has. With no newer poll (API failing) the bounded memory still applies, so the card cannot look fresher than its data. Statusline-only mode skips the check. --- internal/web/menubar.go | 34 +++++- internal/web/menubar_oldest_review_test.go | 133 +++++++++++++++++++++ 2 files changed, 164 insertions(+), 3 deletions(-) diff --git a/internal/web/menubar.go b/internal/web/menubar.go index 719afd1c..e9e0e3ce 100644 --- a/internal/web/menubar.go +++ b/internal/web/menubar.go @@ -724,7 +724,13 @@ func (h *Handler) applyMenubarOldestCapturedAt(card *menubar.ProviderCard, id st // stale for up to menubarQuotaMemoryBound after the quota disappeared. // Anthropic and Mistral read a window (per-quota merge and per-quota latest // snapshots), where a missing quota may simply have aged out. - oldest := h.mergeQuotaTimes(id, present, base != "cursor") + var reportedAt time.Time + if base == "anthropic" && h.anthropicSource() != "statusline" { + // In statusline mode an old API poll says nothing about the current + // plan, and the lookup would scan every statusline row since it. + reportedAt = h.anthropicLastAPIReport() + } + oldest := h.mergeQuotaTimes(id, present, base != "cursor", reportedAt) if oldest.IsZero() { return current } @@ -762,6 +768,23 @@ func (h *Handler) anthropicSource() string { return source } +// anthropicLastAPIReport returns when the newest Anthropic usage API poll was +// stored, or zero when unknown. API polls carry every quota the plan has, so a +// quota last seen before this time is no longer reported. +func (h *Handler) anthropicLastAPIReport() time.Time { + if h.store == nil { + return time.Time{} + } + at, ok, err := h.store.LastAnthropicAPISnapshot() + if err != nil || !ok { + return time.Time{} + } + // Payload quota times are whole seconds; the stored poll time keeps + // nanoseconds. Without truncation a quota from this very poll would read + // as older than it and be dropped. + return at.Truncate(time.Second) +} + // anthropicPrimaryCapturedAt returns the newest capture time among the two // primary windows the statusline feeds. They are what the card and tray show, // so their freshness is what the card's staleness means. A payload without @@ -809,7 +832,12 @@ const menubarQuotaMemoryBound = 24 * time.Hour // rememberMissing must be false for providers whose builder reads only the // latest snapshot (Cursor): a quota absent there is gone, so it stops counting // immediately instead of pinning the card until the bound expires. -func (h *Handler) mergeQuotaTimes(id string, present map[string]time.Time, rememberMissing bool) time.Time { +// +// reportedAt is when the provider last sent a complete report (for Anthropic, +// the newest API poll). A missing quota last seen before it was left out of +// that report, so it no longer exists (a plan downgrade drops per-model weekly +// buckets) and is dropped at once. Zero disables the check. +func (h *Handler) mergeQuotaTimes(id string, present map[string]time.Time, rememberMissing bool, reportedAt time.Time) time.Time { h.menubarLastGoodMu.Lock() defer h.menubarLastGoodMu.Unlock() if len(present) == 0 && rememberMissing { @@ -850,7 +878,7 @@ func (h *Handler) mergeQuotaTimes(id string, present map[string]time.Time, remem if _, ok := present[name]; ok { continue } - if !rememberMissing { + if !rememberMissing || lastSeen[name].Before(reportedAt) { delete(lastSeen, name) delete(missing, name) continue diff --git a/internal/web/menubar_oldest_review_test.go b/internal/web/menubar_oldest_review_test.go index 81f31bdb..1f821e7a 100644 --- a/internal/web/menubar_oldest_review_test.go +++ b/internal/web/menubar_oldest_review_test.go @@ -333,3 +333,136 @@ func TestMenubarRemovedQuotaUnpinsAfterBound(t *testing.T) { t.Fatalf("remembered quotas = %v, want only the present one", got) } } + +// A plan change (Max -> Pro) removes API-only quotas such as a per-model weekly +// bucket. Once a newer API poll has come back without the quota, it is gone: +// it must stop pinning the card's age immediately rather than for +// menubarQuotaMemoryBound. Before the fix the card read "20h" while every quota +// it showed was seconds old. +func TestMenubarQuotaOmittedByNewerAPIPollUnpinsAtOnce(t *testing.T) { + h, s, _, done := staleTestHandler(t, anthropicStaleConfig()) + defer done() + + now := time.Now().UTC().Truncate(time.Second) + removedAt := now.Add(-20 * time.Hour) + apiAt := now.Add(-time.Minute) + if _, err := s.InsertAnthropicSnapshot(&api.AnthropicSnapshot{ + CapturedAt: apiAt, + Quotas: []api.AnthropicQuota{ + {Name: "five_hour", Utilization: 1}, + {Name: "seven_day", Utilization: 11}, + }, + }); err != nil { + t.Fatalf("insert API snapshot: %v", err) + } + + withRemoved := map[string]interface{}{ + "capturedAt": apiAt.Format(time.RFC3339), + "quotas": []interface{}{ + map[string]interface{}{"name": "five_hour", "lastUpdatedAt": apiAt.Format(time.RFC3339)}, + map[string]interface{}{"name": "seven_day", "lastUpdatedAt": apiAt.Format(time.RFC3339)}, + map[string]interface{}{"name": "seven_day_sonnet", "lastUpdatedAt": removedAt.Format(time.RFC3339)}, + }, + } + card := &menubar.ProviderCard{} + if got := h.applyMenubarOldestCapturedAt(card, "anthropic", withRemoved); !got.Equal(removedAt) { + t.Fatalf("while the quota is still shown = %v, want its own time %v", got, removedAt) + } + + without := map[string]interface{}{ + "capturedAt": apiAt.Format(time.RFC3339), + "quotas": []interface{}{ + map[string]interface{}{"name": "five_hour", "lastUpdatedAt": apiAt.Format(time.RFC3339)}, + map[string]interface{}{"name": "seven_day", "lastUpdatedAt": apiAt.Format(time.RFC3339)}, + }, + } + card = &menubar.ProviderCard{} + if got := h.applyMenubarOldestCapturedAt(card, "anthropic", without); !got.Equal(apiAt) { + t.Fatalf("after a newer API poll omitted it = %v, want the shown quotas' time %v", got, apiAt) + } + if _, ok := h.menubarQuotas["anthropic"]["seven_day_sonnet"]; ok { + t.Fatal("the removed quota is still remembered") + } +} + +// The counterpart: when no API poll has succeeded since the quota was last +// seen (polls failing), its absence proves nothing, so it keeps pinning the +// age and the card cannot look fresher than its data. +func TestMenubarQuotaMissingWithoutNewerAPIPollStaysRemembered(t *testing.T) { + h, s, _, done := staleTestHandler(t, anthropicStaleConfig()) + defer done() + + // The stored poll time keeps nanoseconds while payload times are whole + // seconds; the quota from that very poll must not read as older than it. + now := time.Now().UTC() + if now.Nanosecond() == 0 { + now = now.Add(time.Millisecond) + } + lastAPI := now.Add(-3 * time.Hour) + lastAPISeconds := lastAPI.Truncate(time.Second) + statuslineAt := now.Add(-time.Minute) + if _, err := s.InsertAnthropicSnapshot(&api.AnthropicSnapshot{ + CapturedAt: lastAPI, + Quotas: []api.AnthropicQuota{ + {Name: "five_hour", Utilization: 1}, + {Name: "seven_day", Utilization: 11}, + {Name: "seven_day_sonnet", Utilization: 5}, + }, + }); err != nil { + t.Fatalf("insert API snapshot: %v", err) + } + + withSonnet := map[string]interface{}{ + "quotas": []interface{}{ + map[string]interface{}{"name": "five_hour", "lastUpdatedAt": statuslineAt.Format(time.RFC3339)}, + map[string]interface{}{"name": "seven_day_sonnet", "lastUpdatedAt": lastAPI.Format(time.RFC3339)}, + }, + } + card := &menubar.ProviderCard{} + h.applyMenubarOldestCapturedAt(card, "anthropic", withSonnet) + + aged := map[string]interface{}{ + "quotas": []interface{}{ + map[string]interface{}{"name": "five_hour", "lastUpdatedAt": statuslineAt.Format(time.RFC3339)}, + }, + } + card = &menubar.ProviderCard{} + if got := h.applyMenubarOldestCapturedAt(card, "anthropic", aged); !got.Equal(lastAPISeconds) { + t.Fatalf("missing quota with no newer API poll = %v, want the remembered %v", got, lastAPISeconds) + } +} + +// Statusline-only mode never polls the API, so an old API snapshot from before +// the switch says nothing about the current plan: a missing quota keeps the +// bounded memory instead of being dropped against that stale poll. +func TestMenubarStatuslineModeIgnoresOldAPIPoll(t *testing.T) { + cfg := &config.Config{AnthropicToken: "token", AnthropicSource: "statusline", PollInterval: 120 * time.Second} + h, s, _, done := staleTestHandler(t, cfg) + defer done() + + now := time.Now().UTC().Truncate(time.Second) + lastSeen := now.Add(-5 * time.Hour) + if _, err := s.InsertAnthropicSnapshot(&api.AnthropicSnapshot{ + CapturedAt: now.Add(-time.Hour), + Quotas: []api.AnthropicQuota{{Name: "five_hour", Utilization: 1}}, + }); err != nil { + t.Fatalf("insert API snapshot: %v", err) + } + + card := &menubar.ProviderCard{} + h.applyMenubarOldestCapturedAt(card, "anthropic", map[string]interface{}{ + "quotas": []interface{}{ + map[string]interface{}{"name": "five_hour", "lastUpdatedAt": now.Format(time.RFC3339)}, + map[string]interface{}{"name": "seven_day_sonnet", "lastUpdatedAt": lastSeen.Format(time.RFC3339)}, + }, + }) + card = &menubar.ProviderCard{} + got := h.applyMenubarOldestCapturedAt(card, "anthropic", map[string]interface{}{ + "quotas": []interface{}{ + map[string]interface{}{"name": "five_hour", "lastUpdatedAt": now.Format(time.RFC3339)}, + }, + }) + if !got.Equal(lastSeen) { + t.Fatalf("statusline mode = %v, want the remembered %v", got, lastSeen) + } +}