diff --git a/CHANGELOG.md b/CHANGELOG.md index 6502750bd5..cc601732ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Plugins: honor the “Usage bars fill” remaining/used setting in user-installed provider cards, keeping percentage labels and bar direction aligned (#2749). Thanks @RyloRiz! - Sub2API: localize and group menu-card quota labels and request, token, and cost totals into a compact usage summary (#2835). Thanks @weirdo-adam! - 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. ## 0.49.1 — 2026-08-09 diff --git a/Sources/CodexBar/CodexCostCatchUpPolicy.swift b/Sources/CodexBar/CodexCostCatchUpPolicy.swift index 933d5efc92..f3eabd8020 100644 --- a/Sources/CodexBar/CodexCostCatchUpPolicy.swift +++ b/Sources/CodexBar/CodexCostCatchUpPolicy.swift @@ -105,9 +105,9 @@ struct CodexCostCatchUpPolicy: Sendable { } let dutyCycle = switch input.powerSource { - case .ac: 0.20 - case .battery: 0.05 - case .unknown: 0.15 + case .ac: 0.001 + case .battery: 0.0002 + case .unknown: 0.0005 } let activeDuration = max(0, input.previousActiveDuration ?? Self.automaticBurstDuration) let delay = activeDuration * (1 - dutyCycle) / dutyCycle diff --git a/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift b/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift index 8b465d26cb..153433f0c1 100644 --- a/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift +++ b/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift @@ -8,21 +8,6 @@ extension UsageStore { case providers([ProviderInstanceID]) } - func startTokenTimer() { - self.tokenTimerTask?.cancel() - guard let wait = self.tokenFetchTTL else { return } - self.tokenTimerTask = Task.detached(priority: .utility) { [weak self] in - while !Task.isCancelled { - do { - try await Task.sleep(for: .seconds(wait)) - } catch { - return - } - await self?.scheduleTokenRefresh() - } - } - } - func scheduleTokenRefresh() { guard self.tokenRefreshSequenceTask == nil, !self.hasForcedRefreshEnrichmentInFlight else { return } if self.startPendingTokenRefreshRetryIfPossible() { @@ -43,7 +28,7 @@ extension UsageStore { activeProvider != provider.instanceID { // A scoped user refresh can run beside unrelated scheduled work. The scheduled - // sequence still owns the shared slot, so the timer cannot introduce a third pass. + // sequence still owns the shared slot, so provider refreshes cannot introduce a third pass. await self.refreshTokenUsage(provider, force: true) self.scheduleMemoryPressureRelief() return diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 945afdbeff..6c466d0802 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -73,7 +73,6 @@ extension UsageStore { self.probeLogs = [:] guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } self.startTimer() - self.startTokenTimer() self.updateProviderRuntimes() let enabledNow = Set(self.settings.enabledProvidersOrdered( metadataByProvider: self.providerMetadata)) @@ -342,7 +341,6 @@ final class UsageStore { /// In-memory only; paths and session identities never enter the refresh policy. @ObservationIgnored private(set) var lastCodingActivityAt: Date? @ObservationIgnored var adaptiveRefreshScheduledAt: Date? - @ObservationIgnored var tokenTimerTask: Task? @ObservationIgnored var tokenRefreshSequenceTask: Task? @ObservationIgnored var tokenRefreshSequenceToken: UUID? @ObservationIgnored var tokenRefreshSequenceProvider: ProviderInstanceID? @@ -424,10 +422,9 @@ final class UsageStore { @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false @ObservationIgnored private let providerAvailabilityCacheTTL: TimeInterval = 1 @ObservationIgnored let accountInfoCacheTTL: TimeInterval = 30 - /// Token scans can cause an additional widget snapshot publication. Keep the shortest automatic - /// cadence at five minutes so one- and two-minute provider refreshes do not exhaust WidgetKit's - /// reload budget or repeatedly traverse large local histories. - static let minimumTokenFetchTTL: TimeInterval = 5 * 60 + /// Energy/WidgetKit floor for expensive local-history scans and their additional snapshot publications. + /// Faster provider refreshes still update quota/status normally, but reuse token-cost history within this TTL. + static let minimumTokenFetchTTL: TimeInterval = 15 * 60 var tokenFetchTTL: TimeInterval? { Self.tokenFetchTTL( @@ -540,7 +537,6 @@ final class UsageStore { } Task { await self.refresh(enrichmentMode: .automatic) } self.startTimer() - self.startTokenTimer() } var iconStyle: IconStyle { @@ -929,7 +925,6 @@ final class UsageStore { deinit { self.timerTask?.cancel() - self.tokenTimerTask?.cancel() self.tokenRefreshSequenceTask?.cancel() self.codexCostCatchUpTask?.cancel() self.forcedRefreshEnrichmentTask?.cancel() diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift index 826d1317c3..62c69c045d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift @@ -1,5 +1,189 @@ import Foundation +struct CostUsageClaudeFileStamp: Equatable, Sendable { + let fileID: String + let size: Int64 + let modifiedSeconds: Int64 + let modifiedNanoseconds: Int64 + + var mtimeUnixMs: Int64 { + self.modifiedSeconds * 1000 + self.modifiedNanoseconds / 1_000_000 + } + + static func read(at url: URL) -> Self? { + var info = stat() + guard url.path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { return nil } + guard info.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG) else { return nil } + #if os(Linux) + let modifiedSeconds = Int64(info.st_mtim.tv_sec) + let modifiedNanoseconds = Int64(info.st_mtim.tv_nsec) + #else + let modifiedSeconds = Int64(info.st_mtimespec.tv_sec) + let modifiedNanoseconds = Int64(info.st_mtimespec.tv_nsec) + #endif + return Self( + fileID: "\(info.st_dev):\(info.st_ino)", + size: Int64(info.st_size), + modifiedSeconds: modifiedSeconds, + modifiedNanoseconds: modifiedNanoseconds) + } +} + +struct CostUsageClaudeReportMemoKey: Equatable, Sendable { + let provider: UsageProvider + let providerFilter: String + let sinceKey: String + let untilKey: String + let scanSinceKey: String + let scanUntilKey: String + let timeZoneIdentifier: String + let roots: [String] + let cacheArtifactStamp: CostUsageClaudeFileStamp? + let pricingArtifactStamp: CostUsageClaudeFileStamp? + + var scanConfiguration: ScanConfiguration { + ScanConfiguration( + provider: self.provider, + providerFilter: self.providerFilter, + timeZoneIdentifier: self.timeZoneIdentifier, + roots: self.roots) + } + + struct ScanConfiguration: Equatable, Sendable { + let provider: UsageProvider + let providerFilter: String + let timeZoneIdentifier: String + let roots: [String] + } +} + +final class CostUsageClaudeReportMemo: @unchecked Sendable { + struct Entry { + let sourceInventory: [String: CostUsageClaudeFileStamp] + let reportKey: CostUsageClaudeReportMemoKey + let report: CostUsageDailyReport + } + + static let shared = CostUsageClaudeReportMemo() + + private struct StoredEntry { + let entry: Entry + let generation: UInt64 + } + + private let lock = NSLock() + private let capacity = 8 + private var generation: UInt64 = 0 + private var entries: [String: StoredEntry] = [:] + + func entry(provider: UsageProvider, canonicalCachePath: String) -> Entry? { + let key = Self.key(provider: provider, canonicalCachePath: canonicalCachePath) + self.lock.lock() + defer { self.lock.unlock() } + return self.entries[key]?.entry + } + + func store( + provider: UsageProvider, + canonicalCachePath: String, + sourceInventory: [String: CostUsageClaudeFileStamp], + reportKey: CostUsageClaudeReportMemoKey, + report: CostUsageDailyReport) + { + let key = Self.key(provider: provider, canonicalCachePath: canonicalCachePath) + self.lock.lock() + defer { self.lock.unlock() } + self.generation &+= 1 + self.entries[key] = StoredEntry( + entry: Entry(sourceInventory: sourceInventory, reportKey: reportKey, report: report), + generation: self.generation) + if self.entries.count > self.capacity, + let oldest = self.entries.min(by: { $0.value.generation < $1.value.generation })?.key + { + self.entries.removeValue(forKey: oldest) + } + } + + #if DEBUG + func evict(provider: UsageProvider, canonicalCachePath: String) { + let key = Self.key(provider: provider, canonicalCachePath: canonicalCachePath) + self.lock.lock() + defer { self.lock.unlock() } + self.entries.removeValue(forKey: key) + } + #endif + + private static func key(provider: UsageProvider, canonicalCachePath: String) -> String { + "\(provider.rawValue)|\(canonicalCachePath)" + } +} + +#if DEBUG +extension CostUsageScanner { + enum ClaudeScanWork: Sendable { + case cacheDecode + case transcriptParse + case reconcile + case cacheEncode + case reprice + } + + struct ClaudeScanWorkMetrics: Equatable, Sendable { + var cacheDecodes = 0 + var transcriptParses = 0 + var reconciliations = 0 + var cacheEncodes = 0 + var repricedRows = 0 + } + + final class ClaudeScanWorkRecorder: @unchecked Sendable { + private let lock = NSLock() + private var metrics = ClaudeScanWorkMetrics() + + func record(_ work: ClaudeScanWork) { + self.lock.lock() + defer { self.lock.unlock() } + switch work { + case .cacheDecode: self.metrics.cacheDecodes += 1 + case .transcriptParse: self.metrics.transcriptParses += 1 + case .reconcile: self.metrics.reconciliations += 1 + case .cacheEncode: self.metrics.cacheEncodes += 1 + case .reprice: self.metrics.repricedRows += 1 + } + } + + func snapshot() -> ClaudeScanWorkMetrics { + self.lock.lock() + defer { self.lock.unlock() } + return self.metrics + } + } + + @TaskLocal private static var claudeScanWorkRecorder: ClaudeScanWorkRecorder? + + static func withClaudeScanWorkRecorderForTesting( + _ recorder: ClaudeScanWorkRecorder, + operation: () throws -> T) rethrows -> T + { + try self.$claudeScanWorkRecorder.withValue(recorder) { + try operation() + } + } + + static func recordClaudeScanWork(_ work: ClaudeScanWork) { + self.claudeScanWorkRecorder?.record(work) + } + + static func evictClaudeReportMemoForTesting(provider: UsageProvider, cacheRoot: URL?) { + let cacheURL = CostUsageClaudeCacheIO.cacheFileURL(provider: provider, cacheRoot: cacheRoot) + let canonicalCachePath = cacheURL.standardizedFileURL.resolvingSymlinksInPath().path + CostUsageClaudeReportMemo.shared.evict( + provider: provider, + canonicalCachePath: canonicalCachePath) + } +} +#endif + /// Claude and Vertex retain their small transcript cache. Codex deliberately has no route /// through this JSON I/O boundary; its only persistence authority is `CostUsageStore`. enum CostUsageClaudeCacheIO { @@ -25,8 +209,11 @@ enum CostUsageClaudeCacheIO { calendar: Calendar? = nil) -> CostUsageCache { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) - guard let data = try? Data(contentsOf: url), - let cache = try? JSONDecoder().decode(CostUsageCache.self, from: data), + guard let data = try? Data(contentsOf: url) else { return CostUsageCache() } + #if DEBUG + CostUsageScanner.recordClaudeScanWork(.cacheDecode) + #endif + guard let cache = try? JSONDecoder().decode(CostUsageCache.self, from: data), cache.version == 1 else { return CostUsageCache() } if let calendar, cache.timeZoneIdentifier != calendar.timeZone.identifier { @@ -39,15 +226,34 @@ enum CostUsageClaudeCacheIO { provider: UsageProvider, cache: CostUsageCache, cacheRoot: URL? = nil, - calendar: Calendar = .current) + calendar: Calendar = .current, + checkCancellation: CostUsageScanner.CancellationCheck? = nil) throws -> CostUsageClaudeFileStamp? { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) - try? FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) var cache = cache cache.timeZoneIdentifier = calendar.timeZone.identifier - guard let data = try? JSONEncoder().encode(cache) else { return } - try? data.write(to: url, options: [.atomic]) + #if DEBUG + CostUsageScanner.recordClaudeScanWork(.cacheEncode) + #endif + guard let data = try? JSONEncoder().encode(cache) else { return nil } + try checkCancellation?() + let directory = url.deletingLastPathComponent() + try? FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true) + let temporaryURL = directory.appendingPathComponent(".claude-cache-\(UUID().uuidString).tmp") + do { + try data.write(to: temporaryURL) + guard let stamp = CostUsageClaudeFileStamp.read(at: temporaryURL), + rename(temporaryURL.path, url.path) == 0 + else { + try? FileManager.default.removeItem(at: temporaryURL) + return nil + } + return stamp + } catch { + try? FileManager.default.removeItem(at: temporaryURL) + return nil + } } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index d7d5ce462c..bf17734c86 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -335,6 +335,9 @@ extension CostUsageScanner { } private static func reconciledClaudeRows(cache: CostUsageCache) -> [ClaudeUsageRow] { + #if DEBUG + recordClaudeScanWork(.reconcile) + #endif var rows: [ClaudeUsageRow] = [] var winners: [String: (path: String, row: ClaudeUsageRow)] = [:] @@ -535,13 +538,47 @@ extension CostUsageScanner { return [rootPath] } + private final class ClaudeModelsDevCatalogResolver { + private let now: Date + private let cacheRoot: URL? + private var catalog: ModelsDevCatalog? + + init(now: Date, cacheRoot: URL?) { + self.now = now + self.cacheRoot = cacheRoot + } + + func resolve() -> ModelsDevCatalog { + if let catalog = self.catalog { + return catalog + } + let catalog = CostUsagePricing.modelsDevCatalog(now: self.now, cacheRoot: self.cacheRoot) + ?? ModelsDevCatalog(providers: [:]) + self.catalog = catalog + return catalog + } + } + + private struct ClaudeSourceFile { + let url: URL + let stamp: CostUsageClaudeFileStamp + } + + private struct ClaudeSourceInventory { + var files: [String: ClaudeSourceFile] = [:] + + var stamps: [String: CostUsageClaudeFileStamp] { + self.files.mapValues(\.stamp) + } + } + private final class ClaudeScanState { var cache: CostUsageCache - var touched: Set let range: CostUsageDayRange let providerFilter: ClaudeLogProviderFilter let forceFullScan: Bool - let modelsDevCatalog: ModelsDevCatalog? + let changedPaths: Set + let modelsDevCatalogResolver: ClaudeModelsDevCatalogResolver let modelsDevCacheRoot: URL? let checkCancellation: CancellationCheck? @@ -550,16 +587,17 @@ extension CostUsageScanner { range: CostUsageDayRange, providerFilter: ClaudeLogProviderFilter, forceFullScan: Bool, - modelsDevCatalog: ModelsDevCatalog?, + changedPaths: Set, + modelsDevCatalogResolver: ClaudeModelsDevCatalogResolver, modelsDevCacheRoot: URL?, checkCancellation: CancellationCheck?) { self.cache = cache - self.touched = [] self.range = range self.providerFilter = providerFilter self.forceFullScan = forceFullScan - self.modelsDevCatalog = modelsDevCatalog + self.changedPaths = changedPaths + self.modelsDevCatalogResolver = modelsDevCatalogResolver self.modelsDevCacheRoot = modelsDevCacheRoot self.checkCancellation = checkCancellation } @@ -573,12 +611,12 @@ extension CostUsageScanner { { try state.checkCancellation?() let path = url.path - state.touched.insert(path) if let cached = state.cache.files[path], cached.mtimeUnixMs == mtimeMs, cached.size == size, - !state.forceFullScan + !state.forceFullScan, + !state.changedPaths.contains(path) { return } @@ -588,12 +626,15 @@ extension CostUsageScanner { let canIncremental = size > cached.size && startOffset > 0 && startOffset <= size && cached.claudeRows != nil if canIncremental { + #if DEBUG + Self.recordClaudeScanWork(.transcriptParse) + #endif let delta = try Self.parseClaudeFileCancellable( fileURL: url, range: state.range, providerFilter: state.providerFilter, startOffset: startOffset, - modelsDevCatalog: state.modelsDevCatalog, + modelsDevCatalog: state.modelsDevCatalogResolver.resolve(), modelsDevCacheRoot: state.modelsDevCacheRoot, checkCancellation: state.checkCancellation) let mergedRows = Self.mergeClaudeRows(existing: cached.claudeRows ?? [], delta: delta.rows) @@ -606,11 +647,14 @@ extension CostUsageScanner { } } + #if DEBUG + Self.recordClaudeScanWork(.transcriptParse) + #endif let parsed = try Self.parseClaudeFileCancellable( fileURL: url, range: state.range, providerFilter: state.providerFilter, - modelsDevCatalog: state.modelsDevCatalog, + modelsDevCatalog: state.modelsDevCatalogResolver.resolve(), modelsDevCacheRoot: state.modelsDevCacheRoot, checkCancellation: state.checkCancellation) let usage = Self.makeClaudeFileUsage( @@ -621,67 +665,33 @@ extension CostUsageScanner { state.cache.files[path] = usage } - private static func scanClaudeRoot( - root: URL, - state: ClaudeScanState) throws + private static func inventoryClaudeRoots( + _ roots: [URL], + checkCancellation: CancellationCheck?) throws -> ClaudeSourceInventory { - try state.checkCancellation?() - let rootPath = root.path - let rootCandidates = Self.claudeRootCandidates(for: rootPath) - let prefixes = Set(rootCandidates).map { path in - path.hasSuffix("/") ? path : "\(path)/" - } - let rootExists = rootCandidates.contains { FileManager.default.fileExists(atPath: $0) } + var inventory = ClaudeSourceInventory() - guard rootExists else { - let stale = state.cache.files.keys.filter { path in - prefixes.contains(where: { path.hasPrefix($0) }) - } - for path in stale { - state.cache.files.removeValue(forKey: path) - } - return - } - - // Always enumerate the directory tree. The per-file mtime/size cache in - // processClaudeFile already skips unchanged files, so the only cost here is - // the directory walk itself. The previous root-mtime optimization skipped - // enumeration entirely when the root directory mtime was unchanged, but on - // POSIX systems a directory mtime only updates for direct child changes — - // not for files created or modified inside subdirectories. This caused new - // session logs to go undetected until the cache was manually cleared. - let keys: [URLResourceKey] = [ - .isRegularFileKey, - .contentModificationDateKey, - .fileSizeKey, - ] - - guard let enumerator = FileManager.default.enumerator( - at: root, - includingPropertiesForKeys: keys, - options: [.skipsHiddenFiles, .skipsPackageDescendants]) - else { return } - - for case let url as URL in enumerator { - try state.checkCancellation?() - guard url.pathExtension.lowercased() == "jsonl" else { continue } - guard let values = try? url.resourceValues(forKeys: Set(keys)) else { continue } - guard values.isRegularFile == true else { continue } - let size = Int64(values.fileSize ?? 0) - if size <= 0 { - continue + for root in roots { + try checkCancellation?() + let rootPath = root.path + let rootCandidates = Self.claudeRootCandidates(for: rootPath) + guard let existingRootPath = rootCandidates.first(where: { FileManager.default.fileExists(atPath: $0) }) + else { continue } + let existingRoot = existingRootPath == rootPath ? root : URL(fileURLWithPath: existingRootPath) + guard let enumerator = FileManager.default.enumerator( + at: existingRoot, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles, .skipsPackageDescendants]) + else { continue } + + for case let url as URL in enumerator { + try checkCancellation?() + guard url.pathExtension.lowercased() == "jsonl" else { continue } + guard let stamp = CostUsageClaudeFileStamp.read(at: url), stamp.size > 0 else { continue } + inventory.files[url.path] = ClaudeSourceFile(url: url, stamp: stamp) } - - let mtime = values.contentModificationDate?.timeIntervalSince1970 ?? 0 - let mtimeMs = Int64(mtime * 1000) - try Self.processClaudeFile( - url: url, - size: size, - mtimeMs: mtimeMs, - state: state) } - - // Root mtime caching removed — see comment above. + return inventory } static func loadClaudeDaily( @@ -691,52 +701,100 @@ extension CostUsageScanner { options: Options, checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { + let roots = self.defaultClaudeProjectsRoots(options: options) + let inventory = try Self.inventoryClaudeRoots(roots, checkCancellation: checkCancellation) + try checkCancellation?() + + let cacheURL = CostUsageClaudeCacheIO.cacheFileURL(provider: provider, cacheRoot: options.cacheRoot) + let canonicalCachePath = cacheURL.standardizedFileURL.resolvingSymlinksInPath().path + let cacheArtifactStamp = CostUsageClaudeFileStamp.read(at: cacheURL) + let pricingURL = ModelsDevCache.cacheFileURL(cacheRoot: options.cacheRoot) + let pricingArtifactStamp = CostUsageClaudeFileStamp.read(at: pricingURL) + let reportKey = Self.claudeReportMemoKey( + provider: provider, + providerFilter: options.claudeLogProviderFilter, + range: range, + roots: roots, + artifactStamps: (cache: cacheArtifactStamp, pricing: pricingArtifactStamp)) + let memo = CostUsageClaudeReportMemo.shared + let priorMemo = memo.entry(provider: provider, canonicalCachePath: canonicalCachePath) + let sourceInventory = inventory.stamps + + if !options.forceRescan, + let priorMemo, + priorMemo.sourceInventory == sourceInventory, + priorMemo.reportKey == reportKey + { + try checkCancellation?() + return priorMemo.report + } + var cache = CostUsageClaudeCacheIO.load( provider: provider, cacheRoot: options.cacheRoot, calendar: range.calendar) let nowMs = Int64(now.timeIntervalSince1970 * 1000) - let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) let windowExpanded = Self.requestedWindowExpandsCache(range: range, cache: cache) + let sourceInventoryChanged = priorMemo.map { $0.sourceInventory != sourceInventory } ?? false + let cacheArtifactChanged = priorMemo.map { + $0.reportKey.cacheArtifactStamp != cacheArtifactStamp + } ?? false + let scanConfigurationChanged = priorMemo.map { + $0.reportKey.scanConfiguration != reportKey.scanConfiguration + } ?? false let shouldRefresh = options.forceRescan || windowExpanded + || sourceInventoryChanged + || cacheArtifactChanged + || scanConfigurationChanged || refreshMs == 0 || cache.lastScanUnixMs == 0 || nowMs - cache.lastScanUnixMs > refreshMs - let providerFilter = options.claudeLogProviderFilter - - var touched: Set = [] - - if shouldRefresh { + let hasStableProcessBaseline = priorMemo != nil + && !sourceInventoryChanged + && !cacheArtifactChanged + && !scanConfigurationChanged + let shouldMutateCache = shouldRefresh && (!hasStableProcessBaseline || options.forceRescan || windowExpanded) + let modelsDevCatalogResolver = ClaudeModelsDevCatalogResolver(now: now, cacheRoot: options.cacheRoot) + + if shouldMutateCache { try checkCancellation?() if options.forceRescan { cache = CostUsageCache() } - let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) + let changedPaths: Set = if let priorMemo { + Set(inventory.files.keys.filter { path in + priorMemo.sourceInventory[path] != sourceInventory[path] + }) + } else { + [] + } let scanState = ClaudeScanState( cache: cache, range: range, providerFilter: providerFilter, - forceFullScan: options.forceRescan || windowExpanded, - modelsDevCatalog: modelsDevCatalog, + forceFullScan: options.forceRescan || windowExpanded || scanConfigurationChanged, + changedPaths: changedPaths, + modelsDevCatalogResolver: modelsDevCatalogResolver, modelsDevCacheRoot: options.cacheRoot, checkCancellation: checkCancellation) - let roots = self.defaultClaudeProjectsRoots(options: options) - for root in roots { - try Self.scanClaudeRoot( - root: root, + for path in inventory.files.keys.sorted() { + guard let source = inventory.files[path] else { continue } + try Self.processClaudeFile( + url: source.url, + size: source.stamp.size, + mtimeMs: source.stamp.mtimeUnixMs, state: scanState) } try checkCancellation?() cache = scanState.cache - touched = scanState.touched cache.roots = nil - for key in cache.files.keys where !touched.contains(key) { + for key in cache.files.keys where sourceInventory[key] == nil { cache.files.removeValue(forKey: key) } @@ -745,26 +803,80 @@ extension CostUsageScanner { cache.scanSinceKey = range.scanSinceKey cache.scanUntilKey = range.scanUntilKey cache.lastScanUnixMs = nowMs - try checkCancellation?() - CostUsageClaudeCacheIO.save( + } + + let report = Self.buildClaudeReportFromCache( + cache: cache, + range: range, + modelsDevCatalogResolver: modelsDevCatalogResolver, + modelsDevCacheRoot: options.cacheRoot) + try checkCancellation?() + + let committedCacheStamp: CostUsageClaudeFileStamp? = if shouldMutateCache { + try CostUsageClaudeCacheIO.save( provider: provider, cache: cache, cacheRoot: options.cacheRoot, - calendar: range.calendar) + calendar: range.calendar, + checkCancellation: checkCancellation) + } else { + nil } - let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) - return Self.buildClaudeReportFromCache( - cache: cache, + let finalCacheArtifactStamp = CostUsageClaudeFileStamp.read(at: cacheURL) + let finalPricingArtifactStamp = CostUsageClaudeFileStamp.read(at: pricingURL) + let finalReportKey = Self.claudeReportMemoKey( + provider: provider, + providerFilter: providerFilter, range: range, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: options.cacheRoot) + roots: roots, + artifactStamps: (cache: finalCacheArtifactStamp, pricing: finalPricingArtifactStamp)) + let cacheArtifactIsCurrent = if shouldMutateCache { + committedCacheStamp != nil && finalCacheArtifactStamp == committedCacheStamp + } else { + finalCacheArtifactStamp == cacheArtifactStamp + } + if cacheArtifactIsCurrent, finalPricingArtifactStamp == pricingArtifactStamp { + memo.store( + provider: provider, + canonicalCachePath: canonicalCachePath, + sourceInventory: sourceInventory, + reportKey: finalReportKey, + report: report) + } + return report + } + + private static func claudeReportMemoKey( + provider: UsageProvider, + providerFilter: ClaudeLogProviderFilter, + range: CostUsageDayRange, + roots: [URL], + artifactStamps: (cache: CostUsageClaudeFileStamp?, pricing: CostUsageClaudeFileStamp?)) + -> CostUsageClaudeReportMemoKey + { + let providerFilterKey = switch providerFilter { + case .all: "all" + case .vertexAIOnly: "vertex-ai-only" + case .excludeVertexAI: "exclude-vertex-ai" + } + return CostUsageClaudeReportMemoKey( + provider: provider, + providerFilter: providerFilterKey, + sinceKey: range.sinceKey, + untilKey: range.untilKey, + scanSinceKey: range.scanSinceKey, + scanUntilKey: range.scanUntilKey, + timeZoneIdentifier: range.calendar.timeZone.identifier, + roots: roots.map { $0.standardizedFileURL.resolvingSymlinksInPath().path }.sorted(), + cacheArtifactStamp: artifactStamps.cache, + pricingArtifactStamp: artifactStamps.pricing) } private static func buildClaudeReportFromCache( cache: CostUsageCache, range: CostUsageDayRange, - modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCatalogResolver: ClaudeModelsDevCatalogResolver, modelsDevCacheRoot: URL? = nil) -> CostUsageDailyReport { var entries: [CostUsageDailyReport.Entry] = [] @@ -777,8 +889,13 @@ extension CostUsageScanner { var costSeen = false let costScale = 1_000_000_000.0 var repricedCosts: [ClaudeDayModelKey: ClaudeRepricedCost] = [:] + let rows = Self.reconciledClaudeRows(cache: cache) + let modelsDevCatalog = rows.isEmpty ? nil : modelsDevCatalogResolver.resolve() - for row in Self.reconciledClaudeRows(cache: cache) { + for row in rows { + #if DEBUG + Self.recordClaudeScanWork(.reprice) + #endif let key = ClaudeDayModelKey(day: row.dayKey, model: row.model) var aggregate = repricedCosts[key] ?? ClaudeRepricedCost() aggregate.sampleCount += 1 diff --git a/Tests/CodexBarTests/CodexCostCatchUpPolicyTests.swift b/Tests/CodexBarTests/CodexCostCatchUpPolicyTests.swift index a58c367070..8f6a396878 100644 --- a/Tests/CodexBarTests/CodexCostCatchUpPolicyTests.swift +++ b/Tests/CodexBarTests/CodexCostCatchUpPolicyTests.swift @@ -4,7 +4,7 @@ import Testing struct CodexCostCatchUpPolicyTests { @Test - func `automatic mode targets twenty percent duty cycle on AC power`() { + func `automatic mode targets one tenth percent duty cycle on AC power`() { let decision = CodexCostCatchUpPolicy().decision(for: .init( mode: .automatic, previousActiveDuration: 2, @@ -12,11 +12,23 @@ struct CodexCostCatchUpPolicyTests { lowPowerModeEnabled: false, thermalState: .nominal)) - #expect(decision == .init(action: .runAfter(8), targetDutyCycle: 0.2)) + #expect(decision == .init(action: .runAfter(1998), targetDutyCycle: 0.001)) } @Test - func `automatic mode targets five percent duty cycle on battery`() { + func `automatic mode targets one twentieth percent duty cycle for unknown power`() { + let decision = CodexCostCatchUpPolicy().decision(for: .init( + mode: .automatic, + previousActiveDuration: 2, + powerSource: .unknown, + lowPowerModeEnabled: false, + thermalState: .nominal)) + + #expect(decision == .init(action: .runAfter(3998), targetDutyCycle: 0.0005)) + } + + @Test + func `automatic mode targets one fiftieth percent duty cycle on battery`() { let decision = CodexCostCatchUpPolicy().decision(for: .init( mode: .automatic, previousActiveDuration: 2, @@ -24,12 +36,7 @@ struct CodexCostCatchUpPolicyTests { lowPowerModeEnabled: false, thermalState: .nominal)) - guard case let .runAfter(delay) = decision.action else { - Issue.record("Expected automatic battery catch-up to schedule another pass") - return - } - #expect(abs(delay - 38) < 0.000_001) - #expect(decision.targetDutyCycle == 0.05) + #expect(decision == .init(action: .runAfter(9998), targetDutyCycle: 0.0002)) } @Test diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift new file mode 100644 index 0000000000..7c90f7e9d2 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift @@ -0,0 +1,368 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CostUsageScannerClaudeMemoTests { + @Test + func `identical warm refresh only inventories sources`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 1) + _ = try self.writeEvent(env: env, day: day, path: "project/session.jsonl", id: "first", input: 10) + let options = self.options(env: env) + let initial = self.load(day: day, options: options) + let cacheURL = self.cacheURL(env: env) + let cacheStamp = CostUsageClaudeFileStamp.read(at: cacheURL) + + let (warm, metrics) = self.recordedLoad(day: day, options: options) + + #expect(warm.data == initial.data) + #expect(warm.summary == initial.summary) + #expect(metrics == CostUsageScanner.ClaudeScanWorkMetrics()) + #expect(CostUsageClaudeFileStamp.read(at: cacheURL) == cacheStamp) + } + + @Test + func `cold process reuses unchanged files from the persisted cache`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 1) + _ = try self.writeEvent(env: env, day: day, path: "project/first.jsonl", id: "first", input: 10) + _ = try self.writeEvent(env: env, day: day, path: "project/second.jsonl", id: "second", input: 20) + let options = self.options(env: env) + let initial = self.load(day: day, options: options) + CostUsageScanner.evictClaudeReportMemoForTesting(provider: .claude, cacheRoot: env.cacheRoot) + + let (restarted, metrics) = self.recordedLoad(day: day, options: options) + + #expect(restarted.data == initial.data) + #expect(restarted.summary == initial.summary) + #expect(metrics.cacheDecodes == 1) + #expect(metrics.transcriptParses == 0) + } + + @Test + func `nested source addition invalidates the memo`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 2) + _ = try self.writeEvent(env: env, day: day, path: "project/session.jsonl", id: "first", input: 10) + let options = self.options(env: env) + _ = self.load(day: day, options: options) + _ = try self.writeEvent( + env: env, + day: day, + path: "project/nested/deeper/session.jsonl", + id: "nested", + input: 20) + + let (report, metrics) = self.recordedLoad(day: day, options: options) + + #expect(report.summary?.totalInputTokens == 30) + #expect(metrics.cacheDecodes == 1) + #expect(metrics.transcriptParses == 1) + #expect(metrics.cacheEncodes == 1) + } + + @Test + func `source append invalidates the memo and parses the delta`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 3) + let fileURL = try self.writeEvent( + env: env, + day: day, + path: "project/session.jsonl", + id: "first", + input: 10) + let options = self.options(env: env) + _ = self.load(day: day, options: options) + let appended = try env.jsonl([self.event(env: env, day: day, id: "second", input: 20)]) + let handle = try FileHandle(forWritingTo: fileURL) + try handle.seekToEnd() + try handle.write(contentsOf: Data(appended.utf8)) + try handle.close() + + let (report, metrics) = self.recordedLoad(day: day, options: options) + + #expect(report.summary?.totalInputTokens == 30) + #expect(metrics.cacheDecodes == 1) + #expect(metrics.transcriptParses == 1) + #expect(metrics.cacheEncodes == 1) + } + + @Test + func `individual source deletion invalidates the memo and removes its rows`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 4) + let deletedURL = try self.writeEvent( + env: env, + day: day, + path: "project/deleted.jsonl", + id: "deleted", + input: 10) + _ = try self.writeEvent( + env: env, + day: day, + path: "project/retained.jsonl", + id: "retained", + input: 20) + let options = self.options(env: env) + _ = self.load(day: day, options: options) + try FileManager.default.removeItem(at: deletedURL) + + let (report, metrics) = self.recordedLoad(day: day, options: options) + + #expect(report.summary?.totalInputTokens == 20) + #expect(metrics.cacheDecodes == 1) + #expect(metrics.transcriptParses == 0) + #expect(metrics.cacheEncodes == 1) + } + + @Test + func `missing source root invalidates the memo and deletes cached rows`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 4) + _ = try self.writeEvent(env: env, day: day, path: "project/session.jsonl", id: "first", input: 10) + let options = self.options(env: env) + _ = self.load(day: day, options: options) + try FileManager.default.removeItem(at: env.claudeProjectsRoot) + + let (report, metrics) = self.recordedLoad(day: day, options: options) + + #expect(report.data.isEmpty) + #expect(metrics.cacheDecodes == 1) + #expect(metrics.transcriptParses == 0) + #expect(metrics.cacheEncodes == 1) + } + + @Test + func `external atomic cache replacement invalidates the memo`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 5) + _ = try self.writeEvent(env: env, day: day, path: "project/session.jsonl", id: "first", input: 10) + let options = self.options(env: env) + let initial = self.load(day: day, options: options) + let cacheURL = self.cacheURL(env: env) + let originalStamp = try #require(CostUsageClaudeFileStamp.read(at: cacheURL)) + let cacheData = try Data(contentsOf: cacheURL) + try cacheData.write(to: cacheURL, options: [.atomic]) + let replacementStamp = try #require(CostUsageClaudeFileStamp.read(at: cacheURL)) + #expect(replacementStamp.fileID != originalStamp.fileID) + + let (report, metrics) = self.recordedLoad(day: day, options: options) + + #expect(report.data == initial.data) + #expect(metrics.cacheDecodes == 1) + #expect(metrics.transcriptParses == 0) + #expect(metrics.cacheEncodes == 1) + } + + @Test + func `force rescan bypasses an exact memo hit`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 6) + _ = try self.writeEvent(env: env, day: day, path: "project/session.jsonl", id: "first", input: 10) + var options = self.options(env: env) + _ = self.load(day: day, options: options) + options.forceRescan = true + + let (report, metrics) = self.recordedLoad(day: day, options: options) + + #expect(report.summary?.totalInputTokens == 10) + #expect(metrics.cacheDecodes == 1) + #expect(metrics.transcriptParses == 1) + #expect(metrics.cacheEncodes == 1) + #expect(metrics.repricedRows == 1) + } + + @Test + func `pricing replacement reprices without parsing or rewriting the claude cache`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 7) + let model = "claude-test-memo-pricing" + _ = try self.writeEvent( + env: env, + day: day, + path: "project/session.jsonl", + id: "first", + input: 100, + model: model) + #expect(try ModelsDevCache.save( + catalog: self.catalog(model: model, inputRate: 10), + fetchedAt: day, + cacheRoot: env.cacheRoot)) + let options = self.options(env: env) + let first = self.load(day: day, options: options) + let cacheURL = self.cacheURL(env: env) + let cacheStamp = CostUsageClaudeFileStamp.read(at: cacheURL) + #expect(abs((first.summary?.totalCostUSD ?? 0) - 0.001) < 0.000000001) + #expect(try ModelsDevCache.save( + catalog: self.catalog(model: model, inputRate: 20), + fetchedAt: day.addingTimeInterval(1), + cacheRoot: env.cacheRoot)) + + let (repriced, metrics) = self.recordedLoad(day: day, options: options) + + #expect(abs((repriced.summary?.totalCostUSD ?? 0) - 0.002) < 0.000000001) + #expect(metrics.cacheDecodes == 1) + #expect(metrics.transcriptParses == 0) + #expect(metrics.cacheEncodes == 0) + #expect(metrics.repricedRows == 1) + #expect(CostUsageClaudeFileStamp.read(at: cacheURL) == cacheStamp) + } + + @Test + func `timezone change invalidates the memo and rebuilds the cache`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 8) + _ = try self.writeEvent(env: env, day: day, path: "project/session.jsonl", id: "first", input: 10) + var utc = Calendar(identifier: .gregorian) + utc.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + var options = self.options(env: env, calendar: utc) + _ = self.load(day: day, options: options) + var shifted = Calendar(identifier: .gregorian) + shifted.timeZone = try #require(TimeZone(secondsFromGMT: 3600)) + options.calendar = shifted + + let (report, metrics) = self.recordedLoad(day: day, options: options) + + #expect(report.summary?.totalInputTokens == 10) + #expect(metrics.cacheDecodes == 1) + #expect(metrics.transcriptParses == 1) + #expect(metrics.cacheEncodes == 1) + } + + @Test + func `cancellation preserves disk and the prior memo`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 9) + _ = try self.writeEvent(env: env, day: day, path: "project/session.jsonl", id: "first", input: 10) + var options = self.options(env: env) + _ = self.load(day: day, options: options) + let cacheURL = self.cacheURL(env: env) + let diskBefore = try Data(contentsOf: cacheURL) + let stampBefore = CostUsageClaudeFileStamp.read(at: cacheURL) + options.forceRescan = true + var checks = 0 + + #expect(throws: CancellationError.self) { + _ = try CostUsageScanner.loadDailyReportCancellable( + provider: .claude, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options, + checkCancellation: { + checks += 1 + if checks == 4 { + throw CancellationError() + } + }) + } + #expect(try Data(contentsOf: cacheURL) == diskBefore) + #expect(CostUsageClaudeFileStamp.read(at: cacheURL) == stampBefore) + + options.forceRescan = false + let (_, metrics) = self.recordedLoad(day: day, options: options) + #expect(metrics == CostUsageScanner.ClaudeScanWorkMetrics()) + } + + private func options( + env: CostUsageTestEnvironment, + calendar: Calendar = .current) -> CostUsageScanner.Options + { + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + calendar: calendar) + options.refreshMinIntervalSeconds = 0 + return options + } + + private func load(day: Date, options: CostUsageScanner.Options) -> CostUsageDailyReport { + CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + } + + private func recordedLoad( + day: Date, + options: CostUsageScanner.Options) -> (CostUsageDailyReport, CostUsageScanner.ClaudeScanWorkMetrics) + { + let recorder = CostUsageScanner.ClaudeScanWorkRecorder() + let report = CostUsageScanner.withClaudeScanWorkRecorderForTesting(recorder) { + self.load(day: day, options: options) + } + return (report, recorder.snapshot()) + } + + private func writeEvent( + env: CostUsageTestEnvironment, + day: Date, + path: String, + id: String, + input: Int, + model: String = "claude-sonnet-4-20250514") throws -> URL + { + try env.writeClaudeProjectFile( + relativePath: path, + contents: env.jsonl([self.event(env: env, day: day, id: id, input: input, model: model)])) + } + + private func event( + env: CostUsageTestEnvironment, + day: Date, + id: String, + input: Int, + model: String = "claude-sonnet-4-20250514") -> [String: Any] + { + [ + "type": "assistant", + "timestamp": env.isoString(for: day), + "sessionId": "session-\(id)", + "requestId": "request-\(id)", + "message": [ + "id": "message-\(id)", + "model": model, + "usage": [ + "input_tokens": input, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 0, + ], + ], + ] + } + + private func catalog(model: String, inputRate: Double) throws -> ModelsDevCatalog { + try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(""" + { + "anthropic": { + "id": "anthropic", + "models": { + "\(model)": { + "id": "\(model)", + "cost": { "input": \(inputRate), "output": 1 } + } + } + } + } + """.utf8)) + } + + private func cacheURL(env: CostUsageTestEnvironment) -> URL { + CostUsageClaudeCacheIO.cacheFileURL(provider: .claude, cacheRoot: env.cacheRoot) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 9b6fcdc919..ac4c0dcf90 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: 1047, + line: 1042, 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: 1149, + line: 1144, 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: 1153, + line: 1148, anchor: "provider: .claude,", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -3212,7 +3212,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 587, + line: 583, anchor: "self.metadata(for: .codex).browserCookieOrder ?? Browser.defaultImportOrder", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3220,7 +3220,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 639, + line: 635, anchor: "self.providerSpecs[provider]?.style ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3228,7 +3228,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 672, + line: 668, anchor: "guard provider != .codex else { return true }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3236,7 +3236,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1021, + line: 1016, anchor: "let claudeDebugConfiguration: ClaudeDebugLogConfiguration? = if provider == .claude {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3244,7 +3244,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1044, + line: 1039, anchor: "let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, @@ -3252,7 +3252,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1101, + line: 1096, anchor: "case .amp:", expectedProviderIDs: ["amp", "deepseek", "notion", "ollama", "warp"], expectedReferenceCount: 7, @@ -3268,7 +3268,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1156, + line: 1151, anchor: "let claudeSettings = snapshot.claude ?? ProviderSettingsSnapshot.ClaudeProviderSettings(", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/RequiredRefreshCoalescingTests.swift b/Tests/CodexBarTests/RequiredRefreshCoalescingTests.swift index db033d7d76..f9cf898b63 100644 --- a/Tests/CodexBarTests/RequiredRefreshCoalescingTests.swift +++ b/Tests/CodexBarTests/RequiredRefreshCoalescingTests.swift @@ -397,9 +397,9 @@ extension CodexBackgroundRefreshCoalescingTests { } @Test - func `forced token tail excludes periodic token sequence`() async throws { + func `forced token tail excludes scheduled automatic token sequence`() async throws { let settings = try self.makeSettingsStore( - suite: "CodexBackgroundRefreshCoalescingTests-forced-token-excludes-timer") + suite: "CodexBackgroundRefreshCoalescingTests-forced-token-excludes-scheduled") settings.statusChecksEnabled = false settings.costUsageEnabled = true settings.openAIWebAccessEnabled = false @@ -440,9 +440,9 @@ extension CodexBackgroundRefreshCoalescingTests { } @Test - func `forced enrichment excludes timer after token child completes`() async throws { + func `forced enrichment excludes scheduled token work after token child completes`() async throws { let settings = try self.makeSettingsStore( - suite: "CodexBackgroundRefreshCoalescingTests-forced-tail-excludes-token-timer") + suite: "CodexBackgroundRefreshCoalescingTests-forced-tail-excludes-scheduled-token") settings.statusChecksEnabled = false settings.costUsageEnabled = true settings.openAIWebAccessEnabled = false diff --git a/Tests/CodexBarTests/UsageStoreCodexCostCatchUpTests.swift b/Tests/CodexBarTests/UsageStoreCodexCostCatchUpTests.swift index 9ccd317d33..bf6e8c25be 100644 --- a/Tests/CodexBarTests/UsageStoreCodexCostCatchUpTests.swift +++ b/Tests/CodexBarTests/UsageStoreCodexCostCatchUpTests.swift @@ -45,7 +45,7 @@ struct UsageStoreCodexCostCatchUpTests { #expect(advanceCount == 2) #expect(statusLoadCount == 2) #expect(snapshotLoadCount == 2) - #expect(sleepDurations.first == 8) + #expect(sleepDurations.first == 1998) #expect(store.tokenSnapshot(for: .codex)?.last30DaysCostUSD == 2) #expect(store.tokenSnapshotPublicationRevision(for: .codex) == 2) #expect(store.tokenError(for: .codex) == nil) diff --git a/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift b/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift index eb28ffd4fd..a034e15fd1 100644 --- a/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift +++ b/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift @@ -284,7 +284,7 @@ struct UsageStoreManualTokenRefreshTests { } @Test - func `regular refresh schedules token-cost refresh without waiting`() async { + func `ordinary automatic provider refresh schedules token-cost refresh without waiting`() async { let store = Self.makeStore() let gate = TokenRefreshGate() store._test_providerRefreshOverride = { _ in } diff --git a/Tests/CodexBarTests/UsageStoreTokenRefreshCadenceTests.swift b/Tests/CodexBarTests/UsageStoreTokenRefreshCadenceTests.swift index f7dc21cdd8..d4eb01d9a9 100644 --- a/Tests/CodexBarTests/UsageStoreTokenRefreshCadenceTests.swift +++ b/Tests/CodexBarTests/UsageStoreTokenRefreshCadenceTests.swift @@ -1,3 +1,4 @@ +import CodexBarCore import Foundation import Testing @testable import CodexBar @@ -5,9 +6,9 @@ import Testing @MainActor struct UsageStoreTokenRefreshCadenceTests { @Test(arguments: [ - (RefreshFrequency.oneMinute, 300.0), - (.twoMinutes, 300.0), - (.fiveMinutes, 300.0), + (RefreshFrequency.oneMinute, 900.0), + (.twoMinutes, 900.0), + (.fiveMinutes, 900.0), (.fifteenMinutes, 900.0), (.thirtyMinutes, 1800.0), ]) @@ -19,8 +20,8 @@ struct UsageStoreTokenRefreshCadenceTests { } @Test(arguments: [RefreshFrequency.adaptive, .adaptiveAgentAware]) - func `adaptive refresh frequencies use the policy nominal interval`(frequency: RefreshFrequency) { - #expect(UsageStore.tokenFetchTTL(for: frequency) == AdaptiveRefreshPolicy.nominalIntervalForHeuristics) + func `adaptive refresh frequencies honor the fifteen minute token floor`(frequency: RefreshFrequency) { + #expect(UsageStore.tokenFetchTTL(for: frequency) == TimeInterval(15 * 60)) } @Test @@ -49,4 +50,45 @@ struct UsageStoreTokenRefreshCadenceTests { for: .manual, lowPowerModeEnabled: true) == nil) } + + @Test(arguments: [RefreshFrequency.oneMinute, .twoMinutes, .fiveMinutes]) + func `fast provider refreshes cannot rescan local cost within fifteen minutes`( + frequency: RefreshFrequency) async throws + { + let settings = testSettingsStore(suiteName: "UsageStoreTokenRefreshCadenceTests-\(frequency.rawValue)") + settings.refreshFrequency = frequency + settings.costUsageEnabled = true + settings.providerDetectionCompleted = true + let metadata = try #require(ProviderRegistry.shared.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: metadata, enabled: true) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let now = Date() + store._setTokenSnapshotForTesting(Self.tokenSnapshot(updatedAt: now), provider: .codex) + store.lastTokenFetchScope[.codex] = store.tokenSnapshotScopeSignature(for: .codex) + var scanCount = 0 + store._test_tokenUsageRefreshOverride = { _, _ in scanCount += 1 } + + store.lastTokenFetchAt[.codex] = now.addingTimeInterval(-14 * 60) + await store.refreshTokenUsage(.codex, force: false) + #expect(scanCount == 0) + + store.lastTokenFetchAt[.codex] = now.addingTimeInterval(-15 * 60 - 1) + await store.refreshTokenUsage(.codex, force: false) + #expect(scanCount == 1) + } + + private static func tokenSnapshot(updatedAt: Date) -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: 1, + sessionCostUSD: 0.01, + last30DaysTokens: 1, + last30DaysCostUSD: 0.01, + daily: [], + updatedAt: updatedAt) + } } diff --git a/docs/widgets.md b/docs/widgets.md index 8af0bf980f..28ef0c6ec8 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -12,10 +12,10 @@ read_when: - `WidgetSnapshotStore` writes compact JSON snapshots to the app-group container. - Widgets read the snapshot and render usage/credits/history states. - The app writes snapshots after the main refresh pipeline and token-usage refreshes; narrow single-provider refresh paths may wait for the next snapshot write. -- Automatic token/cost refresh eligibility follows the global refresh frequency. One- and two-minute settings are - clamped to a five-minute minimum, both Adaptive modes use their nominal five-minute heuristic interval, and Manual - disables the automatic token timer. The floor limits repeated local-history scans and extra WidgetKit reload requests - so widgets do not exhaust the system-managed refresh budget. +- Automatic provider refresh is the sole periodic trigger for token/cost refreshes; the token/cost TTL only determines + eligibility when that refresh runs. Automatic local-history scans have a 15-minute minimum (30 minutes in low-power + mode), while Manual disables automatic scans. The floor limits repeated local-history work and extra WidgetKit reload + requests without changing provider usage/status freshness or the user-selected provider refresh cadence. - Claude local cost/token history remains eligible for widget snapshots when its account does not expose numeric session or weekly quota data. - If no snapshot is available, widgets fall back to preview/empty data.