Fix menubar stale cache recovery

This commit is contained in:
iamtoruk 2026-05-13 20:22:15 -07:00
parent aa946d0965
commit c626fc4a45
3 changed files with 109 additions and 25 deletions

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}