diff --git a/CHANGELOG.md b/CHANGELOG.md index ba7d77add4..3c7ca689a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - Hide untouched Antigravity model families in the `codexbar serve` web dashboard, matching the menu and widgets (#3061). Thanks @urda! - Documented the AI Usage Limits Stream Deck plugin in the README integrations list (#3066). Thanks @lenadweb! - OpenCode Go: use the public authenticated usage API when `OPENCODE_API_KEY` is configured, overlaying authoritative rolling/weekly/monthly windows on local history with cookie fallback (#2879, #3065). Thanks @akshayprabhu200! +### Fixed +- Fixed Codex cost catch-up getting stuck when recently touched session files contain only out-of-window usage (#3071). ## 0.54.0 — 2026-08-18 diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 315c9cdb1e..abc3eef6ac 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "6293f505c4cbbce8" + static let value = "2d17f4981b78d07f" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 49cb1fec1f..652336a574 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -3078,6 +3078,7 @@ enum CostUsageScanner { scheduledFiles: [URL], pendingPaths: Set, attemptedPaths: Set, + processedPaths: Set, cache: CostUsageCache) -> Set { Set(scheduledFiles.compactMap { fileURL -> String? in @@ -3088,6 +3089,9 @@ enum CostUsageScanner { if metadata.fileId == nil, !FileManager.default.fileExists(atPath: fileURL.path) { return resolvedPath } + if processedPaths.contains(fileURL.path), cache.files[fileURL.path] == nil { + return resolvedPath + } guard let usage = cache.files[fileURL.path], usage.codexScanComplete == true, !usage.hasBufferedCodexForkRetryLines, @@ -4908,11 +4912,16 @@ enum CostUsageScanner { return nil } + private enum CodexFileScanOutcome { + case processed + case deferred + } + private static func scanCodexFile( fileURL: URL, context: CodexFileScanContext, cache: inout CostUsageCache, - state: inout CodexScanState) throws + state: inout CodexScanState) throws -> CodexFileScanOutcome { try context.checkCancellation?() let metadata = Self.codexFileMetadata(fileURL: fileURL) @@ -4923,7 +4932,7 @@ enum CostUsageScanner { } if let fileId = metadata.fileId, state.seenFileIds.contains(fileId) { Self.dropCachedCodexFile(path: metadata.path, cached: cache.files[metadata.path], cache: &cache) - return + return .processed } Self.reconcileCodexCachePathAliases( metadata: metadata, @@ -4934,7 +4943,7 @@ enum CostUsageScanner { let input = CodexFileScanInput(fileURL: fileURL, metadata: metadata, cached: cached) if try Self.keepCachedCodexFileIfFresh(input: input, context: context, cache: &cache, state: &state) { - return + return .processed } let pendingWorkBytes = Self.pendingCodexScanWorkBytes(metadata: metadata, cached: cached) @@ -4953,7 +4962,7 @@ enum CostUsageScanner { "limit": "\(budget.maxBytesPerRefresh)", ]) // Preserve stale cache so later refreshes can resume catch-up. - return + return .deferred } } else { allowedWorkBytes = pendingWorkBytes @@ -4967,7 +4976,7 @@ enum CostUsageScanner { maxBytesToRead: allowedWorkBytes) { context.scanBudget?.consume(workBytes: allowedWorkBytes) - return + return .processed } let fullRescanWorkBytes = max(0, metadata.size) let fullRescanAllowedBytes: Int64 @@ -4981,7 +4990,7 @@ enum CostUsageScanner { case .deferBudget: // No work was consumed by the rejected incremental path, so this is only // reachable when the refresh budget has no allowance for the full rescan. - return + return .deferred } } else { fullRescanAllowedBytes = fullRescanWorkBytes @@ -4994,6 +5003,7 @@ enum CostUsageScanner { state: &state, maxBytesToRead: fullRescanAllowedBytes) context.scanBudget?.consume(workBytes: fullRescanAllowedBytes) + return .processed } static func pendingCodexScanWorkBytes(metadata: CodexFileMetadata, cached: CostUsageFileUsage?) -> Int64 { @@ -5486,6 +5496,11 @@ enum CostUsageScanner { filePathsInScan.formUnion(scanResult.scannedPaths.map { Self.codexPathKey(URL(fileURLWithPath: $0)) }) + let processedWithoutCachePathKeys = Set(scanResult.processedPaths.compactMap { path -> String? in + guard cache.files[path] == nil else { return nil } + return Self.codexPathKey(URL(fileURLWithPath: path)) + }) + filePathsInScan.subtract(processedWithoutCachePathKeys) let pendingLookbackPathCount = shouldBoundCatchUp ? boundedQueuePathCount : activeLookbackState.pendingFilePaths.count @@ -5494,6 +5509,7 @@ enum CostUsageScanner { scheduledFiles: filesScheduledForRefresh, pendingPaths: pendingLookbackPaths, attemptedPaths: scanResult.attemptedPaths, + processedPaths: scanResult.processedPaths, cache: cache) cache.codexActiveLookbackState = Self.finalizedCodexActiveLookbackState( activeLookbackState, @@ -5799,6 +5815,7 @@ enum CostUsageScanner { private struct CodexFileScanResult { let scannedPaths: Set let attemptedPaths: Set + let processedPaths: Set } private static func scanCodexFiles( @@ -5812,17 +5829,21 @@ enum CostUsageScanner { var visitedPaths = Set(files.map(\.standardizedFileURL.path)) var scannedPaths = Set(files.map(\.path)) var attemptedPaths: Set = [] + var processedPaths: Set = [] for fileURL in files { if context.scanBudget?.shouldStopBeforeNextFile() == true { break } context.workRecorder?.recordCodexFileScanAttempt(path: Self.codexPathKey(fileURL)) attemptedPaths.insert(fileURL.path) - try Self.scanCodexFile( + let outcome = try Self.scanCodexFile( fileURL: fileURL, context: context, cache: &cache, state: &scanState) + if case .processed = outcome { + processedPaths.insert(fileURL.path) + } let usage = cache.files[fileURL.path] inheritedResolver.updateCachedUsage(fileURL: fileURL, usage: usage) if Self.shouldRetryBufferedCodexFork(usage) { @@ -5846,11 +5867,14 @@ enum CostUsageScanner { context.workRecorder?.recordCodexFileScanAttempt(path: Self.codexPathKey(fileURL)) scannedPaths.insert(fileURL.path) attemptedPaths.insert(fileURL.path) - try Self.scanCodexFile( + let outcome = try Self.scanCodexFile( fileURL: fileURL, context: context, cache: &cache, state: &dependencyState) + if case .processed = outcome { + processedPaths.insert(fileURL.path) + } let usage = cache.files[fileURL.path] inheritedResolver.updateCachedUsage(fileURL: fileURL, usage: usage) if Self.shouldRetryBufferedCodexFork(usage) { @@ -5866,16 +5890,22 @@ enum CostUsageScanner { var retriedPaths: Set = [] for fileURL in bufferedForkRetries where retriedPaths.insert(fileURL.path).inserted { guard Self.shouldRetryBufferedCodexFork(cache.files[fileURL.path]) else { continue } - try Self.scanCodexFile( + let outcome = try Self.scanCodexFile( fileURL: fileURL, context: context, cache: &cache, state: &retryState) + if case .processed = outcome { + processedPaths.insert(fileURL.path) + } inheritedResolver.updateCachedUsage( fileURL: fileURL, usage: cache.files[fileURL.path]) } - return CodexFileScanResult(scannedPaths: scannedPaths, attemptedPaths: attemptedPaths) + return CodexFileScanResult( + scannedPaths: scannedPaths, + attemptedPaths: attemptedPaths, + processedPaths: processedPaths) } private static func shouldRetryBufferedCodexFork(_ usage: CostUsageFileUsage?) -> Bool { diff --git a/Tests/CodexBarTests/CostUsageCatchUpCompletionTests.swift b/Tests/CodexBarTests/CostUsageCatchUpCompletionTests.swift index e8215def46..a5b7f37316 100644 --- a/Tests/CodexBarTests/CostUsageCatchUpCompletionTests.swift +++ b/Tests/CodexBarTests/CostUsageCatchUpCompletionTests.swift @@ -4,6 +4,163 @@ import Testing @Suite(.serialized) struct CostUsageCatchUpCompletionTests { + @Test + func `touched old Codex file drains catch-up and permits exact proof`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let oldDay = try #require(Calendar.current.date(byAdding: .day, value: -10, to: day)) + let oldISO = env.isoString(for: oldDay) + let currentISO = env.isoString(for: day) + let oldURL = try env.writeCodexSessionFile( + day: oldDay, + filename: "rollout-old.jsonl", + contents: [ + #"{"type":"session_meta","timestamp":"\#(oldISO)","payload":{"session_id":"resumed-session"}}"#, + #"{"type":"turn_context","timestamp":"\#(oldISO)","payload":{"model":"openai/gpt-5.2-codex"}}"#, + #"{"type":"event_msg","timestamp":"\#(oldISO)","payload":{"type":"token_count","info":"# + + #"{"total_token_usage":{"input_tokens":50,"cached_input_tokens":10,"output_tokens":5},"# + + #""model":"openai/gpt-5.2-codex"}}}"#, + ].joined(separator: "\n") + "\n") + let currentURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-current.jsonl", + contents: [ + #"{"type":"session_meta","timestamp":"\#(currentISO)","payload":{"session_id":"resumed-session"}}"#, + #"{"type":"turn_context","timestamp":"\#(currentISO)","payload":{"model":"openai/gpt-5.2-codex"}}"#, + #"{"type":"event_msg","timestamp":"\#(currentISO)","payload":{"type":"token_count","info":"# + + #"{"total_token_usage":{"input_tokens":100,"cached_input_tokens":20,"output_tokens":10},"# + + #""model":"openai/gpt-5.2-codex"}}}"#, + ].joined(separator: "\n") + "\n") + try FileManager.default.setAttributes( + [.modificationDate: day.addingTimeInterval(60)], + ofItemAtPath: oldURL.path) + try FileManager.default.setAttributes( + [.modificationDate: day.addingTimeInterval(120)], + ofItemAtPath: currentURL.path) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + let proofRecorder = CostUsageScanner.CodexScanWorkRecorder() + options.codexScanWorkRecorderForTesting = proofRecorder + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(180), + options: options) + + let completedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) + #expect(completedCache.files[oldURL.path] == nil) + #expect(completedCache.files[currentURL.path] != nil) + #expect(proofRecorder.snapshot().codexProgressAccountingVisits == 1) + #expect(completedCache.codexActiveLookbackState == nil) + #expect(completedCache.codexScanInventoryPaths == [currentURL.path]) + #expect(completedCache.codexScanCatchUpPending == false) + + let roots = CostUsageScanner.codexSessionsRoots(options: options) + .map { $0.resolvingSymlinksInPath().standardizedFileURL.path } + .sorted() + var damagedCache = completedCache + damagedCache.codexActiveLookbackState = try CostUsageCodexActiveLookbackState( + scanSinceKey: #require(damagedCache.scanSinceKey), + rootPaths: roots, + completedRootPaths: roots, + pendingFilePaths: [oldURL.path], + completedCurrentWindowRootPaths: roots, + completedCurrentWindowFlatRootPaths: roots) + damagedCache.codexScanInventoryPaths = nil + damagedCache.codexScanCatchUpPending = true + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: damagedCache) + + options.codexScanWorkRecorderForTesting = nil + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(181), + options: options) + let repairedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) + #expect(repairedCache.codexActiveLookbackState == nil) + #expect(repairedCache.codexScanCatchUpPending == false) + } + + @Test + func `unprocessed pending Codex file remains queued`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso = env.isoString(for: day) + let cachedURL = try env.writeCodexSessionFile( + day: day, + filename: "a-cached.jsonl", + contents: [ + #"{"type":"session_meta","timestamp":"\#(iso)","payload":{"session_id":"cached"}}"#, + #"{"type":"turn_context","timestamp":"\#(iso)","payload":{"model":"openai/gpt-5.2-codex"}}"#, + #"{"type":"event_msg","timestamp":"\#(iso)","payload":{"type":"token_count","info":"# + + #"{"total_token_usage":{"input_tokens":100,"cached_input_tokens":20,"output_tokens":10},"# + + #""model":"openai/gpt-5.2-codex"}}}"#, + ].joined(separator: "\n") + "\n") + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + options.preferNewestCodexSessionsFirst = false + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + let pendingURL = try env.writeCodexSessionFile( + day: day, + filename: "b-pending.jsonl", + contents: [ + #"{"type":"session_meta","timestamp":"\#(iso)","payload":{"session_id":"pending"}}"#, + #"{"type":"turn_context","timestamp":"\#(iso)","payload":{"model":"openai/gpt-5.2-codex"}}"#, + ].joined(separator: "\n") + "\n") + let cachedHandle = try FileHandle(forWritingTo: cachedURL) + try cachedHandle.seekToEnd() + try cachedHandle.write(contentsOf: Data("\n".utf8)) + try cachedHandle.close() + + var pendingCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) + let roots = CostUsageScanner.codexSessionsRoots(options: options) + .map { $0.resolvingSymlinksInPath().standardizedFileURL.path } + .sorted() + pendingCache.codexActiveLookbackState = try CostUsageCodexActiveLookbackState( + scanSinceKey: #require(pendingCache.scanSinceKey), + rootPaths: roots, + completedRootPaths: roots, + pendingFilePaths: [cachedURL.path, pendingURL.path], + completedCurrentWindowRootPaths: roots, + completedCurrentWindowFlatRootPaths: roots) + pendingCache.codexScanCatchUpPending = true + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: pendingCache) + + options.maxCodexScanBytesPerRefresh = 1 + options.maxCodexScanDurationPerRefresh = 60 + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + + let deferredCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) + #expect(deferredCache.files[pendingURL.path] == nil) + #expect(deferredCache.codexActiveLookbackState?.pendingFilePaths.contains(pendingURL.path) == true) + #expect(deferredCache.codexScanCatchUpPending == true) + } + @Test func `device identity restoration queues validation beyond the bounded slice`() async throws { let env = try CostUsageTestEnvironment()