diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c8bc22515..1c9fde4ded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Codex: avoid repeatedly converting historical token snapshots during cost-cache refreshes, preventing sustained CPU usage on large session histories. - Codex: make automatic cost-history catch-up near-idle and limit local-history scans to provider refreshes with a 15-minute energy floor. - Agent Sessions: stop inactive local and remote refresh schedulers from waking while monitoring is disabled. +- Cost usage: refresh token-cost data when “Refresh all providers on menu open” is enabled, with a one-minute scan floor to avoid repeated work (#2388). Thanks @betive37! ## 0.49.1 — 2026-08-09 diff --git a/Sources/CodexBar/MenuOpenRefreshPlan.swift b/Sources/CodexBar/MenuOpenRefreshPlan.swift index a59e87e57d..b43a8d1a16 100644 --- a/Sources/CodexBar/MenuOpenRefreshPlan.swift +++ b/Sources/CodexBar/MenuOpenRefreshPlan.swift @@ -18,13 +18,15 @@ struct MenuOpenRefreshPlan: Equatable { let providers: [ProviderInstanceID] let scheduling: Scheduling let refreshCodexDashboard: Bool + let refreshTokenCost: Bool static func resolve(_ inputs: Inputs) -> Self { if inputs.refreshAllOnOpen { return Self( providers: inputs.enabledProviders, scheduling: .concurrent, - refreshCodexDashboard: inputs.enabledProviders.contains(.codex)) + refreshCodexDashboard: inputs.enabledProviders.contains(.codex), + refreshTokenCost: !inputs.enabledProviders.isEmpty) } let enabled = Set(inputs.enabledProviders) @@ -36,6 +38,7 @@ struct MenuOpenRefreshPlan: Equatable { return Self( providers: providers, scheduling: .sequential, - refreshCodexDashboard: false) + refreshCodexDashboard: false, + refreshTokenCost: false) } } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index a0780767b5..3278130d46 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1140,7 +1140,9 @@ extension StatusItemController { // feel frozen and can block keyboard focus from returning. // Exception: when `refreshAllProvidersOnMenuOpen` is enabled, every enabled provider is refreshed on // open regardless of freshness — still after the delay below, and still via the light usage-only - // primitive so the OpenAI dashboard scrape stays deferred until the menu closes. + // primitive so the OpenAI dashboard scrape stays deferred until the menu closes. Token cost is not + // part of that primitive, so the plan also schedules a forced cost rescan (fire-and-forget; the + // open menu picks up the published snapshot through the store observation). let providersNeedingRetryAtOpen = self.delayedRefreshRetryProviders(for: menu).filter { self.store.needsUsageRefreshRetry(for: $0) } @@ -1174,6 +1176,9 @@ extension StatusItemController { if plan.refreshCodexDashboard { self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "refresh all") } + if plan.refreshTokenCost { + self.store.scheduleForcedTokenRefresh() + } let retryInstanceIDs = plan.providers let retryProviders = retryInstanceIDs.compactMap(\.firstPartyProvider) guard !retryInstanceIDs.isEmpty else { diff --git a/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift b/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift index 153433f0c1..ac7d11e8ea 100644 --- a/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift +++ b/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift @@ -6,16 +6,55 @@ extension UsageStore { case all case provider(ProviderInstanceID) case providers([ProviderInstanceID]) + + var coversAllProviders: Bool { + if case .all = self { + return true + } + return false + } } func scheduleTokenRefresh() { guard self.tokenRefreshSequenceTask == nil, !self.hasForcedRefreshEnrichmentInFlight else { return } + if self.startPendingForcedTokenRefreshIfPossible() { + return + } if self.startPendingTokenRefreshRetryIfPossible() { return } self.startTokenRefreshSequence(force: false, scope: .all) } + /// Minimum spacing between forced all-provider cost scans. Menu open may bypass the fetch TTL, + /// but rapid open/close cycles must not hammer the scanner more than once a minute. + static let forcedTokenRefreshMinInterval: TimeInterval = 60 + + /// Menu-open parity with the manual Refresh action: cost must rescan past the fetch TTL, but + /// without awaiting (AppKit menu tracking is modal) and without preempting an in-flight + /// sequence or forced-refresh enrichment tail. The enrichment tail and a forced all-provider + /// pass already end in fresh cost data, so re-requests coalesce into them. Any other active + /// sequence may skip TTL-fresh providers, so the request stays pending and one forced pass + /// runs once that sequence completes. The TTL bypass is floored: a forced pass that started + /// less than `forcedTokenRefreshMinInterval` ago already delivered fresh cost data, so the + /// request is dropped instead of queued. + func scheduleForcedTokenRefresh(now: Date = Date()) { + if let last = self.lastForcedTokenRefreshStartedAt, + now.timeIntervalSince(last) >= 0, + now.timeIntervalSince(last) < Self.forcedTokenRefreshMinInterval + { + return + } + guard !self.hasForcedRefreshEnrichmentInFlight else { return } + if self.tokenRefreshSequenceTask != nil { + if !self.tokenRefreshSequenceIsForcedAllPass { + self.pendingForcedTokenRefresh = true + } + return + } + self.startTokenRefreshSequence(force: true, scope: .all) + } + func refreshTokenUsageSequenceNow(force: Bool) async { guard let task = await self.serializedTokenRefreshTask(force: force, scope: .all) else { return } await self.awaitTokenRefreshSequence(task) @@ -74,6 +113,13 @@ extension UsageStore { // Publish the first owner before installing the task. A scoped forced refresh can arrive // before the task gets its first MainActor turn and must not mistake this slot for unknown work. self.tokenRefreshSequenceProvider = providers.first + self.tokenRefreshSequenceIsForcedAllPass = force && scope.coversAllProviders + if self.tokenRefreshSequenceIsForcedAllPass { + // A forced all-provider pass delivers everything a coalesced menu-open request wants. + self.pendingForcedTokenRefresh = false + // Manual Refresh lands here too, so a menu open right after it also honors the floor. + self.lastForcedTokenRefreshStartedAt = Date() + } let task = Task(priority: .utility) { @MainActor [weak self] in guard let self else { return } await self.refreshTokenUsageSequence(providers: providers, force: force) @@ -96,9 +142,27 @@ extension UsageStore { self.tokenRefreshSequenceTask = nil self.tokenRefreshSequenceToken = nil self.tokenRefreshSequenceProvider = nil + self.tokenRefreshSequenceIsForcedAllPass = false + if self.startPendingForcedTokenRefreshIfPossible() { + return + } self.startPendingTokenRefreshRetryIfPossible() } + /// A cancelled sequence lost the slot to a serialized replacement pass, so the request stays + /// pending until a sequence completes normally; a forced all-provider replacement clears it + /// on start instead. + @discardableResult + private func startPendingForcedTokenRefreshIfPossible() -> Bool { + guard self.pendingForcedTokenRefresh, !Task.isCancelled else { return false } + self.pendingForcedTokenRefresh = false + guard !self.hasForcedRefreshEnrichmentInFlight else { return false } + // The forced all-provider pass rescans every enabled lane, so stale-retry lanes fold into it. + self.tokenRefreshRetryProviders.subtract(self.enabledProvidersForBackgroundWork()) + self.startTokenRefreshSequence(force: true, scope: .all) + return true + } + func requestTokenRefreshAfterStaleCompletion(for provider: UsageProvider) { self.tokenRefreshRetryProviders.insert(provider.instanceID) Task { @MainActor [weak self] in diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 6c466d0802..69ea0c6d22 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -344,6 +344,9 @@ final class UsageStore { @ObservationIgnored var tokenRefreshSequenceTask: Task? @ObservationIgnored var tokenRefreshSequenceToken: UUID? @ObservationIgnored var tokenRefreshSequenceProvider: ProviderInstanceID? + @ObservationIgnored var tokenRefreshSequenceIsForcedAllPass = false + @ObservationIgnored var pendingForcedTokenRefresh = false + @ObservationIgnored var lastForcedTokenRefreshStartedAt: Date? @ObservationIgnored var tokenRefreshRetryProviders: Set = [] @ObservationIgnored var codexCostCatchUpTask: Task? @ObservationIgnored var codexCostCatchUpToken: UUID? diff --git a/Tests/CodexBarTests/MenuOpenRefreshPlanTests.swift b/Tests/CodexBarTests/MenuOpenRefreshPlanTests.swift index baa49b9d82..8017118906 100644 --- a/Tests/CodexBarTests/MenuOpenRefreshPlanTests.swift +++ b/Tests/CodexBarTests/MenuOpenRefreshPlanTests.swift @@ -16,6 +16,7 @@ struct MenuOpenRefreshPlanTests { #expect(plan.providers == [.codex, .claude, .factory]) #expect(plan.scheduling == .concurrent) #expect(plan.refreshCodexDashboard) + #expect(plan.refreshTokenCost) } @Test @@ -30,6 +31,21 @@ struct MenuOpenRefreshPlanTests { #expect(plan.providers == [.claude, .factory]) #expect(!plan.refreshCodexDashboard) + #expect(plan.refreshTokenCost) + } + + @Test + func `refresh all skips token cost refresh when no providers are enabled`() { + let plan = MenuOpenRefreshPlan.resolve(.init( + refreshAllOnOpen: true, + enabledProviders: [], + visibleProviders: [], + refreshingProviders: [], + staleProviders: [], + missingProviders: [])) + + #expect(plan.providers.isEmpty) + #expect(!plan.refreshTokenCost) } @Test @@ -45,6 +61,7 @@ struct MenuOpenRefreshPlanTests { #expect(plan.providers == [.factory, .codex, .claude]) #expect(plan.scheduling == .sequential) #expect(!plan.refreshCodexDashboard) + #expect(!plan.refreshTokenCost) } @Test diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 2f457e0aa0..5365ec3895 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1275,19 +1275,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "Claude widget quota ownership uses the selected Claude account's isolated snapshot key."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1042, + line: 1045, anchor: "provider: .deepseek,", expectedProviderIDs: ["deepseek"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1144, + line: 1147, anchor: "let sourceMode = self.sourceMode(for: .claude)", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1148, + line: 1151, anchor: "provider: .claude,", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -2099,8 +2099,8 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuOpenRefreshPlan.swift", - line: 27, - anchor: "refreshCodexDashboard: inputs.enabledProviders.contains(.codex))", + line: 28, + anchor: "refreshCodexDashboard: inputs.enabledProviders.contains(.codex),", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, expectedReferenceFingerprint: ["codex@0"], @@ -3219,7 +3219,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 583, + line: 586, anchor: "self.metadata(for: .codex).browserCookieOrder ?? Browser.defaultImportOrder", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3227,7 +3227,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 635, + line: 638, anchor: "self.providerSpecs[provider]?.style ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3235,7 +3235,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 668, + line: 671, anchor: "guard provider != .codex else { return true }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3243,7 +3243,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1016, + line: 1019, anchor: "let claudeDebugConfiguration: ClaudeDebugLogConfiguration? = if provider == .claude {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3251,7 +3251,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1039, + line: 1042, anchor: "let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, @@ -3259,7 +3259,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1096, + line: 1099, anchor: "case .amp:", expectedProviderIDs: ["amp", "deepseek", "notion", "ollama", "warp"], expectedReferenceCount: 7, @@ -3275,7 +3275,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1151, + line: 1154, anchor: "let claudeSettings = snapshot.claude ?? ProviderSettingsSnapshot.ClaudeProviderSettings(", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift b/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift index a034e15fd1..870c887e66 100644 --- a/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift +++ b/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift @@ -295,16 +295,163 @@ struct UsageStoreManualTokenRefreshTests { } await store.refresh(forceTokenUsage: false) + await gate.waitForStart() #expect(await gate.hasFinished() == false) await gate.release() - try? await Task.sleep(for: .milliseconds(50)) + await gate.waitForFinish() let calls = await gate.calls - if !calls.isEmpty { - #expect(calls.map(\.provider) == [.codex]) - #expect(calls.map(\.force) == [false]) - #expect(await gate.hasFinished()) + #expect(calls.map(\.provider) == [.codex]) + #expect(calls.map(\.force) == [false]) + } + + @Test + func `menu open cost refresh schedules a forced token rescan without waiting`() async { + let store = Self.makeStore() + let recorder = TokenRefreshRecorder() + store._test_tokenUsageRefreshOverride = { provider, force in + await recorder.record(provider: provider, force: force) } + + store.scheduleForcedTokenRefresh() + + let didRecord = await recorder.waitForCallCount(1) + #expect(didRecord) + await store.tokenRefreshSequenceTask?.value + #expect(await recorder.calls.map(\.provider) == [.codex]) + #expect(await recorder.calls.map(\.force) == [true]) + } + + @Test + func `menu open cost refresh queues one forced pass behind a running token sequence`() async { + let store = Self.makeStore() + let gate = TokenRefreshGate() + let recorder = TokenRefreshRecorder() + store._test_tokenUsageRefreshOverride = { provider, force in + await recorder.record(provider: provider, force: force) + if !force { + await gate.start(provider: provider, force: force) + await gate.waitForRelease() + await gate.finish() + } + } + + store.scheduleTokenRefreshForTesting() + await gate.waitForStart() + + // Re-entry while the scheduled sequence is blocked must not preempt it, and the two + // requests must coalesce into a single pending forced pass. + store.scheduleForcedTokenRefresh() + store.scheduleForcedTokenRefresh() + #expect(await recorder.calls.count == 1) + + await gate.release() + let didRunForcedFollowUp = await recorder.waitForCallCount(2) + #expect(didRunForcedFollowUp) + await store.tokenRefreshSequenceTask?.value + try? await Task.sleep(for: .milliseconds(50)) + + #expect(await recorder.calls.map(\.force) == [false, true]) + #expect(await recorder.calls.map(\.provider) == [.codex, .codex]) + } + + @Test + func `menu open cost refresh coalesces into an active forced pass`() async { + let store = Self.makeStore() + let gate = TokenRefreshGate() + store._test_tokenUsageRefreshOverride = { provider, force in + await gate.start(provider: provider, force: force) + await gate.waitForRelease() + await gate.finish() + } + + store.scheduleForcedTokenRefresh() + await gate.waitForStart() + store.scheduleForcedTokenRefresh() + + await gate.release() + await store.tokenRefreshSequenceTask?.value + try? await Task.sleep(for: .milliseconds(50)) + + #expect(await gate.calls.count == 1) + #expect(await gate.calls.map(\.force) == [true]) + } + + @Test + func `menu open cost refresh drops requests within the forced-scan floor`() async { + let store = Self.makeStore() + let recorder = TokenRefreshRecorder() + store._test_tokenUsageRefreshOverride = { provider, force in + await recorder.record(provider: provider, force: force) + } + + store.scheduleForcedTokenRefresh() + let didRecord = await recorder.waitForCallCount(1) + #expect(didRecord) + await store.tokenRefreshSequenceTask?.value + + // Reopening the menu right after a forced scan must not start another one. + store.scheduleForcedTokenRefresh() + try? await Task.sleep(for: .milliseconds(50)) + #expect(store.tokenRefreshSequenceTask == nil) + #expect(store.pendingForcedTokenRefresh == false) + #expect(await recorder.calls.count == 1) + } + + @Test + func `menu open cost refresh runs again once the forced-scan floor elapses`() async { + let store = Self.makeStore() + let recorder = TokenRefreshRecorder() + store._test_tokenUsageRefreshOverride = { provider, force in + await recorder.record(provider: provider, force: force) + } + + store.scheduleForcedTokenRefresh() + let didRecord = await recorder.waitForCallCount(1) + #expect(didRecord) + await store.tokenRefreshSequenceTask?.value + + store.lastForcedTokenRefreshStartedAt = + Date(timeIntervalSinceNow: -(UsageStore.forcedTokenRefreshMinInterval + 1)) + store.scheduleForcedTokenRefresh() + + let didRunSecondPass = await recorder.waitForCallCount(2) + #expect(didRunSecondPass) + await store.tokenRefreshSequenceTask?.value + #expect(await recorder.calls.map(\.force) == [true, true]) + } + + @Test + func `menu open cost refresh within the floor does not queue behind a running sequence`() async { + let store = Self.makeStore() + let gate = TokenRefreshGate() + let recorder = TokenRefreshRecorder() + store._test_tokenUsageRefreshOverride = { provider, force in + await recorder.record(provider: provider, force: force) + if !force { + await gate.start(provider: provider, force: force) + await gate.waitForRelease() + await gate.finish() + } + } + + store.scheduleForcedTokenRefresh() + let didRecord = await recorder.waitForCallCount(1) + #expect(didRecord) + await store.tokenRefreshSequenceTask?.value + + store.scheduleTokenRefreshForTesting() + await gate.waitForStart() + + // The scheduled sequence is in flight, but the floor drops the request before it can queue. + store.scheduleForcedTokenRefresh() + #expect(store.pendingForcedTokenRefresh == false) + + await gate.release() + await store.tokenRefreshSequenceTask?.value + try? await Task.sleep(for: .milliseconds(50)) + + #expect(await recorder.calls.map(\.force) == [true, false]) } @Test