diff --git a/CHANGELOG.md b/CHANGELOG.md index ad2b77409b..ec0352bdb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.38.1 — Unreleased ### Added +- Usage refresh: add an opt-in Adaptive cadence that polls every 2–30 minutes based on recent menu use, Low Power Mode, and thermal state. Thanks @hhh2210! - Codex: show a conservative 1.5× pace-headroom hint in menus and CLI output when usage is safely ahead of the reset curve. Thanks @astuteprogrammer! ### Changed @@ -20,7 +21,6 @@ - Settings: recover collapsed sidebars and undersized saved window frames when reopening Settings. Thanks @ProspectOre! - z.ai: parse successful BigModel CN quota responses that omit the optional message field, while preserving useful API-code errors. Thanks @joeVenner! - Claude: block background delegated CLI OAuth refresh when the keychain holds MCP-only state (`mcpOAuth` without `claudeAiOauth`) while preserving explicit Refresh recovery (#1844). Thanks @Yuxin-Qiao! - ## 0.38.0 — 2026-07-03 ### Added diff --git a/Sources/CodexBar/AdaptiveRefreshPolicy.swift b/Sources/CodexBar/AdaptiveRefreshPolicy.swift new file mode 100644 index 0000000000..d87d34429d --- /dev/null +++ b/Sources/CodexBar/AdaptiveRefreshPolicy.swift @@ -0,0 +1,70 @@ +import Foundation + +/// Decides how long to wait before the next automatic usage refresh. +/// Pure by construction: every signal arrives via `Input`, so the same +/// input always yields the same `Decision` with no clock or system reads. +struct AdaptiveRefreshPolicy: Sendable { + struct Input: Sendable, Equatable { + let now: Date + let lastMenuOpenAt: Date? + let lowPowerModeEnabled: Bool + let thermalState: ProcessInfo.ThermalState + } + + enum Reason: String, Sendable { + case recentInteraction + case warm + case idle + case longIdle + case constrained + } + + struct Decision: Sendable, Equatable { + let delay: Duration + let reason: Reason + } + + private static let recentInteractionThreshold: TimeInterval = 5 * 60 + private static let warmThreshold: TimeInterval = 60 * 60 + private static let idleThreshold: TimeInterval = 4 * 60 * 60 + + /// Representative cadence for consumers that need a single interval but cannot reach live + /// signals (`ProviderRegistry` builds provider specs before a `UsageStore` exists). Matches + /// `warmDelay`: the steady-state cadence while the user is active, which is when + /// interval-derived heuristics such as the persistent-CLI-session idle window matter most. + static let nominalIntervalForHeuristics: TimeInterval = 5 * 60 + + private static let recentInteractionDelay: Duration = .seconds(2 * 60) + private static let warmDelay: Duration = .seconds(5 * 60) + private static let idleDelay: Duration = .seconds(15 * 60) + private static let longIdleDelay: Duration = .seconds(30 * 60) + private static let constrainedDelay: Duration = .seconds(30 * 60) + + func nextDelay(for input: Input) -> Decision { + if input.lowPowerModeEnabled || Self.isConstrained(input.thermalState) { + return Decision(delay: Self.constrainedDelay, reason: .constrained) + } + + guard let lastMenuOpenAt = input.lastMenuOpenAt else { + return Decision(delay: Self.longIdleDelay, reason: .longIdle) + } + + // A future or clock-adjusted timestamp yields a negative age, which reads as recent. + let age = input.now.timeIntervalSince(lastMenuOpenAt) + + if age <= Self.recentInteractionThreshold { + return Decision(delay: Self.recentInteractionDelay, reason: .recentInteraction) + } + if age <= Self.warmThreshold { + return Decision(delay: Self.warmDelay, reason: .warm) + } + if age < Self.idleThreshold { + return Decision(delay: Self.idleDelay, reason: .idle) + } + return Decision(delay: Self.longIdleDelay, reason: .longIdle) + } + + private static func isConstrained(_ state: ProcessInfo.ThermalState) -> Bool { + state == .serious || state == .critical + } +} diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index 847c477d4b..e16cf8015f 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -84,7 +84,7 @@ struct ProviderRegistry { costUsageHistoryDays: settings.costUsageHistoryDays, persistsCLISessions: true, persistentCLISessionIdleWindow: Self.persistentCLISessionIdleWindow( - refreshInterval: settings.refreshFrequency.seconds)) + refreshInterval: Self.nominalRefreshInterval(for: settings.refreshFrequency))) }) specs[provider] = spec } @@ -96,6 +96,14 @@ struct ProviderRegistry { max(180, (refreshInterval ?? 120) + 60) } + /// `RefreshFrequency.seconds` is nil for `.adaptive`, which would collapse the idle window to + /// its floor and churn persistent CLI sessions between adaptive ticks. No `UsageStore` exists + /// when specs are built, so `.adaptive` maps to the policy's nominal interval instead of a + /// live decision; `.manual` stays nil. + static func nominalRefreshInterval(for frequency: RefreshFrequency) -> TimeInterval? { + frequency == .adaptive ? AdaptiveRefreshPolicy.nominalIntervalForHeuristics : frequency.seconds + } + @MainActor static func makeSettingsSnapshot( settings: SettingsStore, diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 396340ad5d..c432223f13 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -674,6 +674,7 @@ "refresh_5min" = "5 دقائق"; "refresh_15min" = "15 دقيقة"; "refresh_30min" = "30 دقيقة"; +"refresh_adaptive" = "تكيفي"; /* Additional keys */ "not_found" = "لم يعثر عليه"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 6ec3b24984..be79824538 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -660,6 +660,7 @@ "refresh_5min" = "5 min"; "refresh_15min" = "15 min"; "refresh_30min" = "30 min"; +"refresh_adaptive" = "Adaptatiu"; /* Additional keys */ "not_found" = "No trobat"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index cd071a3c9f..db947e66c3 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -671,6 +671,7 @@ "refresh_5min" = "5 Min"; "refresh_15min" = "15 Min"; "refresh_30min" = "30 Min"; +"refresh_adaptive" = "Adaptiv"; /* Additional keys */ "not_found" = "Nicht gefunden"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 2da28251f6..9f775253ba 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -674,6 +674,7 @@ "refresh_5min" = "5 min"; "refresh_15min" = "15 min"; "refresh_30min" = "30 min"; +"refresh_adaptive" = "Adaptive"; /* Additional keys */ "not_found" = "Not found"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index fc52ee6a7d..68810e6c06 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -666,6 +666,7 @@ "refresh_5min" = "5 min"; "refresh_15min" = "15 min"; "refresh_30min" = "30 min"; +"refresh_adaptive" = "Adaptativo"; /* Additional keys */ "not_found" = "No encontrado"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index c7bf07f936..0a21f04ce9 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -674,6 +674,7 @@ "refresh_5min" = "۵ دقیقه"; "refresh_15min" = "۱۵ دقیقه"; "refresh_30min" = "۳۰ دقیقه"; +"refresh_adaptive" = "تطبیقی"; /* Additional keys */ "not_found" = "پیدا نشد"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index e8ba174b87..72b62eddcf 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -673,6 +673,7 @@ "refresh_5min" = "5 minutes"; "refresh_15min" = "15 minutes"; "refresh_30min" = "30 minutes"; +"refresh_adaptive" = "Adaptatif"; /* Additional keys */ "not_found" = "Pas trouvé"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 220bffd67b..da64fd2ab6 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -676,6 +676,7 @@ "refresh_5min" = "5 mnt"; "refresh_15min" = "15 mnt"; "refresh_30min" = "30 mnt"; +"refresh_adaptive" = "Adaptif"; /* Additional keys */ "not_found" = "Tidak ditemukan"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 9fb4229983..6466b8dc65 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -676,6 +676,7 @@ "refresh_5min" = "5 min."; "refresh_15min" = "15 min."; "refresh_30min" = "30 min."; +"refresh_adaptive" = "Adattivo"; /* Additional keys */ "not_found" = "Non trovato"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 88935d5e9c..9dbd00a893 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -670,6 +670,7 @@ "refresh_5min" = "5分"; "refresh_15min" = "15分"; "refresh_30min" = "30分"; +"refresh_adaptive" = "アダプティブ"; /* Additional keys */ "not_found" = "見つかりません"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 98e7dc4eb3..03dc9fea39 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -648,6 +648,7 @@ "refresh_5min" = "5분"; "refresh_15min" = "15분"; "refresh_30min" = "30분"; +"refresh_adaptive" = "적응형"; "not_found" = "찾을 수 없음"; "cost_header_estimated" = "비용(추정)"; "cost_estimate_hint" = "로컬 로그에서 추정 · 실제 청구액과 다를 수 있음"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 40a5fa6b5b..edad9f3c79 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -673,6 +673,7 @@ "refresh_5min" = "5 min"; "refresh_15min" = "15 minuten"; "refresh_30min" = "30 min"; +"refresh_adaptive" = "Adaptief"; /* Additional keys */ "not_found" = "Niet gevonden"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 44f59596f2..f9ec8acdf7 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -676,6 +676,7 @@ "refresh_5min" = "Co 5 min"; "refresh_15min" = "Co 15 min"; "refresh_30min" = "Co 30 min"; +"refresh_adaptive" = "Adaptacyjny"; /* Additional keys */ "not_found" = "Nie znaleziono"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index c2e6bd93d5..c528e5b38c 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -670,6 +670,7 @@ "refresh_5min" = "5 min"; "refresh_15min" = "15 min"; "refresh_30min" = "30 min"; +"refresh_adaptive" = "Adaptativo"; /* Additional keys */ "not_found" = "Não encontrado"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index b77a92bbca..bdabd2d439 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -672,6 +672,7 @@ "refresh_5min" = "5 min"; "refresh_15min" = "15 min"; "refresh_30min" = "30 min"; +"refresh_adaptive" = "Adaptiv"; /* Additional keys */ "not_found" = "Hittades inte"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 0919875fcd..98a85a44d9 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -674,6 +674,7 @@ "refresh_5min" = "5 นาที"; "refresh_15min" = "15 นาที"; "refresh_30min" = "30 นาที"; +"refresh_adaptive" = "ปรับอัตโนมัติ"; /* Additional keys */ "not_found" = "ไม่พบ"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 501dd51e60..26b945b6d6 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -672,6 +672,7 @@ "refresh_5min" = "5 dak"; "refresh_15min" = "15 dak"; "refresh_30min" = "30 dak"; +"refresh_adaptive" = "Uyarlanabilir"; /* Additional keys */ "not_found" = "Bulunamadı"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index e7dc045743..fa88822a41 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -673,6 +673,7 @@ "refresh_5min" = "5 хв"; "refresh_15min" = "15 хв"; "refresh_30min" = "30 хв"; +"refresh_adaptive" = "Адаптивний"; /* Additional keys */ "not_found" = "Не знайдено"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index c3bdc46e4c..8562b25832 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -669,6 +669,7 @@ "refresh_5min" = "5 phút"; "refresh_15min" = "15 phút"; "refresh_30min" = "30 phút"; +"refresh_adaptive" = "Thích ứng"; /* Additional keys */ "not_found" = "Không tìm thấy"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index cd4a92741d..4fbdab092d 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -645,6 +645,7 @@ "refresh_5min" = "5 分钟"; "refresh_15min" = "15 分钟"; "refresh_30min" = "30 分钟"; +"refresh_adaptive" = "自适应"; "not_found" = "未找到"; "CodexBar can't show its menu bar icon" = "CodexBar 无法显示菜单栏图标"; "Dismiss" = "关闭"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 5cbea09091..7a619c7413 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -658,6 +658,7 @@ "refresh_5min" = "5 分鐘"; "refresh_15min" = "15 分鐘"; "refresh_30min" = "30 分鐘"; +"refresh_adaptive" = "自適應"; "not_found" = "找不到"; "CodexBar can't show its menu bar icon" = "CodexBar 無法顯示選單列圖示"; "Dismiss" = "關閉"; diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index a60f44f409..aa350c4e90 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -10,11 +10,16 @@ enum RefreshFrequency: String, CaseIterable, Identifiable { case fiveMinutes case fifteenMinutes case thirtyMinutes + /// Newest/most advanced option; kept last so the picker still lists the fixed intervals + /// in ascending cadence order before it. + case adaptive var id: String { self.rawValue } + /// nil for `.manual` (no timer) and `.adaptive` (delay is computed per tick by + /// `AdaptiveRefreshPolicy`, not a fixed interval). var seconds: TimeInterval? { switch self { case .manual: nil @@ -23,6 +28,7 @@ enum RefreshFrequency: String, CaseIterable, Identifiable { case .fiveMinutes: 300 case .fifteenMinutes: 900 case .thirtyMinutes: 1800 + case .adaptive: nil } } @@ -34,6 +40,7 @@ enum RefreshFrequency: String, CaseIterable, Identifiable { case .fiveMinutes: L("refresh_5min") case .fifteenMinutes: L("refresh_15min") case .thirtyMinutes: L("refresh_30min") + case .adaptive: L("refresh_adaptive") } } } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 1c41afcb01..714dbd2791 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -68,6 +68,9 @@ extension StatusItemController { } func menuWillOpen(_ menu: NSMenu) { + // Records interaction and may bring an adaptive timer forward; never refreshes synchronously. + self.store.noteMenuOpened() + let trace = self.beginMenuOperationTrace("menuWillOpen", breadcrumb: "menuWillOpen") defer { self.endMenuOperationTrace(trace, menu: menu, provider: self.menuProvider(for: menu)) } diff --git a/Sources/CodexBar/UsageStore+AdaptiveRefresh.swift b/Sources/CodexBar/UsageStore+AdaptiveRefresh.swift new file mode 100644 index 0000000000..670ef113d7 --- /dev/null +++ b/Sources/CodexBar/UsageStore+AdaptiveRefresh.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Wiring around `AdaptiveRefreshPolicy` for `UsageStore.startTimer()`: gathering live signals, +/// logging the resulting decision, and applying the DEBUG-only sleep-duration override used by +/// tests. Split out of UsageStore.swift to keep that file's class body under the lint line limit. +extension UsageStore { + func effectiveTimerSleepDuration(_ computed: Duration) -> Duration { + #if DEBUG + self.refreshTimerSleepOverrideForTesting ?? computed + #else + computed + #endif + } + + /// Pure wiring helper: builds the `AdaptiveRefreshPolicy.Input` from explicit values and + /// returns the resulting decision. `startTimer()` supplies live `ProcessInfo` state and + /// `lastMenuOpenAt` at call time; this stays a plain, testable function of its arguments. + nonisolated static func adaptiveRefreshDecision( + now: Date, + lastMenuOpenAt: Date?, + lowPowerModeEnabled: Bool, + thermalState: ProcessInfo.ThermalState, + policy: AdaptiveRefreshPolicy = AdaptiveRefreshPolicy()) -> AdaptiveRefreshPolicy.Decision + { + policy.nextDelay(for: AdaptiveRefreshPolicy.Input( + now: now, + lastMenuOpenAt: lastMenuOpenAt, + lowPowerModeEnabled: lowPowerModeEnabled, + thermalState: thermalState)) + } + + nonisolated static func shouldAdvanceAdaptiveTimer(scheduledAt: Date?, candidate: Date) -> Bool { + guard let scheduledAt else { return true } + return candidate < scheduledAt + } + + func logAdaptiveRefreshDecision(_ decision: AdaptiveRefreshPolicy.Decision) { + // Reason and delay only; never provider/account/email/path/credential/response data. + // No "adaptive refresh: " prefix — the adaptiveRefresh log category already identifies the source. + self.adaptiveRefreshLogger.debug( + "reason=\(decision.reason.rawValue) delay=\(decision.delay.components.seconds)s") + } + + /// Computes this tick's adaptive sleep duration (and logs the decision) while briefly holding a + /// strong reference to `store`; returns nil once the store has deallocated, ending the loop. + /// Kept as a separate call so the strong reference doesn't extend into the caller's `Task.sleep`. + static func nextAdaptiveTimerSleepDuration(for store: UsageStore?) async -> Duration? { + guard let store else { return nil } + let now = Date() + let decision = Self.adaptiveRefreshDecision( + now: now, + lastMenuOpenAt: store.lastMenuOpenAt, + lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled, + thermalState: ProcessInfo.processInfo.thermalState) + store.adaptiveRefreshScheduledAt = now.addingTimeInterval(TimeInterval(decision.delay.components.seconds)) + store.logAdaptiveRefreshDecision(decision) + return store.effectiveTimerSleepDuration(decision.delay) + } + + /// The refresh interval scheduling *heuristics* (reset-boundary refresh, OpenAI web staleness, + /// persistent-CLI-session idle windows) should use as "how often does a normal refresh happen". + /// This is deliberately distinct from `RefreshFrequency.seconds`, which is nil for both `.manual` + /// (no timer at all — heuristics correctly get nil here too) and `.adaptive` (no *fixed* + /// interval, but ticks are still happening on a real, computable cadence). For `.adaptive`, this + /// resolves to what `AdaptiveRefreshPolicy` would decide right now from live signals, so those + /// heuristics stay active and roughly proportionate instead of silently behaving like manual. + func normalRefreshIntervalForHeuristics() -> TimeInterval? { + switch self.settings.refreshFrequency { + case .manual: + nil + case .adaptive: + TimeInterval(Self.adaptiveRefreshDecision( + now: Date(), + lastMenuOpenAt: self.lastMenuOpenAt, + lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled, + thermalState: ProcessInfo.processInfo.thermalState).delay.components.seconds) + default: + self.settings.refreshFrequency.seconds + } + } +} diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 2a5f9f1982..4ed5a410f5 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -65,8 +65,8 @@ extension UsageStore { startupConnectivityRetryAttempt == nil ? providerRefreshPhase : .startup } - private func openAIWebRefreshIntervalSeconds() -> TimeInterval { - let base = max(self.settings.refreshFrequency.seconds ?? 0, 120) + func openAIWebRefreshIntervalSeconds() -> TimeInterval { + let base = max(self.normalRefreshIntervalForHeuristics() ?? 0, 120) return base * Self.openAIWebRefreshMultiplier } diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index f6a2365ffc..dc12e469b1 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -715,7 +715,7 @@ extension UsageStore { costUsageHistoryDays: self.settings.costUsageHistoryDays, persistsCLISessions: true, persistentCLISessionIdleWindow: ProviderRegistry.persistentCLISessionIdleWindow( - refreshInterval: self.settings.refreshFrequency.seconds)) + refreshInterval: self.normalRefreshIntervalForHeuristics())) } func sourceMode(for provider: UsageProvider) -> ProviderSourceMode { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 738835e686..7839db907e 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -229,6 +229,7 @@ final class UsageStore { @ObservationIgnored private let tokenCostLogger = CodexBarLog.logger(LogCategories.tokenCost) @ObservationIgnored let augmentLogger = CodexBarLog.logger(LogCategories.augment) @ObservationIgnored let providerLogger = CodexBarLog.logger(LogCategories.providers) + @ObservationIgnored let adaptiveRefreshLogger = CodexBarLog.logger(LogCategories.adaptiveRefresh) @ObservationIgnored var openAIWebDebugLines: [String] = [] @ObservationIgnored var failureGates: [UsageProvider: ConsecutiveFailureGate] = [:] @ObservationIgnored var tokenFailureGates: [UsageProvider: ConsecutiveFailureGate] = [:] @@ -239,6 +240,9 @@ final class UsageStore { @ObservationIgnored private var providerAvailabilityCache: [UsageProvider: ProviderAvailabilityCacheEntry] = [:] @ObservationIgnored var accountInfoCache: [UsageProvider: AccountInfoCacheEntry] = [:] @ObservationIgnored private var timerTask: Task? + /// In-memory only; resets on every launch. + @ObservationIgnored private(set) var lastMenuOpenAt: Date? + @ObservationIgnored var adaptiveRefreshScheduledAt: Date? @ObservationIgnored private var tokenTimerTask: Task? @ObservationIgnored private var tokenRefreshSequenceTask: Task? @ObservationIgnored private var tokenRefreshSequenceProvider: UsageProvider? @@ -577,6 +581,27 @@ final class UsageStore { await self.runRefresh(forceTokenUsage: forceTokenUsage, startupConnectivityRetryAttempt: nil) } + func noteMenuOpened(at date: Date = Date()) { + self.lastMenuOpenAt = date + guard self.settings.refreshFrequency == .adaptive else { return } + + let decision = Self.adaptiveRefreshDecision( + now: date, + lastMenuOpenAt: date, + lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled, + thermalState: ProcessInfo.processInfo.thermalState) + let candidate = date.addingTimeInterval(TimeInterval(decision.delay.components.seconds)) + guard Self.shouldAdvanceAdaptiveTimer( + scheduledAt: self.adaptiveRefreshScheduledAt, + candidate: candidate) + else { return } + self.startTimer(preservingResetBoundaryRefresh: true) + } + + #if DEBUG + @ObservationIgnored private(set) var completedRefreshCountForTesting = 0 + #endif + func runRefresh( forceTokenUsage: Bool = false, startupConnectivityRetryAttempt: Int?, @@ -680,7 +705,7 @@ final class UsageStore { } self.scheduleResetBoundaryRefreshIfNeeded( - normalRefreshInterval: self.settings.refreshFrequency.seconds) + normalRefreshInterval: self.normalRefreshIntervalForHeuristics()) if allowsStartupConnectivityRetry { self.completeStartupConnectivityRetryPass(currentAttempt: startupConnectivityRetryAttempt ?? 0) @@ -688,6 +713,9 @@ final class UsageStore { if refreshPhase == .startup { self.scheduleMemoryPressureRelief() } + #if DEBUG + self.completedRefreshCountForTesting += 1 + #endif } /// For demo/testing: drop the snapshot so the loading animation plays, then restore the last snapshot. @@ -710,15 +738,56 @@ final class UsageStore { self.observeSettingsChanges() } - private func startTimer() { + #if DEBUG + @ObservationIgnored private(set) var refreshTimerSleepOverrideForTesting: Duration? + + /// Sets this store's timer sleep override and restarts the timer with it applied, so tests can + /// observe multiple fixed/adaptive ticks without waiting real minutes. The reason/delay a tick + /// computes and logs is unaffected; only how long it sleeps before acting on that decision + /// changes. Instance-scoped (not a shared global) so concurrently running tests, each with their + /// own `UsageStore`, cannot clobber one another's override. + func restartTimerWithSleepOverrideForTesting(_ duration: Duration?) { + self.refreshTimerSleepOverrideForTesting = duration + self.startTimer() + } + #endif + + private func startTimer(preservingResetBoundaryRefresh: Bool = false) { self.timerTask?.cancel() - self.cancelResetBoundaryRefresh() - guard let wait = self.settings.refreshFrequency.seconds else { return } + self.adaptiveRefreshScheduledAt = nil + if !preservingResetBoundaryRefresh { + self.cancelResetBoundaryRefresh() + } + + let frequency = self.settings.refreshFrequency + guard frequency != .manual else { return } + + if frequency == .adaptive { + // Background poller so the menu stays responsive; canceled when settings change or store + // deallocates. Delay is recomputed before every tick from live power/thermal state and the + // in-memory menu-open signal; the policy itself stays pure (Input is built here). `self` is + // only strongly held for the brief, synchronous decision computation below, never across + // the sleep — a weak reference lets the store deallocate mid-sleep, same as fixed mode. + self.timerTask = Task.detached(priority: .utility) { [weak self] in + while !Task.isCancelled { + guard let sleepDuration = await Self.nextAdaptiveTimerSleepDuration(for: self) else { return } + try? await Task.sleep(for: sleepDuration) + guard !Task.isCancelled else { return } + await self?.refresh() + } + } + return + } + + guard let wait = frequency.seconds else { return } // Background poller so the menu stays responsive; canceled when settings change or store deallocates. + // `self` is only briefly borrowed to read the (DEBUG-only) sleep override, never held across the sleep. self.timerTask = Task.detached(priority: .utility) { [weak self] in while !Task.isCancelled { - try? await Task.sleep(for: .seconds(wait)) + let sleepDuration = await self?.effectiveTimerSleepDuration(.seconds(wait)) ?? .seconds(wait) + try? await Task.sleep(for: sleepDuration) + guard !Task.isCancelled else { return } await self?.refresh() } } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index add755e2d2..547df9120e 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -1,6 +1,7 @@ public enum LogCategories { public static let abacusCookie = "abacus-cookie" public static let abacusUsage = "abacus-usage" + public static let adaptiveRefresh = "adaptive-refresh" public static let amp = "amp" public static let antigravity = "antigravity" public static let app = "app" diff --git a/Tests/CodexBarTests/AdaptiveRefreshHeuristicsTests.swift b/Tests/CodexBarTests/AdaptiveRefreshHeuristicsTests.swift new file mode 100644 index 0000000000..b60601963c --- /dev/null +++ b/Tests/CodexBarTests/AdaptiveRefreshHeuristicsTests.swift @@ -0,0 +1,201 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +/// Covers `normalRefreshIntervalForHeuristics()` and every consumer that previously read +/// `RefreshFrequency.seconds` directly. That property is nil for both `.manual` and `.adaptive`, +/// so without the helper the interval-derived heuristics (reset-boundary refresh, OpenAI web +/// staleness, persistent-CLI-session idle windows) silently degrade to manual behavior the +/// moment a user picks adaptive. Each consumer has a test here that goes red if its call site +/// is reverted to `.seconds`. +@MainActor +struct AdaptiveRefreshHeuristicsTests { + @Test + func `manual keeps the heuristics interval nil`() { + let store = Self.makeStore(suite: "heuristics-manual-nil", frequency: .manual) + #expect(store.normalRefreshIntervalForHeuristics() == nil) + } + + @Test(arguments: [ + (RefreshFrequency.oneMinute, 60.0), + (.twoMinutes, 120.0), + (.fiveMinutes, 300.0), + (.fifteenMinutes, 900.0), + (.thirtyMinutes, 1800.0) + ]) + func `fixed frequencies pass their configured seconds through`( + frequency: RefreshFrequency, + expectedSeconds: TimeInterval) + { + let store = Self.makeStore(suite: "heuristics-fixed-\(frequency.rawValue)", frequency: frequency) + #expect(store.normalRefreshIntervalForHeuristics() == expectedSeconds) + } + + @Test + func `adaptive resolves to the live adaptive decision delay`() { + let store = Self.makeStore(suite: "heuristics-adaptive-live", frequency: .adaptive) + + // No recorded menu open: the decision is longIdle, or constrained on a low-power/hot + // machine — both are 30 minutes, so this assertion is environment-independent. + #expect(store.normalRefreshIntervalForHeuristics() == 1800.0) + + store.noteMenuOpened() + let expected = TimeInterval(UsageStore.adaptiveRefreshDecision( + now: Date(), + lastMenuOpenAt: store.lastMenuOpenAt, + lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled, + thermalState: ProcessInfo.processInfo.thermalState).delay.components.seconds) + #expect(store.normalRefreshIntervalForHeuristics() == expected) + if Self.machineIsUnconstrained { + #expect(store.normalRefreshIntervalForHeuristics() == 120.0) + } + } + + @Test + func `adaptive cadence schedules a reset-boundary refresh through the refresh pipeline`() async { + let store = Self.makeStoreWithStubbedCodex(suite: "heuristics-boundary-adaptive", frequency: .adaptive) + + // Goes through the real end-of-refresh scheduling call, which must feed the adaptive + // interval (30 min here — no menu open) rather than the nil `RefreshFrequency.seconds`. + await store.refresh() + defer { store.cancelResetBoundaryRefresh() } + + #expect(store.scheduledResetBoundaryRefreshAt != nil) + } + + @Test + func `manual cadence still never schedules a reset-boundary refresh through the refresh pipeline`() async { + let store = Self.makeStoreWithStubbedCodex(suite: "heuristics-boundary-manual", frequency: .manual) + + await store.refresh() + defer { store.cancelResetBoundaryRefresh() } + + #expect(store.scheduledResetBoundaryRefreshAt == nil) + } + + @Test + func `adaptive mode lifts the openai web refresh interval off the manual floor`() { + let adaptiveStore = Self.makeStore(suite: "heuristics-web-adaptive", frequency: .adaptive) + let manualStore = Self.makeStore(suite: "heuristics-web-manual", frequency: .manual) + + let adaptiveInterval = adaptiveStore.openAIWebRefreshIntervalSeconds() + let manualInterval = manualStore.openAIWebRefreshIntervalSeconds() + + // Manual hits the 120s fallback floor; adaptive with no menu open resolves to 1800s. + // Comparing as a ratio keeps this independent of the web-refresh multiplier. + #expect(manualInterval > 0) + #expect(adaptiveInterval == manualInterval * 15) + } + + @Test + func `registry nominal interval maps adaptive to the policy nominal and keeps manual nil`() { + #expect(ProviderRegistry.nominalRefreshInterval(for: .adaptive) + == AdaptiveRefreshPolicy.nominalIntervalForHeuristics) + #expect(ProviderRegistry.nominalRefreshInterval(for: .manual) == nil) + #expect(ProviderRegistry.nominalRefreshInterval(for: .thirtyMinutes) == 1800.0) + } + + @Test + func `provider specs give adaptive a nominal cli session idle window instead of the floor`() { + let adaptiveStore = Self.makeStore(suite: "heuristics-spec-adaptive", frequency: .adaptive) + let manualStore = Self.makeStore(suite: "heuristics-spec-manual", frequency: .manual) + + // Registry specs have no UsageStore to ask, so adaptive maps to the policy's nominal + // 300s steady-state interval: max(180, 300 + 60) = 360. + let adaptiveWindow = adaptiveStore.providerSpecs[.codex]? + .makeFetchContext().persistentCLISessionIdleWindow + let manualWindow = manualStore.providerSpecs[.codex]? + .makeFetchContext().persistentCLISessionIdleWindow + #expect(adaptiveWindow == 360) + #expect(manualWindow == 180) + } + + @Test + func `account-scoped fetch contexts derive the idle window from the live adaptive interval`() { + let adaptiveStore = Self.makeStore(suite: "heuristics-account-adaptive", frequency: .adaptive) + let manualStore = Self.makeStore(suite: "heuristics-account-manual", frequency: .manual) + + // Unlike registry specs, this path runs inside UsageStore, so adaptive uses the live + // decision: 1800s with no menu open, giving max(180, 1800 + 60) = 1860. + let adaptiveWindow = adaptiveStore + .makeFetchContext(provider: .codex, override: nil).persistentCLISessionIdleWindow + let manualWindow = manualStore + .makeFetchContext(provider: .codex, override: nil).persistentCLISessionIdleWindow + #expect(adaptiveWindow == 1860) + #expect(manualWindow == 180) + } + + private static var machineIsUnconstrained: Bool { + let thermalState = ProcessInfo.processInfo.thermalState + return !ProcessInfo.processInfo.isLowPowerModeEnabled + && (thermalState == .nominal || thermalState == .fair) + } + + private static func makeStore(suite: String, frequency: RefreshFrequency) -> UsageStore { + let settings = testSettingsStore(suiteName: "AdaptiveRefreshHeuristicsTests-\(suite)") + settings.providerDetectionCompleted = true + settings.refreshFrequency = frequency + Self.disableAllProviders(settings: settings) + return UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + } + + /// The reset-boundary pipeline tests need `refresh()` to complete with a snapshot still in + /// place, and `clearDisabledProviderRefreshState` wipes snapshots of disabled providers. So + /// codex stays enabled but its fetch is stubbed to return a canned snapshot whose primary + /// window resets 10 minutes out — inside a 30-minute normal-refresh window, outside nothing. + /// The live-system account is pinned and the snapshot carries the same email, so the + /// account-scoped apply guard resolves identically whether or not the machine running the + /// tests has a real `~/.codex` login (CI runners do not). + private static func makeStoreWithStubbedCodex(suite: String, frequency: RefreshFrequency) -> UsageStore { + let store = Self.makeStore(suite: suite, frequency: frequency) + let metadata = ProviderRegistry.shared.metadata[.codex]! + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: Self.stubbedCodexEmail, + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .unresolved) + store.settings.codexActiveSource = .liveSystem + store.settings.setProviderEnabled(provider: .codex, metadata: metadata, enabled: true) + store.providerSpecs[.codex] = CodexAccountScopedRefreshTests.makeCodexProviderSpec( + baseSpec: store.providerSpecs[.codex]!) + { + Self.snapshot(updatedAt: Date(), primaryResetsAt: Date().addingTimeInterval(10 * 60)) + } + return store + } + + private nonisolated static let stubbedCodexEmail = "adaptive-heuristics@example.com" + + /// Keeps `refresh()` cheap and deterministic: no provider fetch can replace the snapshot + /// injected by the reset-boundary tests or slow the pipeline tests down. + private static func disableAllProviders(settings: SettingsStore) { + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + guard let providerMetadata = metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: providerMetadata, enabled: false) + } + } + + private nonisolated static func snapshot(updatedAt: Date, primaryResetsAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: primaryResetsAt, + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: self.stubbedCodexEmail, + accountOrganization: nil, + loginMethod: "Pro")) + } +} diff --git a/Tests/CodexBarTests/AdaptiveRefreshPolicyTests.swift b/Tests/CodexBarTests/AdaptiveRefreshPolicyTests.swift new file mode 100644 index 0000000000..aefd92e890 --- /dev/null +++ b/Tests/CodexBarTests/AdaptiveRefreshPolicyTests.swift @@ -0,0 +1,125 @@ +import Foundation +import Testing +@testable import CodexBar + +struct AdaptiveRefreshPolicyTests { + private static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000) + + private func input( + ageSeconds: TimeInterval?, + lowPowerModeEnabled: Bool = false, + thermalState: ProcessInfo.ThermalState = .nominal) -> AdaptiveRefreshPolicy.Input + { + let lastMenuOpenAt = ageSeconds.map { Self.referenceNow.addingTimeInterval(-$0) } + return AdaptiveRefreshPolicy.Input( + now: Self.referenceNow, + lastMenuOpenAt: lastMenuOpenAt, + lowPowerModeEnabled: lowPowerModeEnabled, + thermalState: thermalState) + } + + @Test(arguments: [ + (-600.0, AdaptiveRefreshPolicy.Reason.recentInteraction, 120), + (0.0, .recentInteraction, 120), + (299.0, .recentInteraction, 120), + (300.0, .recentInteraction, 120), + (301.0, .warm, 300), + (3599.0, .warm, 300), + (3600.0, .warm, 300), + (3601.0, .idle, 900), + (14399.0, .idle, 900), + (14400.0, .longIdle, 1800), + (100_000.0, .longIdle, 1800) + ]) + func `age determines the table boundary`( + ageSeconds: TimeInterval, + expectedReason: AdaptiveRefreshPolicy.Reason, + expectedDelaySeconds: Int) + { + let decision = AdaptiveRefreshPolicy().nextDelay(for: self.input(ageSeconds: ageSeconds)) + #expect(decision.reason == expectedReason) + #expect(decision.delay == .seconds(expectedDelaySeconds)) + } + + @Test + func `nil last menu open is treated as long idle`() { + let decision = AdaptiveRefreshPolicy().nextDelay(for: self.input(ageSeconds: nil)) + #expect(decision.reason == .longIdle) + #expect(decision.delay == .seconds(30 * 60)) + } + + @Test + func `constrained wins even when no menu open is recorded`() { + let lowPower = AdaptiveRefreshPolicy().nextDelay(for: self.input( + ageSeconds: nil, + lowPowerModeEnabled: true)) + #expect(lowPower.reason == .constrained) + + let critical = AdaptiveRefreshPolicy().nextDelay(for: self.input( + ageSeconds: nil, + thermalState: .critical)) + #expect(critical.reason == .constrained) + } + + @Test + func `low power mode wins over recent interaction`() { + let decision = AdaptiveRefreshPolicy().nextDelay(for: self.input( + ageSeconds: 0, + lowPowerModeEnabled: true)) + #expect(decision.reason == .constrained) + #expect(decision.delay == .seconds(30 * 60)) + } + + @Test(arguments: [ProcessInfo.ThermalState.serious, .critical]) + func `serious and critical thermal states force the constrained branch`( + thermalState: ProcessInfo.ThermalState) + { + let decision = AdaptiveRefreshPolicy().nextDelay(for: self.input( + ageSeconds: 0, + thermalState: thermalState)) + #expect(decision.reason == .constrained) + #expect(decision.delay == .seconds(30 * 60)) + } + + @Test(arguments: [ProcessInfo.ThermalState.nominal, .fair]) + func `nominal and fair thermal states do not force the constrained branch`( + thermalState: ProcessInfo.ThermalState) + { + let decision = AdaptiveRefreshPolicy().nextDelay(for: self.input( + ageSeconds: 0, + thermalState: thermalState)) + #expect(decision.reason != .constrained) + #expect(decision.reason == .recentInteraction) + } + + @Test + func `future or clock-adjusted timestamps read as recent and never go negative`() { + let farFuture = self.input(ageSeconds: -1_000_000) + let decision = AdaptiveRefreshPolicy().nextDelay(for: farFuture) + #expect(decision.reason == .recentInteraction) + #expect(decision.delay == .seconds(2 * 60)) + #expect(decision.delay > .zero) + } + + @Test + func `every decision stays within the two to thirty minute bounds`() { + let ages: [TimeInterval?] = [ + nil, -1_000_000, -600, 0, 300, 301, 3600, 3601, 14399, 14400, 1_000_000, + ] + let lowPowerModes = [false, true] + let thermalStates: [ProcessInfo.ThermalState] = [.nominal, .fair, .serious, .critical] + + for age in ages { + for lowPowerModeEnabled in lowPowerModes { + for thermalState in thermalStates { + let decision = AdaptiveRefreshPolicy().nextDelay(for: self.input( + ageSeconds: age, + lowPowerModeEnabled: lowPowerModeEnabled, + thermalState: thermalState)) + #expect(decision.delay >= .seconds(2 * 60)) + #expect(decision.delay <= .seconds(30 * 60)) + } + } + } + } +} diff --git a/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift b/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift new file mode 100644 index 0000000000..84f3aedffe --- /dev/null +++ b/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift @@ -0,0 +1,315 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +/// Covers the timer plumbing added on top of the pure `AdaptiveRefreshPolicy` (see +/// `AdaptiveRefreshPolicyTests`): how `UsageStore.startTimer()` wires live signals into the +/// policy, and how manual/fixed/adaptive modes drive (or don't drive) `refresh()` over time. +@MainActor +struct AdaptiveRefreshTimerTests { + @Test + func `launch with no menu history begins at thirty minutes`() { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-launch", frequency: .adaptive) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) + + #expect(store.lastMenuOpenAt == nil) + let decision = UsageStore.adaptiveRefreshDecision( + now: Date(), + lastMenuOpenAt: store.lastMenuOpenAt, + lowPowerModeEnabled: false, + thermalState: .nominal) + #expect(decision.reason == .longIdle) + #expect(decision.delay == .seconds(30 * 60)) + } + + @Test + func `menu-open signal changes the next adaptive decision`() { + let now = Date(timeIntervalSinceReferenceDate: 900_000_000) + + let beforeOpen = UsageStore.adaptiveRefreshDecision( + now: now, lastMenuOpenAt: nil, lowPowerModeEnabled: false, thermalState: .nominal) + #expect(beforeOpen.reason == .longIdle) + + let afterOpen = UsageStore.adaptiveRefreshDecision( + now: now, lastMenuOpenAt: now, lowPowerModeEnabled: false, thermalState: .nominal) + #expect(afterOpen.reason == .recentInteraction) + } + + @Test + func `menu open advances a long idle timer during refresh without postponing an earlier tick`() async throws { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-advance", frequency: .adaptive) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) + store.restartTimerWithSleepOverrideForTesting(.seconds(10)) + try await Self.waitUntil { store.adaptiveRefreshScheduledAt != nil } + + let longIdleSchedule = try #require(store.adaptiveRefreshScheduledAt) + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(30), + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: nil), + provider: .codex) + store.scheduleResetBoundaryRefreshIfNeeded(normalRefreshInterval: 30 * 60, now: now) + defer { store.cancelResetBoundaryRefresh() } + let resetBoundarySchedule = try #require(store.scheduledResetBoundaryRefreshAt) + + store.isRefreshing = true + defer { store.isRefreshing = false } + store.noteMenuOpened() + try await Self.waitUntil { + guard let scheduledAt = store.adaptiveRefreshScheduledAt else { return false } + return scheduledAt < longIdleSchedule + } + let interactionSchedule = try #require(store.adaptiveRefreshScheduledAt) + #expect(store.isRefreshing) + #expect(store.scheduledResetBoundaryRefreshAt == resetBoundarySchedule) + + store.noteMenuOpened(at: Date().addingTimeInterval(30)) + #expect(store.adaptiveRefreshScheduledAt == interactionSchedule) + } + + @Test + func `noting a menu open records the signal without starting a refresh`() { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-noteMenuOpened", frequency: .manual) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) + + #expect(store.completedRefreshCountForTesting == 0) + #expect(store.isRefreshing == false) + + store.noteMenuOpened() + + #expect(store.lastMenuOpenAt != nil) + #expect(store.completedRefreshCountForTesting == 0) + #expect(store.isRefreshing == false) + } + + @Test + func `refresh call is a no-op while another refresh is already in flight`() async { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-coalesce", frequency: .manual) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) + + store.isRefreshing = true + await store.refresh() + + // The guard at the top of runRefresh() returned immediately: no completion was recorded and the + // flag was left untouched by this call. This is the invariant every timer tick (fixed or + // adaptive) relies on to avoid overlapping with a refresh already in flight. + #expect(store.completedRefreshCountForTesting == 0) + #expect(store.isRefreshing == true) + } + + @Test + func `manual mode performs the initial refresh but no recurring ticks`() async throws { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-manual", frequency: .manual) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) + try await Self.waitUntil { store.completedRefreshCountForTesting >= 1 } + + // Manual mode never starts a timer, so nothing can push the count past the one launch refresh + // no matter how long we wait; a short settle window is enough to catch a regression. + try await Task.sleep(for: .milliseconds(300)) + #expect(store.completedRefreshCountForTesting == 1) + } + + @Test + func `fixed mode ticks recur at the overridden cadence`() async throws { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-fixed", frequency: .oneMinute) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) + store.restartTimerWithSleepOverrideForTesting(.milliseconds(20)) + + // 1 initial launch refresh plus at least one 20ms-cadence tick; proves the loop recurs + // rather than sleeping once and stopping. Each refresh cycle here costs low single-digit + // seconds of wall time even with every provider disabled, so the timeout is generous. + try await Self.waitUntil(timeout: .seconds(45)) { store.completedRefreshCountForTesting >= 2 } + #expect(store.completedRefreshCountForTesting >= 2) + } + + @Test + func `adaptive mode keeps recomputing and refreshing across menu-open changes`() async throws { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-adaptive", frequency: .adaptive) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) + store.restartTimerWithSleepOverrideForTesting(.milliseconds(20)) + try await Self.waitUntil(timeout: .seconds(45)) { store.completedRefreshCountForTesting >= 1 } + let countBeforeMenuOpen = store.completedRefreshCountForTesting + + store.noteMenuOpened() + + // The loop kept looping (recomputing the decision from a fresh Input) after lastMenuOpenAt + // changed, rather than sleeping once on a captured delay and stopping. + try await Self.waitUntil(timeout: .seconds(45)) { store.completedRefreshCountForTesting > countBeforeMenuOpen } + #expect(store.completedRefreshCountForTesting > countBeforeMenuOpen) + } + + @Test + func `changing frequency away from fixed cancels the pending tick without an extra refresh`() async throws { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-cancel", frequency: .oneMinute) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) + // Deliberately much longer than anything else in this test: the assertion only needs this + // sleep to still be pending (uncompleted) when we switch away, not to time anything precisely. + store.restartTimerWithSleepOverrideForTesting(.seconds(5)) + // Only the initial launch refresh can land this quickly; the fixed-mode timer's first tick + // needs the full 5s override to elapse, so it cannot have fired yet. + try await Self.waitUntil { store.completedRefreshCountForTesting >= 1 } + let countBeforeSwitch = store.completedRefreshCountForTesting + + settings.refreshFrequency = .manual + + // The settings-change path (outside adaptive-refresh scope) may fire its own refresh(es) for + // reasons unrelated to the timer under test; wait for the count to stop moving rather than + // assuming it fires exactly once. Windows are doubled from an earlier version that flaked once + // under full parallel `make test` load. + let countAfterSettling = try await Self.waitForStableCount(store: store, settleWindow: .milliseconds(800)) + #expect(countAfterSettling > countBeforeSwitch) + + // Settle comfortably within the 5s override window. If the old fixed-mode timer had not been + // canceled, its pending tick would eventually land and push the count past the settled value — + // but not within this window, so any further increase here indicates a real cancellation bug, + // not settings-change noise. + try await Task.sleep(for: .milliseconds(1600)) + #expect(store.completedRefreshCountForTesting == countAfterSettling) + } + + // The test above goes through `settings.refreshFrequency = .manual`, which also triggers the + // settings-observer's own `refreshForSettingsChange()` — a legitimate refresh unrelated to the + // timer. That confound means it cannot, by itself, prove the `guard !Task.isCancelled else { return }` + // after each branch's sleep is load-bearing (deleting either guard still leaves this test green, + // since the settings-observer refresh already accounts for the "count increased" expectation). + // The two tests below isolate `startTimer()`'s cancel-and-replace path directly, by calling + // `restartTimerWithSleepOverrideForTesting` a second time at the *same* frequency — which goes + // straight through `startTimer()` with no settings observation involved — so no refresh is + // legitimately expected at all, and any extra one proves a canceled sleep still ran its body. + + @Test + func `restarting the timer cancels a pending fixed tick without an extra refresh`() async throws { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-cancel-fixed", frequency: .oneMinute) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) + store.restartTimerWithSleepOverrideForTesting(.seconds(5)) + try await Self.waitUntil { store.completedRefreshCountForTesting >= 1 } + let countBeforeRestart = store.completedRefreshCountForTesting + + // Cancels the pending 5s sleep above and starts a fresh one, still at .oneMinute. No settings + // mutation, so no settings-observer refresh is expected here at all. + store.restartTimerWithSleepOverrideForTesting(.seconds(5)) + + // Neither the old (canceled) timer's tick nor the new timer's first tick can land within this + // window — both need the full 5s override. Any refresh here can only be the canceled sleep's + // body running anyway. + try await Task.sleep(for: .milliseconds(800)) + #expect(store.completedRefreshCountForTesting == countBeforeRestart) + } + + @Test + func `restarting the timer cancels a pending adaptive tick without an extra refresh`() async throws { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-cancel-adaptive", frequency: .adaptive) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) + store.restartTimerWithSleepOverrideForTesting(.seconds(5)) + try await Self.waitUntil { store.completedRefreshCountForTesting >= 1 } + let countBeforeRestart = store.completedRefreshCountForTesting + + store.restartTimerWithSleepOverrideForTesting(.seconds(5)) + + try await Task.sleep(for: .milliseconds(800)) + #expect(store.completedRefreshCountForTesting == countBeforeRestart) + } + + /// Polls `condition` until it's true or `timeout` elapses, without assuming how long setup or + /// scheduling takes. Throws `CancellationError` (surfaced as a test failure) on timeout. + private static func waitUntil( + timeout: Duration = .seconds(30), + pollInterval: Duration = .milliseconds(20), + _ condition: () -> Bool) async throws + { + let deadline = ContinuousClock.now + timeout + while !condition() { + if ContinuousClock.now >= deadline { + throw CancellationError() + } + try await Task.sleep(for: pollInterval) + } + } + + /// Polls `store.completedRefreshCountForTesting` until it stops changing for `settleWindow`, + /// tolerating an unknown number of in-flight refreshes (e.g. settings-change side effects + /// unrelated to the timer under test) before returning the final, stable count. + private static func waitForStableCount( + store: UsageStore, + settleWindow: Duration, + timeout: Duration = .seconds(30), + pollInterval: Duration = .milliseconds(20)) async throws -> Int + { + let deadline = ContinuousClock.now + timeout + var lastCount = store.completedRefreshCountForTesting + var lastChangedAt = ContinuousClock.now + while true { + try await Task.sleep(for: pollInterval) + let current = store.completedRefreshCountForTesting + let now = ContinuousClock.now + if current != lastCount { + lastCount = current + lastChangedAt = now + } else if now - lastChangedAt >= settleWindow { + return lastCount + } + if now >= deadline { + throw CancellationError() + } + } + } + + private static func makeSettingsStore(suite: String, frequency: RefreshFrequency) -> SettingsStore { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + settings.refreshFrequency = frequency + Self.disableAllProviders(settings: settings) + return settings + } + + /// Codex is enabled by default; disabling every provider (including it) keeps `refresh()` cheap + /// and deterministic in these tests, which care about tick cadence, not provider fetch results. + private static func disableAllProviders(settings: SettingsStore) { + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + guard let providerMetadata = metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: providerMetadata, enabled: false) + } + } + + private static func makeUsageStore( + settings: SettingsStore, + startupBehavior: UsageStore.StartupBehavior) -> UsageStore + { + UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: startupBehavior, + environmentBase: [:]) + } +} diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index bc583c1ac9..7f9518ec9a 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -1675,6 +1675,36 @@ extension StatusMenuTests { accountOrganization: nil, loginMethod: "Plus Plan")) } + + /// The recent-interaction signal that `AdaptiveRefreshPolicy` reads has exactly one production + /// entry point: `StatusItemController.menuWillOpen(_:)` calling `store.noteMenuOpened()`. Every + /// other adaptive-refresh test drives `UsageStore` directly, so none of them would catch that + /// wiring line being deleted — this test drives the real menu-open path instead. + @Test + func `menuWillOpen records the menu-open signal on the store`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + #expect(store.lastMenuOpenAt == nil) + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + #expect(store.lastMenuOpenAt != nil) + } } private actor BlockingStatusMenuProviderRefresh { diff --git a/docs/refresh-loop.md b/docs/refresh-loop.md index 1426ce2425..d7384b662f 100644 --- a/docs/refresh-loop.md +++ b/docs/refresh-loop.md @@ -8,8 +8,9 @@ read_when: # Refresh loop ## Cadence -- `RefreshFrequency`: Manual, 1m, 2m, 5m (default), 15m, 30m. -- Stored in `UserDefaults` via `SettingsStore`. +- `RefreshFrequency`: Manual, 1m, 2m, 5m (default), 15m, 30m, Adaptive. +- Stored in `UserDefaults` via `SettingsStore`. The default stays 5m for new and existing users; nothing + auto-migrates an existing fixed selection to Adaptive. ## Behavior - Background refresh runs off-main and updates `UsageStore` (usage + credits + optional web scrape). @@ -19,6 +20,35 @@ read_when: background, coalesced/throttled during automatic refreshes, and forced by manual refresh without blocking the usage refresh path. +## Adaptive mode +- `AdaptiveRefreshPolicy` (`Sources/CodexBar/AdaptiveRefreshPolicy.swift`) is a pure function of an `Input` + (current time, last menu-open time, Low Power Mode, thermal state) that returns the next delay and a + stable `Reason`. It reads no clock and no `ProcessInfo` state itself — `UsageStore.startTimer()` gathers + those impure signals immediately before each tick. +- Policy table, first match wins: + + | Condition | Delay | Reason | + |---|---:|---| + | Low Power Mode enabled, or thermal state `.serious`/`.critical` | 30 min | `constrained` | + | Menu opened within the last 5 min (including future/clock-adjusted timestamps) | 2 min | `recentInteraction` | + | Menu opened 5 min–1 h ago | 5 min | `warm` | + | Menu opened 1–4 h ago | 15 min | `idle` | + | No recorded menu open, or opened 4+ h ago | 30 min | `longIdle` | + +- Every decision falls in the 2–30 min range by construction. Deliberately excludes quota, latency, error, + account, and time-of-day signals. +- `UsageStore` tracks `lastMenuOpenAt` in memory only (never persisted; resets on launch). `noteMenuOpened(at:)` + is called from `StatusItemController.menuWillOpen(_:)`. A menu open can bring a pending adaptive tick + forward to the recent-interaction cadence, but never postpones an earlier tick or refreshes synchronously. +- Each adaptive tick recomputes the delay after the previous refresh completes, sleeps, then calls the same + `UsageStore.refresh()` used by fixed-interval mode, so the existing `isRefreshing` coalescing guard still + applies — only one provider-batch refresh runs at a time regardless of cadence mode. +- Selected delay and reason are logged (e.g. `reason=warm delay=300s` in the `adaptive-refresh` category) through + the existing local logger; never provider identity, account, email, workspace, path, credentials, or response data. +- Interval-derived heuristics (reset-boundary refresh, OpenAI web staleness, persistent-CLI-session idle windows) + read `UsageStore.normalRefreshIntervalForHeuristics()`, which resolves adaptive mode to the current decision's + delay — they stay active in adaptive mode rather than degrading to manual, whose interval is nil. + ## Optional future - Auto-seed a log if none exists via `codex exec --skip-git-repo-check --json "ping"` (currently not executed).