mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-16 19:44:14 +00:00
Fix menubar stale cache recovery
This commit is contained in:
parent
aa946d0965
commit
c626fc4a45
3 changed files with 109 additions and 25 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue