From c626fc4a4552b705582f5653cf18df59bb673f7a Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Wed, 13 May 2026 20:22:15 -0700 Subject: [PATCH] Fix menubar stale cache recovery --- mac/Sources/CodeBurnMenubar/AppStore.swift | 38 +++++++++++ mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 33 +++------- .../AppStoreRefreshRecoveryTests.swift | 63 +++++++++++++++++++ 3 files changed, 109 insertions(+), 25 deletions(-) create mode 100644 mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 2757da6..a1b7e2d 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -2,6 +2,7 @@ import Foundation import Observation private let cacheTTLSeconds: TimeInterval = 30 +private let interactiveRefreshResetSeconds: TimeInterval = 120 struct CachedPayload { let payload: MenubarPayload @@ -95,10 +96,34 @@ final class AppStore { } } + var hasStaleInteractivePayload: Bool { + staleInteractivePayloadAgeSeconds != nil + } + + var shouldResetInteractiveRefreshPipeline: Bool { + hasStaleLoading || hasStaleInteractivePayload + } + + var staleInteractivePayloadAgeSeconds: Int? { + let keys = Set([ + currentKey, + PayloadCacheKey(period: .today, provider: .all), + PayloadCacheKey(period: selectedPeriod, provider: .all), + ]) + let staleAges = keys.compactMap { key -> TimeInterval? in + guard let cached = cache[key] else { return nil } + let age = Date().timeIntervalSince(cached.fetchedAt) + return age > interactiveRefreshResetSeconds ? age : nil + } + return staleAges.max().map(Int.init) + } + var needsInteractivePayloadRefresh: Bool { let todayKey = PayloadCacheKey(period: .today, provider: .all) + let periodAllKey = PayloadCacheKey(period: selectedPeriod, provider: .all) return cache[currentKey]?.isFresh != true || cache[todayKey]?.isFresh != true || + cache[periodAllKey]?.isFresh != true || hasStaleLoading } @@ -110,6 +135,12 @@ final class AppStore { cache.values.contains { !$0.payload.current.providers.isEmpty } } +#if DEBUG + func setCachedPayloadForTesting(_ payload: MenubarPayload, period: Period, provider: ProviderFilter, fetchedAt: Date) { + cache[PayloadCacheKey(period: period, provider: provider)] = CachedPayload(payload: payload, fetchedAt: fetchedAt) + } +#endif + var findingsCount: Int { payload.optimize.findingCount } @@ -213,8 +244,15 @@ final class AppStore { formatter.dateFormat = "yyyy-MM-dd" let today = formatter.string(from: Date()) if cacheDate != today { + payloadRefreshGeneration &+= 1 cache.removeAll() + loadingCountsByKey.removeAll() + loadingStartedAtByKey.removeAll() + inFlightKeys.removeAll() + attemptedKeys.removeAll() + lastErrorByKey.removeAll() cacheDate = today + NSLog("CodeBurn: reset menubar payload cache for new day %@", today) } } diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 0c7a76d..7daccb2 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -374,8 +374,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private func refreshPayloadForPopoverOpen() { guard store.needsInteractivePayloadRefresh else { return } + let shouldResetPipeline = store.shouldResetInteractiveRefreshPipeline + if shouldResetPipeline, let age = store.staleInteractivePayloadAgeSeconds { + NSLog("CodeBurn: popover opened with %ds stale payload cache - resetting refresh pipeline", age) + } recoverRefreshPipelineAfterInterruption( - resetLoading: store.hasStaleLoading, + resetLoading: shouldResetPipeline, reason: "popover open" ) } @@ -383,38 +387,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private func startRefreshLoop() { refreshLoopTask?.cancel() refreshLoopHeartbeatAt = Date() + forceRefresh(bypassRateLimit: true, forceQuota: true) refreshLoopTask = Task { [weak self] in - // Provider refreshes only run when the user has explicitly connected. - // Each refresh is a no-op until its corresponding bootstrap flag is set. - if let self { - await self.refreshLiveQuotaProgressIfDue(force: true) - } while !Task.isCancelled { guard let self else { return } self.refreshLoopHeartbeatAt = Date() let clearedStaleForceRefresh = self.clearStaleForceRefreshIfNeeded() let clearedStaleLoading = self.store.clearStaleLoadingIfNeeded() - // Skip the loop's tick if a wake / manual / distributed- - // notification refresh just ran. Without this gate, every - // wake produced two refreshes (forceRefresh from the wake - // observer plus the loop's natural tick). let sinceLast = Date().timeIntervalSince(self.lastRefreshTime) - if self.forceRefreshTask == nil && (clearedStaleForceRefresh || clearedStaleLoading || sinceLast >= 5) { - if self.store.selectedPeriod != .today || self.store.selectedProvider != .all { - async let quiet: Void = self.store.refreshQuietly(period: .today) - async let main: Void = self.store.refresh(includeOptimize: false, force: true) - _ = await (quiet, main) - } else { - await self.store.refresh(includeOptimize: false, force: true) - } - self.lastRefreshTime = Date() - self.refreshStatusButton() + if clearedStaleForceRefresh || clearedStaleLoading || sinceLast >= TimeInterval(refreshIntervalSeconds) { + self.forceRefresh(bypassRateLimit: true) } - // Cadence-driven live-quota refresh, anchored on LAST SUCCESS - // (not last attempt) so an intermittent failure doesn't reset - // the timer. Each provider has its own anchor so a Codex 429 - // doesn't delay a due Claude refresh. - await self.refreshLiveQuotaProgressIfDue() try? await Task.sleep(nanoseconds: refreshIntervalNanos) } } diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift new file mode 100644 index 0000000..0c7fb14 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +private func menubarPayload(cost: Double) -> MenubarPayload { + MenubarPayload( + generated: "test", + current: CurrentBlock( + label: "Today", + cost: cost, + calls: 1, + sessions: 1, + oneShotRate: nil, + inputTokens: 1, + outputTokens: 1, + cacheHitPercent: 0, + topActivities: [], + topModels: [], + providers: ["claude": cost] + ), + optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []), + history: HistoryBlock(daily: []) + ) +} + +@Suite("AppStore refresh recovery") +@MainActor +struct AppStoreRefreshRecoveryTests { + @Test("stale visible payload triggers hard recovery without clearing cache") + func stalePayloadTriggersHardRecoveryWithoutClearingCache() { + let store = AppStore() + store.setCachedPayloadForTesting( + menubarPayload(cost: 92.33), + period: .today, + provider: .all, + fetchedAt: Date().addingTimeInterval(-180) + ) + + #expect(store.todayPayload?.current.cost == 92.33) + #expect(store.needsInteractivePayloadRefresh) + #expect(store.hasStaleInteractivePayload) + #expect(store.shouldResetInteractiveRefreshPipeline) + + store.resetRefreshState(clearCache: false) + + #expect(store.todayPayload?.current.cost == 92.33) + } + + @Test("fresh visible payload does not trigger hard recovery") + func freshPayloadDoesNotTriggerHardRecovery() { + let store = AppStore() + store.setCachedPayloadForTesting( + menubarPayload(cost: 164.06), + period: .today, + provider: .all, + fetchedAt: Date() + ) + + #expect(!store.needsInteractivePayloadRefresh) + #expect(!store.hasStaleInteractivePayload) + #expect(!store.shouldResetInteractiveRefreshPipeline) + } +}