From dd2fa7dffda33ca692f6e267d4e4c734f853d7ce Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:29:02 +0800 Subject: [PATCH 01/22] Bound Codex cost cache persistence size --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 134 +++++++++++- Tests/CodexBarTests/CostUsageCacheTests.swift | 200 ++++++++++++++++++ 3 files changed, 326 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 7401a1244a..e18b2f21da 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 = "843ca061c36bbea1" + static let value = "8456c68643ab5e5e" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 8dcb034715..ea05cae426 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -1,6 +1,17 @@ import Foundation enum CostUsageCacheIO { + /// Persistence budgets for the Codex cost cache. The artifact holds one entry per + /// scanned session file plus per-file detail (rows, turn IDs, token snapshots) and is + /// decoded and encoded as a single JSON document on every scan, so an unbounded corpus + /// can otherwise grow it to multiple gigabytes. These bounds mirror the scan-side byte + /// budgets; in-window data is never dropped, so reports stay complete. + static let maxCacheFileBytes: Int = 256 * 1024 * 1024 + static let maxCacheFileEntries: Int = 25000 + /// Artifacts above this size are refused at load time and rebuilt by the bounded + /// scanner instead of being decoded in one shot. + static let maxCacheLoadBytes: Int = 1024 * 1024 * 1024 + /// Producer keys from older parser hashes whose caches are still valid under the current /// delta semantics. Cleared for #2037: interleave containment changed how cumulative /// totals are counted, so every earlier cache must be rebuilt. @@ -36,7 +47,8 @@ enum CostUsageCacheIO { provider: UsageProvider, cacheRoot: URL? = nil, producerKey: String? = nil, - calendar: Calendar? = nil) -> CostUsageCache + calendar: Calendar? = nil, + maxCacheBytes: Int = CostUsageCacheIO.maxCacheLoadBytes) -> CostUsageCache { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) let expectedProducerKey = producerKey ?? self.currentProducerKey(provider: provider) @@ -46,7 +58,8 @@ enum CostUsageCacheIO { if let decoded = self.loadCache( at: url, expectedProducerKey: expectedProducerKey, - compatibleProducerKeys: compatibleProducerKeys) + compatibleProducerKeys: compatibleProducerKeys, + maxBytes: maxCacheBytes) { if let calendar, decoded.timeZoneIdentifier != calendar.timeZone.identifier { return CostUsageCache() @@ -59,10 +72,11 @@ enum CostUsageCacheIO { static func loadCodexForMigration( cacheRoot: URL? = nil, producerKey: String? = nil, - calendar: Calendar? = nil) -> CostUsageCodexCacheLoadResult + calendar: Calendar? = nil, + maxCacheBytes: Int = CostUsageCacheIO.maxCacheLoadBytes) -> CostUsageCodexCacheLoadResult { let url = self.cacheFileURL(provider: .codex, cacheRoot: cacheRoot) - guard let decoded = self.decodeCache(at: url) else { + guard let decoded = self.decodeCache(at: url, maxBytes: maxCacheBytes) else { return CostUsageCodexCacheLoadResult(cache: CostUsageCache(), incompatibleCache: nil) } if let calendar, decoded.timeZoneIdentifier != calendar.timeZone.identifier { @@ -89,9 +103,10 @@ enum CostUsageCacheIO { private static func loadCache( at url: URL, expectedProducerKey: String?, - compatibleProducerKeys: Set) -> CostUsageCache? + compatibleProducerKeys: Set, + maxBytes: Int) -> CostUsageCache? { - guard let decoded = self.decodeCache(at: url) else { return nil } + guard let decoded = self.decodeCache(at: url, maxBytes: maxBytes) else { return nil } if let expectedProducerKey { guard decoded.producerKey == expectedProducerKey || decoded.producerKey.map(compatibleProducerKeys.contains) == true @@ -100,7 +115,10 @@ enum CostUsageCacheIO { return decoded } - private static func decodeCache(at url: URL) -> CostUsageCache? { + private static func decodeCache(at url: URL, maxBytes: Int) -> CostUsageCache? { + let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)? + .int64Value ?? 0 + guard fileSize <= maxBytes else { return nil } guard let data = try? Data(contentsOf: url) else { return nil } guard let decoded = try? JSONDecoder().decode(CostUsageCache.self, from: data) else { return nil } @@ -113,7 +131,9 @@ enum CostUsageCacheIO { cache: CostUsageCache, cacheRoot: URL? = nil, producerKey: String? = nil, - calendar: Calendar = .current) + calendar: Calendar = .current, + maxCacheBytes: Int = CostUsageCacheIO.maxCacheFileBytes, + maxCacheEntries: Int = CostUsageCacheIO.maxCacheFileEntries) { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) let dir = url.deletingLastPathComponent() @@ -123,10 +143,106 @@ enum CostUsageCacheIO { cache.producerKey = producerKey ?? self.currentProducerKey(provider: provider) cache.timeZoneIdentifier = calendar.timeZone.identifier - let data = (try? JSONEncoder().encode(cache)) ?? Data() + if provider == .codex { + Self.pruneCodexCacheForBudget( + &cache, + maxCacheBytes: maxCacheBytes, + maxCacheEntries: maxCacheEntries, + previousArtifactBytes: Self.fileSize(at: url)) + } + + var data = (try? JSONEncoder().encode(cache)) ?? Data() + if data.count > maxCacheBytes { + Self.stripCodexTokenDetailForBudget(&cache) + data = (try? JSONEncoder().encode(cache)) ?? Data() + } try? data.write(to: url, options: [.atomic]) } + /// Bounds the Codex cache artifact when the corpus has outgrown the persistence budget. + /// The all-time accumulation lives in per-file entries whose usage days fall outside the + /// current scan window; the current report never reads those entries, and dropping them + /// (with the same day-aggregate subtraction the scanner uses) keeps the artifact from + /// growing without limit. Priority turn IDs outside the window are only consulted for + /// in-window rows, so they are trimmed to the window as well. + private static func pruneCodexCacheForBudget( + _ cache: inout CostUsageCache, + maxCacheBytes: Int, + maxCacheEntries: Int, + previousArtifactBytes: Int64?) + { + guard let sinceKey = cache.scanSinceKey, let untilKey = cache.scanUntilKey else { return } + let overBudget = cache.files.count > maxCacheEntries + || (previousArtifactBytes ?? 0) > Int64(maxCacheBytes) + guard overBudget else { return } + + let outOfWindowKeys = cache.files.keys.filter { key in + guard let usage = cache.files[key] else { return false } + return !usage.touchesCodexScanWindow(sinceKey: sinceKey, untilKey: untilKey) + } + for key in outOfWindowKeys { + guard let old = cache.files.removeValue(forKey: key) else { continue } + CostUsageScanner.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) + } + + let inWindow: (String) -> Bool = { key in + CostUsageScanner.CostUsageDayRange.isInRange( + dayKey: key, + since: sinceKey, + until: untilKey) + } + if let idsByDay = cache.codexPriorityTurnIDsByDay { + let trimmed = idsByDay.filter { inWindow($0.key) } + cache.codexPriorityTurnIDsByDay = trimmed.isEmpty ? nil : trimmed + } + if let turnKeys = cache.codexPriorityTurnKeys { + let trimmed = turnKeys.filter { inWindow($0.key) } + cache.codexPriorityTurnKeys = trimmed.isEmpty ? nil : trimmed + } + } + + /// Last-resort trim for in-window corpora that still exceed the byte budget. Strips + /// fork-baseline token snapshots and divergence bookkeeping from files whose newest + /// usage day is outside the most recent week. These fields are optimizations with + /// on-disk re-read fallbacks; day aggregates, totals, rows, and cost data are kept, + /// so reports and pricing remain correct. + private static func stripCodexTokenDetailForBudget(_ cache: inout CostUsageCache) { + guard let untilKey = cache.scanUntilKey, + let cutoffKey = dayKey(untilKey, addingDays: -7) + else { return } + for (path, var usage) in cache.files { + let newestDay = usage.days.keys.max() + guard let newestDay, newestDay < cutoffKey else { continue } + usage.codexTokenSnapshots = nil + usage.codexTokenCheckpoints = nil + usage.codexTokenTimestampsMonotonic = nil + usage.codexTokenIndexAnchor = nil + usage.seenRawTotals = nil + usage.hasDivergentTotals = nil + usage.hasInterleavedTotals = nil + usage.lastRawTotalsBaseline = nil + usage.lastRawTotalsWatermark = nil + cache.files[path] = usage + } + } + + private static func dayKey(_ key: String, addingDays days: Int) -> String? { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone.current + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + guard let date = formatter.date(from: key), + let shifted = calendar.date(byAdding: .day, value: days, to: date) + else { return nil } + return formatter.string(from: shifted) + } + + private static func fileSize(at url: URL) -> Int64? { + (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?.int64Value + } + static func currentProducerKey( provider: UsageProvider, parserHash: String = CodexParserHash.value) -> String? diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index ddd3a1af94..e067cdd395 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -254,6 +254,206 @@ struct CostUsageCacheTests { #expect(CostUsageCacheIO.currentProducerKey(provider: .codex) == "codex:cu:p\(hash)") } + @Test + func `save prunes out-of-window files when over the entry budget`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + cache.days = [ + "2026-06-10": ["gpt-5.5": [10, 0, 0]], + "2026-06-20": ["gpt-5.5": [20, 0, 0]], + "2026-04-10": ["gpt-5.5": [99, 0, 0]], + ] + cache.files = [ + "/sessions/2026-06-10.jsonl": CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-10": ["gpt-5.5": [10, 0, 0]]]), + "/sessions/2026-06-20.jsonl": CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-20": ["gpt-5.5": [20, 0, 0]]]), + "/sessions/2026-04-10.jsonl": CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-04-10": ["gpt-5.5": [99, 0, 0]]]), + ] + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + maxCacheEntries: 2) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + #expect(loaded.files.keys.sorted() == [ + "/sessions/2026-06-10.jsonl", + "/sessions/2026-06-20.jsonl", + ]) + #expect(loaded.days["2026-04-10"] == nil) + #expect(loaded.days["2026-06-10"]?["gpt-5.5"] == [10, 0, 0]) + #expect(loaded.days["2026-06-20"]?["gpt-5.5"] == [20, 0, 0]) + } + + @Test + func `save prunes out-of-window files when the previous artifact exceeds the byte budget`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let url = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data(repeating: 0x20, count: 4096).write(to: url) + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + cache.days = [ + "2026-06-10": ["gpt-5.5": [1, 0, 0]], + "2026-04-10": ["gpt-5.5": [9, 0, 0]], + ] + cache.files = [ + "/sessions/2026-06-10.jsonl": CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-10": ["gpt-5.5": [1, 0, 0]]]), + "/sessions/2026-04-10.jsonl": CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-04-10": ["gpt-5.5": [9, 0, 0]]]), + ] + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + maxCacheBytes: 1024, + maxCacheEntries: 100) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + #expect(Array(loaded.files.keys) == ["/sessions/2026-06-10.jsonl"]) + #expect(loaded.days["2026-04-10"] == nil) + #expect(loaded.days["2026-06-10"]?["gpt-5.5"] == [1, 0, 0]) + } + + @Test + func `save never drops in-window files even when over the entry budget`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + cache.days = [ + "2026-06-10": ["gpt-5.5": [1, 0, 0]], + "2026-06-20": ["gpt-5.5": [2, 0, 0]], + "2026-06-28": ["gpt-5.5": [3, 0, 0]], + ] + for (index, day) in ["2026-06-10", "2026-06-20", "2026-06-28"].enumerated() { + cache.files["/sessions/\(day).jsonl"] = CostUsageFileUsage( + mtimeUnixMs: Int64(index), + size: 100, + days: [day: ["gpt-5.5": [index + 1, 0, 0]]]) + } + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + maxCacheEntries: 2) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + #expect(loaded.files.count == 3) + #expect(loaded.days.count == 3) + } + + @Test + func `load refuses oversized cache artifacts`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let url = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + var payload = Data( + #"{"version":1,"producerKey":"codex:cu:p1111111111111111","files":{},"days":{}}"#.utf8) + payload.append(Data(repeating: 0x20, count: 2048)) + try payload.write(to: url) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + maxCacheBytes: 1024) + + #expect(loaded.files.isEmpty) + #expect(loaded.days.isEmpty) + } + + @Test + func `over budget save strips stale token snapshots but keeps recent ones`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-06-30" + let snapshot = CostUsageCodexTokenSnapshot( + timestamp: "2026-06-05T00:00:00Z", + last: nil, + total: CostUsageCodexTotals(input: 1, cached: 0, output: 0)) + var old = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-05": ["gpt-5.5": [1, 0, 0]]]) + old.codexTokenSnapshots = [snapshot] + old.seenRawTotals = [CostUsageCodexTotals(input: 1, cached: 0, output: 0)] + var recent = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-28": ["gpt-5.5": [1, 0, 0]]]) + recent.codexTokenSnapshots = [snapshot] + cache.files = ["/sessions/old.jsonl": old, "/sessions/recent.jsonl": recent] + cache.days = [ + "2026-06-05": ["gpt-5.5": [1, 0, 0]], + "2026-06-28": ["gpt-5.5": [1, 0, 0]], + ] + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + maxCacheBytes: 64, + maxCacheEntries: 100) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + #expect(loaded.files["/sessions/old.jsonl"]?.codexTokenSnapshots == nil) + #expect(loaded.files["/sessions/old.jsonl"]?.seenRawTotals == nil) + #expect(loaded.files["/sessions/recent.jsonl"]?.codexTokenSnapshots == [snapshot]) + #expect(loaded.days["2026-06-05"]?["gpt-5.5"] == [1, 0, 0]) + } + private func makeTemporaryCacheRoot() throws -> URL { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-cost-cache-\(UUID().uuidString)", isDirectory: true) From 2fb4d4ca6bb88f3a87bdaac8b02bbf96a2f188e9 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:15:08 +0800 Subject: [PATCH 02/22] Preserve resumable and fork-dependent cache entries --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 61 +++--------- Tests/CodexBarTests/CostUsageCacheTests.swift | 98 ++++++++++++++----- 3 files changed, 92 insertions(+), 69 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index e18b2f21da..4a205d79a5 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 = "8456c68643ab5e5e" + static let value = "d27b71418b4f56ed" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index ea05cae426..17732cec70 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -151,11 +151,7 @@ enum CostUsageCacheIO { previousArtifactBytes: Self.fileSize(at: url)) } - var data = (try? JSONEncoder().encode(cache)) ?? Data() - if data.count > maxCacheBytes { - Self.stripCodexTokenDetailForBudget(&cache) - data = (try? JSONEncoder().encode(cache)) ?? Data() - } + let data = (try? JSONEncoder().encode(cache)) ?? Data() try? data.write(to: url, options: [.atomic]) } @@ -163,8 +159,10 @@ enum CostUsageCacheIO { /// The all-time accumulation lives in per-file entries whose usage days fall outside the /// current scan window; the current report never reads those entries, and dropping them /// (with the same day-aggregate subtraction the scanner uses) keeps the artifact from - /// growing without limit. Priority turn IDs outside the window are only consulted for - /// in-window rows, so they are trimmed to the window as well. + /// growing without limit. Entries that are still resuming or that in-window forks depend + /// on are preserved so bounded scans and fork baselines keep making progress. Priority + /// turn IDs outside the window are only consulted for in-window rows, so they are trimmed + /// to the window as well. private static func pruneCodexCacheForBudget( _ cache: inout CostUsageCache, maxCacheBytes: Int, @@ -176,9 +174,18 @@ enum CostUsageCacheIO { || (previousArtifactBytes ?? 0) > Int64(maxCacheBytes) guard overBudget else { return } + let neededParentSessionIDs = Set(cache.files.values.compactMap(\.forkedFromId)) let outOfWindowKeys = cache.files.keys.filter { key in guard let usage = cache.files[key] else { return false } - return !usage.touchesCodexScanWindow(sinceKey: sinceKey, untilKey: untilKey) + if usage.touchesCodexScanWindow(sinceKey: sinceKey, untilKey: untilKey) { return false } + if usage.codexScanComplete == false { return false } + if usage.codexJSONLResumeState != nil { return false } + if usage.codexScanFileId != nil { return false } + if usage.hasBufferedCodexForkRetryLines { return false } + if let sessionId = usage.sessionId, neededParentSessionIDs.contains(sessionId) { + return false + } + return true } for key in outOfWindowKeys { guard let old = cache.files.removeValue(forKey: key) else { continue } @@ -201,44 +208,6 @@ enum CostUsageCacheIO { } } - /// Last-resort trim for in-window corpora that still exceed the byte budget. Strips - /// fork-baseline token snapshots and divergence bookkeeping from files whose newest - /// usage day is outside the most recent week. These fields are optimizations with - /// on-disk re-read fallbacks; day aggregates, totals, rows, and cost data are kept, - /// so reports and pricing remain correct. - private static func stripCodexTokenDetailForBudget(_ cache: inout CostUsageCache) { - guard let untilKey = cache.scanUntilKey, - let cutoffKey = dayKey(untilKey, addingDays: -7) - else { return } - for (path, var usage) in cache.files { - let newestDay = usage.days.keys.max() - guard let newestDay, newestDay < cutoffKey else { continue } - usage.codexTokenSnapshots = nil - usage.codexTokenCheckpoints = nil - usage.codexTokenTimestampsMonotonic = nil - usage.codexTokenIndexAnchor = nil - usage.seenRawTotals = nil - usage.hasDivergentTotals = nil - usage.hasInterleavedTotals = nil - usage.lastRawTotalsBaseline = nil - usage.lastRawTotalsWatermark = nil - cache.files[path] = usage - } - } - - private static func dayKey(_ key: String, addingDays days: Int) -> String? { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone.current - let formatter = DateFormatter() - formatter.calendar = calendar - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd" - guard let date = formatter.date(from: key), - let shifted = calendar.date(byAdding: .day, value: days, to: date) - else { return nil } - return formatter.string(from: shifted) - } - private static func fileSize(at url: URL) -> Int64? { (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?.int64Value } diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index e067cdd395..7d8f61488a 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -408,32 +408,37 @@ struct CostUsageCacheTests { } @Test - func `over budget save strips stale token snapshots but keeps recent ones`() throws { + func `save preserves out-of-window fork parents during budget pruning`() throws { let root = try self.makeTemporaryCacheRoot() defer { try? FileManager.default.removeItem(at: root) } var cache = CostUsageCache() cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-06-30" - let snapshot = CostUsageCodexTokenSnapshot( - timestamp: "2026-06-05T00:00:00Z", - last: nil, - total: CostUsageCodexTotals(input: 1, cached: 0, output: 0)) - var old = CostUsageFileUsage( + cache.scanUntilKey = "2026-07-01" + var parent = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-04-10": ["gpt-5.5": [1, 0, 0]]]) + parent.sessionId = "parent-session" + var unrelated = CostUsageFileUsage( mtimeUnixMs: 1, size: 100, - days: ["2026-06-05": ["gpt-5.5": [1, 0, 0]]]) - old.codexTokenSnapshots = [snapshot] - old.seenRawTotals = [CostUsageCodexTotals(input: 1, cached: 0, output: 0)] - var recent = CostUsageFileUsage( + days: ["2026-04-11": ["gpt-5.5": [1, 0, 0]]]) + unrelated.sessionId = "unrelated-session" + var child = CostUsageFileUsage( mtimeUnixMs: 1, size: 100, - days: ["2026-06-28": ["gpt-5.5": [1, 0, 0]]]) - recent.codexTokenSnapshots = [snapshot] - cache.files = ["/sessions/old.jsonl": old, "/sessions/recent.jsonl": recent] + days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) + child.forkedFromId = "parent-session" + cache.files = [ + "/sessions/parent.jsonl": parent, + "/sessions/unrelated.jsonl": unrelated, + "/sessions/child.jsonl": child, + ] cache.days = [ - "2026-06-05": ["gpt-5.5": [1, 0, 0]], - "2026-06-28": ["gpt-5.5": [1, 0, 0]], + "2026-04-10": ["gpt-5.5": [1, 0, 0]], + "2026-04-11": ["gpt-5.5": [1, 0, 0]], + "2026-06-20": ["gpt-5.5": [1, 0, 0]], ] CostUsageCacheIO.save( @@ -441,17 +446,66 @@ struct CostUsageCacheTests { cache: cache, cacheRoot: root, producerKey: "codex:cu:p1111111111111111", - maxCacheBytes: 64, - maxCacheEntries: 100) + maxCacheEntries: 2) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + #expect(loaded.files["/sessions/parent.jsonl"] != nil) + #expect(loaded.files["/sessions/unrelated.jsonl"] == nil) + #expect(loaded.files["/sessions/child.jsonl"] != nil) + #expect(loaded.days["2026-04-10"]?["gpt-5.5"] == [1, 0, 0]) + #expect(loaded.days["2026-04-11"] == nil) + } + + func `save preserves out-of-window entries that are still resuming`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + var incomplete = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-04-10": ["gpt-5.5": [1, 0, 0]]]) + incomplete.codexScanComplete = false + var inProgress = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-04-11": ["gpt-5.5": [1, 0, 0]]]) + inProgress.codexScanFileId = "scan-id" + var settled = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-04-12": ["gpt-5.5": [1, 0, 0]]]) + cache.files = [ + "/sessions/incomplete.jsonl": incomplete, + "/sessions/in-progress.jsonl": inProgress, + "/sessions/settled.jsonl": settled, + ] + cache.days = [ + "2026-04-10": ["gpt-5.5": [1, 0, 0]], + "2026-04-11": ["gpt-5.5": [1, 0, 0]], + "2026-04-12": ["gpt-5.5": [1, 0, 0]], + ] + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + maxCacheEntries: 2) let loaded = CostUsageCacheIO.load( provider: .codex, cacheRoot: root, producerKey: "codex:cu:p1111111111111111") - #expect(loaded.files["/sessions/old.jsonl"]?.codexTokenSnapshots == nil) - #expect(loaded.files["/sessions/old.jsonl"]?.seenRawTotals == nil) - #expect(loaded.files["/sessions/recent.jsonl"]?.codexTokenSnapshots == [snapshot]) - #expect(loaded.days["2026-06-05"]?["gpt-5.5"] == [1, 0, 0]) + #expect(loaded.files["/sessions/incomplete.jsonl"] != nil) + #expect(loaded.files["/sessions/in-progress.jsonl"] != nil) + #expect(loaded.files["/sessions/settled.jsonl"] == nil) + #expect(loaded.days["2026-04-12"] == nil) } private func makeTemporaryCacheRoot() throws -> URL { From 88aa138a29863972ddfc7c8d1eae7dfad0d3e46b Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:29:52 +0800 Subject: [PATCH 03/22] Prune cache against requested window --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 16 ++++- .../Vendored/CostUsage/CostUsageScanner.swift | 3 +- Tests/CodexBarTests/CostUsageCacheTests.swift | 66 +++++++++++++++++-- 4 files changed, 78 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 4a205d79a5..d6875b213a 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 = "d27b71418b4f56ed" + static let value = "3c03b1d5045174e2" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 17732cec70..997ba5369f 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -132,6 +132,7 @@ enum CostUsageCacheIO { cacheRoot: URL? = nil, producerKey: String? = nil, calendar: Calendar = .current, + requestedScanWindow: (sinceKey: String, untilKey: String)? = nil, maxCacheBytes: Int = CostUsageCacheIO.maxCacheFileBytes, maxCacheEntries: Int = CostUsageCacheIO.maxCacheFileEntries) { @@ -146,6 +147,7 @@ enum CostUsageCacheIO { if provider == .codex { Self.pruneCodexCacheForBudget( &cache, + requestedScanWindow: requestedScanWindow, maxCacheBytes: maxCacheBytes, maxCacheEntries: maxCacheEntries, previousArtifactBytes: Self.fileSize(at: url)) @@ -165,11 +167,16 @@ enum CostUsageCacheIO { /// to the window as well. private static func pruneCodexCacheForBudget( _ cache: inout CostUsageCache, + requestedScanWindow: (sinceKey: String, untilKey: String)?, maxCacheBytes: Int, maxCacheEntries: Int, previousArtifactBytes: Int64?) { - guard let sinceKey = cache.scanSinceKey, let untilKey = cache.scanUntilKey else { return } + // Prune against the active requested scan window (what the current report reads), + // not the historically widened retained union persisted in the cache. + let sinceKey = requestedScanWindow?.sinceKey ?? cache.scanSinceKey + let untilKey = requestedScanWindow?.untilKey ?? cache.scanUntilKey + guard let sinceKey, let untilKey else { return } let overBudget = cache.files.count > maxCacheEntries || (previousArtifactBytes ?? 0) > Int64(maxCacheBytes) guard overBudget else { return } @@ -180,7 +187,6 @@ enum CostUsageCacheIO { if usage.touchesCodexScanWindow(sinceKey: sinceKey, untilKey: untilKey) { return false } if usage.codexScanComplete == false { return false } if usage.codexJSONLResumeState != nil { return false } - if usage.codexScanFileId != nil { return false } if usage.hasBufferedCodexForkRetryLines { return false } if let sessionId = usage.sessionId, neededParentSessionIDs.contains(sessionId) { return false @@ -191,6 +197,12 @@ enum CostUsageCacheIO { guard let old = cache.files.removeValue(forKey: key) else { continue } CostUsageScanner.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) } + if !outOfWindowKeys.isEmpty, requestedScanWindow != nil { + // Entries outside the requested window are gone; narrow persisted coverage so a + // later refresh does not treat them as in-window again. + cache.scanSinceKey = requestedScanWindow?.sinceKey ?? cache.scanSinceKey + cache.scanUntilKey = requestedScanWindow?.untilKey ?? cache.scanUntilKey + } let inWindow: (String) -> Bool = { key in CostUsageScanner.CostUsageDayRange.isInRange( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 853f54dd88..c93652bfc0 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -4462,7 +4462,8 @@ enum CostUsageScanner { provider: .codex, cache: cache, cacheRoot: options.cacheRoot, - calendar: range.calendar) + calendar: range.calendar, + requestedScanWindow: (sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey)) } // swiftlint:disable:next function_body_length diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 7d8f61488a..2fec8d9465 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -459,6 +459,7 @@ struct CostUsageCacheTests { #expect(loaded.days["2026-04-11"] == nil) } + @Test func `save preserves out-of-window entries that are still resuming`() throws { let root = try self.makeTemporaryCacheRoot() defer { try? FileManager.default.removeItem(at: root) } @@ -471,23 +472,36 @@ struct CostUsageCacheTests { size: 100, days: ["2026-04-10": ["gpt-5.5": [1, 0, 0]]]) incomplete.codexScanComplete = false - var inProgress = CostUsageFileUsage( + var bufferedForkRetry = CostUsageFileUsage( mtimeUnixMs: 1, size: 100, days: ["2026-04-11": ["gpt-5.5": [1, 0, 0]]]) - inProgress.codexScanFileId = "scan-id" + bufferedForkRetry.codexBufferedSubagentLines = [ + CostUsageScanner.CodexBufferedFastLine( + lineIndex: 0, + ordinal: nil, + line: .taskStarted(turnID: nil)), + ] + var completedWithScanID = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-04-13": ["gpt-5.5": [1, 0, 0]]]) + completedWithScanID.codexScanFileId = "scan-id" + completedWithScanID.codexScanComplete = true var settled = CostUsageFileUsage( mtimeUnixMs: 1, size: 100, days: ["2026-04-12": ["gpt-5.5": [1, 0, 0]]]) cache.files = [ "/sessions/incomplete.jsonl": incomplete, - "/sessions/in-progress.jsonl": inProgress, + "/sessions/buffered-fork-retry.jsonl": bufferedForkRetry, + "/sessions/completed-with-scan-id.jsonl": completedWithScanID, "/sessions/settled.jsonl": settled, ] cache.days = [ "2026-04-10": ["gpt-5.5": [1, 0, 0]], "2026-04-11": ["gpt-5.5": [1, 0, 0]], + "2026-04-13": ["gpt-5.5": [1, 0, 0]], "2026-04-12": ["gpt-5.5": [1, 0, 0]], ] @@ -496,18 +510,60 @@ struct CostUsageCacheTests { cache: cache, cacheRoot: root, producerKey: "codex:cu:p1111111111111111", - maxCacheEntries: 2) + maxCacheEntries: 3) let loaded = CostUsageCacheIO.load( provider: .codex, cacheRoot: root, producerKey: "codex:cu:p1111111111111111") #expect(loaded.files["/sessions/incomplete.jsonl"] != nil) - #expect(loaded.files["/sessions/in-progress.jsonl"] != nil) + #expect(loaded.files["/sessions/buffered-fork-retry.jsonl"] != nil) + #expect(loaded.files["/sessions/completed-with-scan-id.jsonl"] == nil) #expect(loaded.files["/sessions/settled.jsonl"] == nil) #expect(loaded.days["2026-04-12"] == nil) } + @Test + func `save prunes against the requested window and narrows persisted coverage`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-01-01" + cache.scanUntilKey = "2026-07-01" + cache.days = [ + "2026-02-10": ["gpt-5.5": [1, 0, 0]], + "2026-06-20": ["gpt-5.5": [2, 0, 0]], + "2026-06-28": ["gpt-5.5": [3, 0, 0]], + ] + for (index, day) in ["2026-02-10", "2026-06-20", "2026-06-28"].enumerated() { + cache.files["/sessions/\(day).jsonl"] = CostUsageFileUsage( + mtimeUnixMs: Int64(index), + size: 100, + days: [day: ["gpt-5.5": [index + 1, 0, 0]]]) + } + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), + maxCacheEntries: 2) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + #expect(Array(loaded.files.keys).sorted() == [ + "/sessions/2026-06-20.jsonl", + "/sessions/2026-06-28.jsonl", + ]) + #expect(loaded.days["2026-02-10"] == nil) + #expect(loaded.scanSinceKey == "2026-06-01") + #expect(loaded.scanUntilKey == "2026-07-01") + } + private func makeTemporaryCacheRoot() throws -> URL { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-cost-cache-\(UUID().uuidString)", isDirectory: true) From 7883c72aa53d80cac908c484ba3582868b711719 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:04:26 +0800 Subject: [PATCH 04/22] Prune candidate artifacts over byte budget --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 34 +++++++++--- Tests/CodexBarTests/CostUsageCacheTests.swift | 52 +++++++++++++++++++ 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index d6875b213a..1e19df5f55 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 = "3c03b1d5045174e2" + static let value = "50cfde7288aca3b7" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 997ba5369f..1adac2d9f7 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -144,8 +144,9 @@ enum CostUsageCacheIO { cache.producerKey = producerKey ?? self.currentProducerKey(provider: provider) cache.timeZoneIdentifier = calendar.timeZone.identifier + var pruned = false if provider == .codex { - Self.pruneCodexCacheForBudget( + pruned = Self.pruneCodexCacheForBudget( &cache, requestedScanWindow: requestedScanWindow, maxCacheBytes: maxCacheBytes, @@ -153,7 +154,23 @@ enum CostUsageCacheIO { previousArtifactBytes: Self.fileSize(at: url)) } - let data = (try? JSONEncoder().encode(cache)) ?? Data() + var data = (try? JSONEncoder().encode(cache)) ?? Data() + if !pruned, provider == .codex, data.count > maxCacheBytes { + // The candidate artifact crossed the byte budget during this refresh even though + // the previous artifact was within budget (e.g. per-file detail grew faster than + // the scan window). Prune out-of-window entries now so the oversized document is + // not written for the next refresh to decode. + pruned = Self.pruneCodexCacheForBudget( + &cache, + requestedScanWindow: requestedScanWindow, + maxCacheBytes: maxCacheBytes, + maxCacheEntries: maxCacheEntries, + previousArtifactBytes: nil, + force: true) + if pruned { + data = (try? JSONEncoder().encode(cache)) ?? Data() + } + } try? data.write(to: url, options: [.atomic]) } @@ -170,16 +187,17 @@ enum CostUsageCacheIO { requestedScanWindow: (sinceKey: String, untilKey: String)?, maxCacheBytes: Int, maxCacheEntries: Int, - previousArtifactBytes: Int64?) + previousArtifactBytes: Int64?, + force: Bool = false) -> Bool { // Prune against the active requested scan window (what the current report reads), // not the historically widened retained union persisted in the cache. let sinceKey = requestedScanWindow?.sinceKey ?? cache.scanSinceKey let untilKey = requestedScanWindow?.untilKey ?? cache.scanUntilKey - guard let sinceKey, let untilKey else { return } - let overBudget = cache.files.count > maxCacheEntries + guard let sinceKey, let untilKey else { return false } + let overBudget = force || cache.files.count > maxCacheEntries || (previousArtifactBytes ?? 0) > Int64(maxCacheBytes) - guard overBudget else { return } + guard overBudget else { return false } let neededParentSessionIDs = Set(cache.files.values.compactMap(\.forkedFromId)) let outOfWindowKeys = cache.files.keys.filter { key in @@ -210,14 +228,18 @@ enum CostUsageCacheIO { since: sinceKey, until: untilKey) } + var trimmedTurnIDs = false if let idsByDay = cache.codexPriorityTurnIDsByDay { let trimmed = idsByDay.filter { inWindow($0.key) } cache.codexPriorityTurnIDsByDay = trimmed.isEmpty ? nil : trimmed + trimmedTurnIDs = trimmed.count != idsByDay.count } if let turnKeys = cache.codexPriorityTurnKeys { let trimmed = turnKeys.filter { inWindow($0.key) } cache.codexPriorityTurnKeys = trimmed.isEmpty ? nil : trimmed + trimmedTurnIDs = trimmedTurnIDs || trimmed.count != turnKeys.count } + return !outOfWindowKeys.isEmpty || trimmedTurnIDs } private static func fileSize(at url: URL) -> Int64? { diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 2fec8d9465..99bb7ff32f 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -564,6 +564,58 @@ struct CostUsageCacheTests { #expect(loaded.scanUntilKey == "2026-07-01") } + @Test + func `save prunes when the candidate artifact crosses the byte budget in one refresh`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + var inWindow = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) + var staleWithDetail = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-04-10": ["gpt-5.5": [1, 0, 0]]]) + let snapshots = (0..<400).map { index in + CostUsageCodexTokenSnapshot( + timestamp: "2026-04-10T00:00:0\(index % 10)Z", + last: nil, + total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) + } + staleWithDetail.codexTokenSnapshots = snapshots + cache.files = [ + "/sessions/in-window.jsonl": inWindow, + "/sessions/stale-with-detail.jsonl": staleWithDetail, + ] + cache.days = [ + "2026-06-20": ["gpt-5.5": [1, 0, 0]], + "2026-04-10": ["gpt-5.5": [1, 0, 0]], + ] + + // The entry count is within budget and no previous artifact exists, but the + // candidate payload exceeds the tiny byte budget; pruning must still happen. + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), + maxCacheBytes: 1024, + maxCacheEntries: 100) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + #expect(Array(loaded.files.keys) == ["/sessions/in-window.jsonl"]) + #expect(loaded.days["2026-04-10"] == nil) + #expect(loaded.days["2026-06-20"]?["gpt-5.5"] == [1, 0, 0]) + } + private func makeTemporaryCacheRoot() throws -> URL { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-cost-cache-\(UUID().uuidString)", isDirectory: true) From 2ad965d031b9cf3798f61f1c3c61c41f2b774317 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:22:45 +0800 Subject: [PATCH 05/22] Scope cache guards and preserve active sessions --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 40 +++++- Tests/CodexBarTests/CostUsageCacheTests.swift | 122 ++++++++++++++++++ 3 files changed, 157 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 1e19df5f55..8fc83bc7fe 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 = "50cfde7288aca3b7" + static let value = "72f178625159f363" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 1adac2d9f7..0dfb16cd47 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -51,6 +51,9 @@ enum CostUsageCacheIO { maxCacheBytes: Int = CostUsageCacheIO.maxCacheLoadBytes) -> CostUsageCache { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) + // Only Codex has bounded persistence pruning on save; other providers would be + // rejected, rebuilt, and written oversized again on every refresh. + let effectiveMaxBytes = provider == .codex ? maxCacheBytes : Int.max let expectedProducerKey = producerKey ?? self.currentProducerKey(provider: provider) let compatibleProducerKeys = producerKey == nil && provider == .codex ? self.compatibleCodexProducerKeys @@ -59,7 +62,7 @@ enum CostUsageCacheIO { at: url, expectedProducerKey: expectedProducerKey, compatibleProducerKeys: compatibleProducerKeys, - maxBytes: maxCacheBytes) + maxBytes: effectiveMaxBytes) { if let calendar, decoded.timeZoneIdentifier != calendar.timeZone.identifier { return CostUsageCache() @@ -199,18 +202,24 @@ enum CostUsageCacheIO { || (previousArtifactBytes ?? 0) > Int64(maxCacheBytes) guard overBudget else { return false } - let neededParentSessionIDs = Set(cache.files.values.compactMap(\.forkedFromId)) - let outOfWindowKeys = cache.files.keys.filter { key in + let outOfWindowCandidates = cache.files.keys.filter { key in guard let usage = cache.files[key] else { return false } if usage.touchesCodexScanWindow(sinceKey: sinceKey, untilKey: untilKey) { return false } if usage.codexScanComplete == false { return false } if usage.codexJSONLResumeState != nil { return false } if usage.hasBufferedCodexForkRetryLines { return false } - if let sessionId = usage.sessionId, neededParentSessionIDs.contains(sessionId) { - return false - } + if Self.isRecentlyActive(usage, sinceKey: sinceKey, untilKey: untilKey) { return false } return true } + // Protect parents referenced by entries that survive pruning. A stale child that is + // removed in this pass must not keep its stale parent alive. + let survivingKeys = Set(cache.files.keys).subtracting(outOfWindowCandidates) + let survivingParentSessionIDs = Set( + survivingKeys.compactMap { cache.files[$0]?.forkedFromId }) + let outOfWindowKeys = outOfWindowCandidates.filter { key in + guard let sessionId = cache.files[key]?.sessionId else { return true } + return !survivingParentSessionIDs.contains(sessionId) + } for key in outOfWindowKeys { guard let old = cache.files.removeValue(forKey: key) else { continue } CostUsageScanner.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) @@ -242,6 +251,25 @@ enum CostUsageCacheIO { return !outOfWindowKeys.isEmpty || trimmedTurnIDs } + /// A session file whose modification time falls inside the scan window is active even + /// when it has produced no usage rows yet (e.g. a session started today); dropping it + /// would make every refresh rediscover and fully parse it. + private static func isRecentlyActive( + _ usage: CostUsageFileUsage, + sinceKey: String, + untilKey: String) -> Bool + { + guard usage.mtimeUnixMs > 0 else { return false } + let calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar(matching: .current) + let mtimeDayKey = CostUsageScanner.CostUsageDayRange.dayKey( + from: Date(timeIntervalSince1970: TimeInterval(usage.mtimeUnixMs) / 1000), + calendar: calendar) + return CostUsageScanner.CostUsageDayRange.isInRange( + dayKey: mtimeDayKey, + since: sinceKey, + until: untilKey) + } + private static func fileSize(at url: URL) -> Int64? { (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?.int64Value } diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 99bb7ff32f..eed8966762 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -616,6 +616,128 @@ struct CostUsageCacheTests { #expect(loaded.days["2026-06-20"]?["gpt-5.5"] == [1, 0, 0]) } + @Test + func `load cap applies only to codex`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let url = CostUsageCacheIO.cacheFileURL(provider: .claude, cacheRoot: root) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + var payload = Data( + #"{"version":1,"lastScanUnixMs":999,"files":{},"days":{}}"#.utf8) + payload.append(Data(repeating: 0x20, count: 2048)) + try payload.write(to: url) + + // The load cap guards Codex's bounded-rebuild path only; Claude/Vertex caches are + // not pruned on save, so rejecting them would cause a rebuild loop. + let loaded = CostUsageCacheIO.load( + provider: .claude, + cacheRoot: root, + maxCacheBytes: 1024) + + #expect(loaded.lastScanUnixMs == 999) + } + + @Test + func `save drops stale parents referenced only by stale children`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + var parent = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-04-10": ["gpt-5.5": [1, 0, 0]]]) + parent.sessionId = "parent-session" + var staleChild = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-04-11": ["gpt-5.5": [1, 0, 0]]]) + staleChild.sessionId = "child-session" + staleChild.forkedFromId = "parent-session" + cache.files = [ + "/sessions/parent.jsonl": parent, + "/sessions/stale-child.jsonl": staleChild, + ] + cache.days = [ + "2026-04-10": ["gpt-5.5": [1, 0, 0]], + "2026-04-11": ["gpt-5.5": [1, 0, 0]], + ] + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + maxCacheEntries: 1) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + #expect(loaded.files.isEmpty) + #expect(loaded.days.isEmpty) + } + + @Test + func `save retains recently active zero-day session entries`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone.current + var components = DateComponents() + components.calendar = calendar + components.timeZone = calendar.timeZone + components.year = 2026 + components.month = 6 + components.day = 25 + components.hour = 12 + let activeMtime = Int64( + (calendar.date(from: components) ?? Date()).timeIntervalSince1970 * 1000) + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + var activeZeroDay = CostUsageFileUsage( + mtimeUnixMs: activeMtime, + size: 100, + days: ["2026-04-10": ["gpt-5.5": [1, 0, 0]]]) + activeZeroDay.sessionId = "active-session" + var inactive = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-04-11": ["gpt-5.5": [1, 0, 0]]]) + inactive.sessionId = "inactive-session" + cache.files = [ + "/sessions/active-zero-day.jsonl": activeZeroDay, + "/sessions/inactive.jsonl": inactive, + ] + cache.days = [ + "2026-04-10": ["gpt-5.5": [1, 0, 0]], + "2026-04-11": ["gpt-5.5": [1, 0, 0]], + ] + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + maxCacheEntries: 1) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + #expect(loaded.files["/sessions/active-zero-day.jsonl"] != nil) + #expect(loaded.files["/sessions/inactive.jsonl"] == nil) + #expect(loaded.days["2026-04-10"]?["gpt-5.5"] == [1, 0, 0]) + } + private func makeTemporaryCacheRoot() throws -> URL { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-cost-cache-\(UUID().uuidString)", isDirectory: true) From 8cd629ef00a64137de1af38d47790e65536cddcc Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:49:17 +0800 Subject: [PATCH 06/22] Keep cache loadable under byte budget --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 153 ++++++++++++++++-- Tests/CodexBarTests/CostUsageCacheTests.swift | 52 ++++++ 3 files changed, 192 insertions(+), 15 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 8fc83bc7fe..056737b2da 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 = "72f178625159f363" + static let value = "00fdf1366a5bc1f5" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 0dfb16cd47..0fc7a7c55f 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -147,36 +147,54 @@ enum CostUsageCacheIO { cache.producerKey = producerKey ?? self.currentProducerKey(provider: provider) cache.timeZoneIdentifier = calendar.timeZone.identifier - var pruned = false if provider == .codex { - pruned = Self.pruneCodexCacheForBudget( + Self.pruneCodexCacheForBudget( &cache, requestedScanWindow: requestedScanWindow, + calendar: calendar, maxCacheBytes: maxCacheBytes, maxCacheEntries: maxCacheEntries, previousArtifactBytes: Self.fileSize(at: url)) + // Estimate before materializing the document so a refresh that grew the cache + // stays bounded even when the previous artifact was within budget. + if Self.estimatedCodexCacheBytes(cache) > maxCacheBytes { + Self.pruneCodexCacheForBudget( + &cache, + requestedScanWindow: requestedScanWindow, + calendar: calendar, + maxCacheBytes: maxCacheBytes, + maxCacheEntries: maxCacheEntries, + previousArtifactBytes: nil, + force: true) + Self.trimInWindowEntriesForBudget( + &cache, + calendar: calendar, + maxCacheBytes: maxCacheBytes) + } } var data = (try? JSONEncoder().encode(cache)) ?? Data() - if !pruned, provider == .codex, data.count > maxCacheBytes { - // The candidate artifact crossed the byte budget during this refresh even though - // the previous artifact was within budget (e.g. per-file detail grew faster than - // the scan window). Prune out-of-window entries now so the oversized document is - // not written for the next refresh to decode. - pruned = Self.pruneCodexCacheForBudget( + if provider == .codex, data.count > maxCacheBytes { + // The estimate underestimated the payload; prune again so the artifact stays + // loadable and the next refresh never hits the load-refusal rebuild loop. + Self.pruneCodexCacheForBudget( &cache, requestedScanWindow: requestedScanWindow, + calendar: calendar, maxCacheBytes: maxCacheBytes, maxCacheEntries: maxCacheEntries, previousArtifactBytes: nil, force: true) - if pruned { - data = (try? JSONEncoder().encode(cache)) ?? Data() - } + Self.trimInWindowEntriesForBudget( + &cache, + calendar: calendar, + maxCacheBytes: maxCacheBytes) + data = (try? JSONEncoder().encode(cache)) ?? Data() } try? data.write(to: url, options: [.atomic]) } + // swiftlint:disable function_parameter_count /// Bounds the Codex cache artifact when the corpus has outgrown the persistence budget. /// The all-time accumulation lives in per-file entries whose usage days fall outside the /// current scan window; the current report never reads those entries, and dropping them @@ -188,6 +206,7 @@ enum CostUsageCacheIO { private static func pruneCodexCacheForBudget( _ cache: inout CostUsageCache, requestedScanWindow: (sinceKey: String, untilKey: String)?, + calendar: Calendar, maxCacheBytes: Int, maxCacheEntries: Int, previousArtifactBytes: Int64?, @@ -208,7 +227,9 @@ enum CostUsageCacheIO { if usage.codexScanComplete == false { return false } if usage.codexJSONLResumeState != nil { return false } if usage.hasBufferedCodexForkRetryLines { return false } - if Self.isRecentlyActive(usage, sinceKey: sinceKey, untilKey: untilKey) { return false } + if Self.isRecentlyActive(usage, calendar: calendar, sinceKey: sinceKey, untilKey: untilKey) { + return false + } return true } // Protect parents referenced by entries that survive pruning. A stale child that is @@ -251,19 +272,123 @@ enum CostUsageCacheIO { return !outOfWindowKeys.isEmpty || trimmedTurnIDs } + // swiftlint:enable function_parameter_count + + /// Drops the oldest completed in-window entries until the estimated payload fits the + /// byte budget. This is the last line of defense for window-heavy corpora: dropping + /// entries (with the same day-aggregate subtraction the scanner uses) keeps the artifact + /// loadable, so the load cap never rejects what `save` can produce and refreshes cannot + /// fall into a permanent full-rebuild loop. Dropped in-window files are rediscovered and + /// rescanned by the bounded scanner on later refreshes. + private static func trimInWindowEntriesForBudget( + _ cache: inout CostUsageCache, + calendar: Calendar, + maxCacheBytes: Int) -> Bool + { + guard let sinceKey = cache.scanSinceKey, let untilKey = cache.scanUntilKey else { return false } + let protectedParentIDs = Set(cache.files.values.compactMap(\.forkedFromId)) + let candidates: [(key: String, usage: CostUsageFileUsage)] = cache.files.compactMap { key, usage in + let inWindow = usage.touchesCodexScanWindow(sinceKey: sinceKey, untilKey: untilKey) + || Self.isRecentlyActive(usage, calendar: calendar, sinceKey: sinceKey, untilKey: untilKey) + guard inWindow else { return nil } + if usage.codexScanComplete == false { return nil } + if usage.codexJSONLResumeState != nil { return nil } + if usage.hasBufferedCodexForkRetryLines { return nil } + if let sessionId = usage.sessionId, protectedParentIDs.contains(sessionId) { return nil } + return (key, usage) + } + guard !candidates.isEmpty else { return false } + + // Drop oldest usage first so recent sessions keep their fork-baseline detail. + let oldestFirst = candidates.sorted { lhs, rhs in + let lhsDay = lhs.usage.days.keys.min() ?? "9999" + let rhsDay = rhs.usage.days.keys.min() ?? "9999" + return lhsDay < rhsDay + } + var estimated = Self.estimatedCodexCacheBytes(cache) + let target = max(1, (maxCacheBytes * 3) / 4) + var droppedKeys: [String] = [] + for (index, candidate) in oldestFirst.enumerated() where estimated > target { + // Always keep at least the newest entry so the artifact retains window data even + // when a single entry alone exceeds the target. + guard index < oldestFirst.count - 1 else { break } + droppedKeys.append(candidate.key) + estimated -= Self.estimatedFileUsageBytes(candidate.usage) + } + for key in droppedKeys { + guard let old = cache.files.removeValue(forKey: key) else { continue } + CostUsageScanner.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) + } + return !droppedKeys.isEmpty + } + + /// Cheap upper-bound-ish estimate of the encoded JSON payload, used to decide whether to + /// prune before materializing the document. Deliberately conservative per-entry overhead + /// so the estimate triggers at or before the real byte budget. + private static func estimatedCodexCacheBytes(_ cache: CostUsageCache) -> Int { + var bytes = 4096 + bytes += cache.files.count * 160 + for usage in cache.files.values { + bytes += Self.estimatedFileUsageBytes(usage) + } + if let idsByDay = cache.codexPriorityTurnIDsByDay { + for (day, ids) in idsByDay { + bytes += day.count + 32 + ids.count * 48 + } + } + if let turnKeys = cache.codexPriorityTurnKeys { + for (key, value) in turnKeys { + bytes += key.count + value.count + 48 + } + } + return bytes + } + + private static func estimatedFileUsageBytes(_ usage: CostUsageFileUsage) -> Int { + var bytes = 240 + for (day, models) in usage.days { + bytes += day.count + 32 + for (model, packed) in models { + bytes += model.count + 40 + packed.count * 10 + } + } + bytes += (usage.codexRows?.count ?? 0) * 140 + bytes += (usage.codexTurnIDs?.count ?? 0) * 56 + bytes += (usage.codexTokenSnapshots?.count ?? 0) * 96 + bytes += (usage.codexTokenCheckpoints?.count ?? 0) * 84 + bytes += (usage.seenRawTotals?.count ?? 0) * 72 + for map in [ + usage.codexCostNanos, + usage.codexPrioritySurchargeNanos, + usage.codexStandardCostNanos, + usage.codexPriorityCostNanos, + ].compactMap(\.self) { + for (day, values) in map { + bytes += day.count + 32 + values.count * 72 + } + } + for map in [usage.codexStandardTokens, usage.codexPriorityTokens].compactMap(\.self) { + for (day, values) in map { + bytes += day.count + 32 + values.count * 40 + } + } + return bytes + } + /// A session file whose modification time falls inside the scan window is active even /// when it has produced no usage rows yet (e.g. a session started today); dropping it /// would make every refresh rediscover and fully parse it. private static func isRecentlyActive( _ usage: CostUsageFileUsage, + calendar: Calendar, sinceKey: String, untilKey: String) -> Bool { guard usage.mtimeUnixMs > 0 else { return false } - let calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar(matching: .current) + let scanCalendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar(matching: calendar) let mtimeDayKey = CostUsageScanner.CostUsageDayRange.dayKey( from: Date(timeIntervalSince1970: TimeInterval(usage.mtimeUnixMs) / 1000), - calendar: calendar) + calendar: scanCalendar) return CostUsageScanner.CostUsageDayRange.isInRange( dayKey: mtimeDayKey, since: sinceKey, diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index eed8966762..5c84dbbc6b 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -738,6 +738,58 @@ struct CostUsageCacheTests { #expect(loaded.days["2026-04-10"]?["gpt-5.5"] == [1, 0, 0]) } + @Test + func `save drops oldest in-window entries when the window corpus exceeds the byte budget`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + let snapshots = (0..<300).map { index in + CostUsageCodexTokenSnapshot( + timestamp: "2026-06-0\(index % 9)T00:00:0\(index % 10)Z", + last: nil, + total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) + } + var older = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-05": ["gpt-5.5": [1, 0, 0]]]) + older.codexTokenSnapshots = snapshots + var recent = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-28": ["gpt-5.5": [1, 0, 0]]]) + recent.codexTokenSnapshots = snapshots + cache.files = [ + "/sessions/older.jsonl": older, + "/sessions/recent.jsonl": recent, + ] + cache.days = [ + "2026-06-05": ["gpt-5.5": [1, 0, 0]], + "2026-06-28": ["gpt-5.5": [1, 0, 0]], + ] + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), + maxCacheBytes: 30000, + maxCacheEntries: 100) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + #expect(loaded.files["/sessions/older.jsonl"] == nil) + #expect(loaded.files["/sessions/recent.jsonl"] != nil) + #expect(loaded.days["2026-06-05"] == nil) + #expect(loaded.days["2026-06-28"]?["gpt-5.5"] == [1, 0, 0]) + } + private func makeTemporaryCacheRoot() throws -> URL { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-cost-cache-\(UUID().uuidString)", isDirectory: true) From e1894f28a17766f3d0b06f429fb5bcc5e9238303 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:19:51 +0800 Subject: [PATCH 07/22] Mark trimmed caches for catch-up and prune discovery --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 55 ++++++++ Tests/CodexBarTests/CostUsageCacheTests.swift | 121 ++++++++++++++++++ 3 files changed, 177 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 056737b2da..b61964242f 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 = "00fdf1366a5bc1f5" + static let value = "03e1c691072b3d6b" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 0fc7a7c55f..62c4b36de3 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -241,10 +241,19 @@ enum CostUsageCacheIO { guard let sessionId = cache.files[key]?.sessionId else { return true } return !survivingParentSessionIDs.contains(sessionId) } + var removedPaths: Set = [] + var removedSessionIDs: Set = [] for key in outOfWindowKeys { guard let old = cache.files.removeValue(forKey: key) else { continue } + removedPaths.insert(key) + if let sessionId = old.sessionId { + removedSessionIDs.insert(sessionId) + } CostUsageScanner.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) } + if !removedPaths.isEmpty { + Self.pruneDiscovery(&cache, removedPaths: removedPaths, removedSessionIDs: removedSessionIDs) + } if !outOfWindowKeys.isEmpty, requestedScanWindow != nil { // Entries outside the requested window are gone; narrow persisted coverage so a // later refresh does not treat them as in-window again. @@ -315,13 +324,50 @@ enum CostUsageCacheIO { droppedKeys.append(candidate.key) estimated -= Self.estimatedFileUsageBytes(candidate.usage) } + var removedPaths: Set = [] + var removedSessionIDs: Set = [] for key in droppedKeys { guard let old = cache.files.removeValue(forKey: key) else { continue } + removedPaths.insert(key) + if let sessionId = old.sessionId { + removedSessionIDs.insert(sessionId) + } CostUsageScanner.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) } + if !removedPaths.isEmpty { + Self.pruneDiscovery(&cache, removedPaths: removedPaths, removedSessionIDs: removedSessionIDs) + // Dropped in-window entries would under-report until the next refresh; mark the + // cache as needing catch-up so a cold restart re-scans them promptly. + cache.codexScanCatchUpPending = true + cache.lastScanUnixMs = 0 + } return !droppedKeys.isEmpty } + /// Removes discovery records for session files that were pruned from `files` so the + /// persisted discovery state stays bounded with the artifact. + private static func pruneDiscovery( + _ cache: inout CostUsageCache, + removedPaths: Set, + removedSessionIDs: Set) + { + guard var discovery = cache.codexSessionDiscovery, !removedPaths.isEmpty else { return } + discovery.filePaths.removeAll { removedPaths.contains($0) } + discovery.fileStamps = discovery.fileStamps.filter { !removedPaths.contains($0.key) } + discovery.filePathBySessionId = discovery.filePathBySessionId.filter { + !removedSessionIDs.contains($0.key) + } + discovery.missingSessionIds.removeAll { removedSessionIDs.contains($0) } + discovery.pendingSessionIds.removeAll { removedSessionIDs.contains($0) } + if let head = discovery.headScan, removedPaths.contains(head.path) { + discovery.headScan = nil + } + // A compacted discovery is no longer complete; the scanner re-enqueues current files + // under its bounded budget instead of trusting stale coverage. + discovery.isComplete = false + cache.codexSessionDiscovery = discovery + } + /// Cheap upper-bound-ish estimate of the encoded JSON payload, used to decide whether to /// prune before materializing the document. Deliberately conservative per-entry overhead /// so the estimate triggers at or before the real byte budget. @@ -341,6 +387,15 @@ enum CostUsageCacheIO { bytes += key.count + value.count + 48 } } + if let discovery = cache.codexSessionDiscovery { + bytes += discovery.filePaths.count * 110 + bytes += discovery.fileStamps.count * 100 + bytes += discovery.filePathBySessionId.count * 80 + bytes += discovery.missingSessionIds.count * 48 + bytes += discovery.pendingSessionIds.count * 48 + bytes += discovery.directoryPaths.count * 90 + bytes += discovery.directoryStamps.count * 70 + } return bytes } diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 5c84dbbc6b..e55c691d06 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import CodexBarCore +// swiftlint:disable:next type_body_length struct CostUsageCacheTests { @Test func `legacy codex token cache decodes without reasoning while current rows round trip it`() throws { @@ -790,6 +791,126 @@ struct CostUsageCacheTests { #expect(loaded.days["2026-06-28"]?["gpt-5.5"] == [1, 0, 0]) } + @Test + func `save marks trimmed caches as needing catch up`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + cache.lastScanUnixMs = 123_456 + let snapshots = (0..<300).map { index in + CostUsageCodexTokenSnapshot( + timestamp: "2026-06-0\(index % 9)T00:00:0\(index % 10)Z", + last: nil, + total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) + } + var older = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-05": ["gpt-5.5": [1, 0, 0]]]) + older.codexTokenSnapshots = snapshots + var recent = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-28": ["gpt-5.5": [1, 0, 0]]]) + recent.codexTokenSnapshots = snapshots + cache.files = [ + "/sessions/older.jsonl": older, + "/sessions/recent.jsonl": recent, + ] + cache.days = [ + "2026-06-05": ["gpt-5.5": [1, 0, 0]], + "2026-06-28": ["gpt-5.5": [1, 0, 0]], + ] + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), + maxCacheBytes: 30000, + maxCacheEntries: 100) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + #expect(loaded.codexScanCatchUpPending == true) + #expect(loaded.lastScanUnixMs == 0) + } + + @Test + func `save prunes discovery records with removed sessions`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + var stale = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-04-10": ["gpt-5.5": [1, 0, 0]]]) + stale.sessionId = "stale-session" + var inWindow = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) + inWindow.sessionId = "live-session" + cache.files = [ + "/sessions/stale.jsonl": stale, + "/sessions/in-window.jsonl": inWindow, + ] + cache.days = [ + "2026-04-10": ["gpt-5.5": [1, 0, 0]], + "2026-06-20": ["gpt-5.5": [1, 0, 0]], + ] + cache.codexSessionDiscovery = CostUsageCodexSessionDiscovery( + roots: ["/sessions"], + generation: nil, + directoryStamps: [:], + directoryPaths: [], + nextDirectoryIndex: 0, + filePaths: ["/sessions/stale.jsonl", "/sessions/in-window.jsonl"], + nextFileIndex: 0, + fileStamps: [ + "/sessions/stale.jsonl": .init(mtimeUnixMs: 1, size: 100, fileId: nil), + "/sessions/in-window.jsonl": .init(mtimeUnixMs: 1, size: 100, fileId: nil), + ], + headScan: nil, + filePathBySessionId: [ + "stale-session": "/sessions/stale.jsonl", + "live-session": "/sessions/in-window.jsonl", + ], + missingSessionIds: ["stale-session"], + pendingSessionIds: [], + validationDirectoryIndex: 0, + isComplete: true) + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), + maxCacheEntries: 1) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + let discovery = try #require(loaded.codexSessionDiscovery) + #expect(discovery.filePaths == ["/sessions/in-window.jsonl"]) + #expect(discovery.fileStamps["/sessions/stale.jsonl"] == nil) + #expect(discovery.filePathBySessionId["stale-session"] == nil) + #expect(discovery.filePathBySessionId["live-session"] != nil) + #expect(discovery.missingSessionIds == []) + #expect(discovery.isComplete == false) + } + private func makeTemporaryCacheRoot() throws -> URL { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-cost-cache-\(UUID().uuidString)", isDirectory: true) From 1ed0514554d2840ced3350f2159fcb1de1d38135 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:36:03 +0800 Subject: [PATCH 08/22] Bound sole oversized entries and reset discovery cursors --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 46 +++++++++++++-- Tests/CodexBarTests/CostUsageCacheTests.swift | 57 +++++++++++++++++++ 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index b61964242f..2ed0b238e9 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 = "03e1c691072b3d6b" + static let value = "0a9a2c89a1671206" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 62c4b36de3..a77af20e13 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -5,7 +5,8 @@ enum CostUsageCacheIO { /// scanned session file plus per-file detail (rows, turn IDs, token snapshots) and is /// decoded and encoded as a single JSON document on every scan, so an unbounded corpus /// can otherwise grow it to multiple gigabytes. These bounds mirror the scan-side byte - /// budgets; in-window data is never dropped, so reports stay complete. + /// budgets. In-window entries are only dropped by the last-resort budget trim, which + /// marks the artifact for catch-up so reports recover on the next refresh. static let maxCacheFileBytes: Int = 256 * 1024 * 1024 static let maxCacheFileEntries: Int = 25000 /// Artifacts above this size are refused at load time and rebuilt by the bounded @@ -334,14 +335,24 @@ enum CostUsageCacheIO { } CostUsageScanner.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) } + // A sole in-window entry can still exceed the budget alone. Strip its rebuildable + // detail (keeping identity, day aggregates, totals, and cost data) and force a + // bounded full re-read, so the persisted artifact always fits the load cap. + var stripped = false + if estimated > target, let survivor = oldestFirst.last { + Self.stripFileUsageDetail(&cache, key: survivor.key) + stripped = true + } if !removedPaths.isEmpty { Self.pruneDiscovery(&cache, removedPaths: removedPaths, removedSessionIDs: removedSessionIDs) - // Dropped in-window entries would under-report until the next refresh; mark the - // cache as needing catch-up so a cold restart re-scans them promptly. + } + if !removedPaths.isEmpty || stripped { + // Dropped or stripped in-window entries would under-report until the next refresh; + // mark the cache as needing catch-up so a cold restart re-scans them promptly. cache.codexScanCatchUpPending = true cache.lastScanUnixMs = 0 } - return !droppedKeys.isEmpty + return !droppedKeys.isEmpty || stripped } /// Removes discovery records for session files that were pruned from `files` so the @@ -362,12 +373,39 @@ enum CostUsageCacheIO { if let head = discovery.headScan, removedPaths.contains(head.path) { discovery.headScan = nil } + // Cursors may point past the shortened arrays; reset them so the next discovery + // pass re-enqueues remaining files instead of finishing immediately. + discovery.nextFileIndex = 0 + discovery.nextDirectoryIndex = 0 + discovery.validationDirectoryIndex = 0 // A compacted discovery is no longer complete; the scanner re-enqueues current files // under its bounded budget instead of trusting stale coverage. discovery.isComplete = false cache.codexSessionDiscovery = discovery } + /// Strips rebuildable per-file detail from the sole oversized survivor so the artifact + /// stays within the byte budget. Day aggregates, totals, cost data, identity, and fork + /// metadata are kept; a zero `parsedBytes` forces a bounded full re-read on the next + /// refresh so any rebuilt index covers the whole file. + private static func stripFileUsageDetail(_ cache: inout CostUsageCache, key: String) { + guard var usage = cache.files[key] else { return } + usage.codexRows = nil + usage.codexTurnIDs = nil + usage.codexTokenSnapshots = nil + usage.codexTokenCheckpoints = nil + usage.codexTokenTimestampsMonotonic = nil + usage.codexTokenIndexAnchor = nil + usage.seenRawTotals = nil + usage.hasDivergentTotals = nil + usage.hasInterleavedTotals = nil + usage.lastRawTotalsBaseline = nil + usage.lastRawTotalsWatermark = nil + usage.parsedBytes = 0 + usage.codexCostCacheComplete = nil + cache.files[key] = usage + } + /// Cheap upper-bound-ish estimate of the encoded JSON payload, used to decide whether to /// prune before materializing the document. Deliberately conservative per-entry overhead /// so the estimate triggers at or before the real byte budget. diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index e55c691d06..03232d511f 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -909,6 +909,63 @@ struct CostUsageCacheTests { #expect(discovery.filePathBySessionId["live-session"] != nil) #expect(discovery.missingSessionIds == []) #expect(discovery.isComplete == false) + #expect(discovery.nextFileIndex == 0) + #expect(discovery.nextDirectoryIndex == 0) + } + + @Test + func `save strips detail from a sole oversized in-window entry`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + var huge = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 1_000_000, + days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) + huge.sessionId = "huge-session" + huge.parsedBytes = 1_000_000 + huge.codexRows = [ + CostUsageScanner.CodexUsageRow( + day: "2026-06-20", + model: "gpt-5.5", + turnID: "turn", + eventIndex: 1, + input: 10, + cached: 2, + output: 4, + reasoning: nil), + ] + huge.codexTokenSnapshots = (0..<1000).map { index in + CostUsageCodexTokenSnapshot( + timestamp: "2026-06-20T00:00:0\(index % 10)Z", + last: nil, + total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) + } + cache.files = ["/sessions/huge.jsonl": huge] + cache.days = ["2026-06-20": ["gpt-5.5": [1, 0, 0]]] + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), + maxCacheBytes: 30000, + maxCacheEntries: 100) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + let survivor = try #require(loaded.files["/sessions/huge.jsonl"]) + #expect(survivor.codexTokenSnapshots == nil) + #expect(survivor.codexRows == nil) + #expect(survivor.parsedBytes == 0) + #expect(survivor.days["2026-06-20"]?["gpt-5.5"] == [1, 0, 0]) + #expect(loaded.codexScanCatchUpPending == true) } private func makeTemporaryCacheRoot() throws -> URL { From 98a640d91b2a715e720cc391f9f02801cee284b1 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:51:08 +0800 Subject: [PATCH 09/22] Mark stripped entries incomplete --- Sources/CodexBarCore/Generated/CodexParserHash.generated.swift | 2 +- Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift | 2 ++ Tests/CodexBarTests/CostUsageCacheTests.swift | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 2ed0b238e9..15fbb0b6e6 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 = "0a9a2c89a1671206" + static let value = "394f51d1b608ac89" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index a77af20e13..d2163c2f2d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -403,6 +403,8 @@ enum CostUsageCacheIO { usage.lastRawTotalsWatermark = nil usage.parsedBytes = 0 usage.codexCostCacheComplete = nil + usage.codexScanComplete = false + usage.codexScanFileId = nil cache.files[key] = usage } diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 03232d511f..290d0fd423 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -964,6 +964,8 @@ struct CostUsageCacheTests { #expect(survivor.codexTokenSnapshots == nil) #expect(survivor.codexRows == nil) #expect(survivor.parsedBytes == 0) + #expect(survivor.codexScanComplete == false) + #expect(survivor.codexScanFileId == nil) #expect(survivor.days["2026-06-20"]?["gpt-5.5"] == [1, 0, 0]) #expect(loaded.codexScanCatchUpPending == true) } From 0c2674375b0d6cb7726831d3961437e34cba9d1b Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:08:21 +0800 Subject: [PATCH 10/22] Preserve full report and live fork protection during trim --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 56 ++++++++++++++++++- Tests/CodexBarTests/CostUsageCacheTests.swift | 3 + 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 15fbb0b6e6..68b03390b6 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 = "394f51d1b608ac89" + static let value = "99158a888d347eed" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index d2163c2f2d..e0e89a3464 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -296,7 +296,6 @@ enum CostUsageCacheIO { maxCacheBytes: Int) -> Bool { guard let sinceKey = cache.scanSinceKey, let untilKey = cache.scanUntilKey else { return false } - let protectedParentIDs = Set(cache.files.values.compactMap(\.forkedFromId)) let candidates: [(key: String, usage: CostUsageFileUsage)] = cache.files.compactMap { key, usage in let inWindow = usage.touchesCodexScanWindow(sinceKey: sinceKey, untilKey: untilKey) || Self.isRecentlyActive(usage, calendar: calendar, sinceKey: sinceKey, untilKey: untilKey) @@ -304,13 +303,30 @@ enum CostUsageCacheIO { if usage.codexScanComplete == false { return nil } if usage.codexJSONLResumeState != nil { return nil } if usage.hasBufferedCodexForkRetryLines { return nil } - if let sessionId = usage.sessionId, protectedParentIDs.contains(sessionId) { return nil } return (key, usage) } guard !candidates.isEmpty else { return false } + // Protect parents referenced by entries that survive this trim; a child that is + // removed here must not keep its stale parent protected. + let candidateKeys = Set(candidates.map(\.key)) + let protectedParentIDs = Set( + cache.files.compactMap { key, usage in + candidateKeys.contains(key) ? nil : usage.forkedFromId + }) + let droppable = candidates.filter { candidate in + guard let sessionId = candidate.usage.sessionId else { return true } + return !protectedParentIDs.contains(sessionId) + } + guard !droppable.isEmpty else { return false } + // Preserve the complete report from the untrimmed cache so catch-up displays full + // totals instead of the reduced window after a restart. + let preTrimCache = cache + let previousReport = Self.previousReportForCatchUp( + cache: preTrimCache, + calendar: calendar) // Drop oldest usage first so recent sessions keep their fork-baseline detail. - let oldestFirst = candidates.sorted { lhs, rhs in + let oldestFirst = droppable.sorted { lhs, rhs in let lhsDay = lhs.usage.days.keys.min() ?? "9999" let rhsDay = rhs.usage.days.keys.min() ?? "9999" return lhsDay < rhsDay @@ -351,10 +367,44 @@ enum CostUsageCacheIO { // mark the cache as needing catch-up so a cold restart re-scans them promptly. cache.codexScanCatchUpPending = true cache.lastScanUnixMs = 0 + cache.codexPreviousReport = previousReport } return !droppedKeys.isEmpty || stripped } + private static func previousReportForCatchUp( + cache: CostUsageCache, + calendar: Calendar) -> CostUsageCodexPreviousReport? + { + guard let sinceKey = cache.scanSinceKey, + let untilKey = cache.scanUntilKey, + let since = dayDate(sinceKey, calendar: calendar), + let until = dayDate(untilKey, calendar: calendar) + else { return nil } + let range = CostUsageScanner.CostUsageDayRange( + since: since, + until: until, + calendar: calendar) + let report = CostUsageScanner.buildCodexReportFromCache(cache: cache, range: range) + return CostUsageCodexPreviousReport(report: report, cache: cache) + } + + private static func dayDate(_ key: String, calendar: Calendar) -> Date? { + let parts = key.split(separator: "-", omittingEmptySubsequences: true) + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]) + else { return nil } + var components = DateComponents() + components.calendar = calendar + components.timeZone = calendar.timeZone + components.year = year + components.month = month + components.day = day + return calendar.date(from: components) + } + /// Removes discovery records for session files that were pruned from `files` so the /// persisted discovery state stays bounded with the artifact. private static func pruneDiscovery( diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 290d0fd423..be8ee21305 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -840,6 +840,9 @@ struct CostUsageCacheTests { producerKey: "codex:cu:p1111111111111111") #expect(loaded.codexScanCatchUpPending == true) #expect(loaded.lastScanUnixMs == 0) + #expect(loaded.codexPreviousReport != nil) + #expect(loaded.codexPreviousReport?.data.contains { $0.date == "2026-06-05" } == true) + #expect(loaded.codexPreviousReport?.data.contains { $0.date == "2026-06-28" } == true) } @Test From f8acd53fca68dac3d43ba090cd2b958afa3c96d7 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:23:57 +0800 Subject: [PATCH 11/22] Compact protected fork parents over budget --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 17 +++++- Tests/CodexBarTests/CostUsageCacheTests.swift | 57 +++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 68b03390b6..8a1822e95c 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 = "99158a888d347eed" + static let value = "af3c33c6f16e0f8d" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index e0e89a3464..cc0072662f 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -313,11 +313,14 @@ enum CostUsageCacheIO { cache.files.compactMap { key, usage in candidateKeys.contains(key) ? nil : usage.forkedFromId }) + let protected = candidates.filter { candidate in + guard let sessionId = candidate.usage.sessionId else { return false } + return protectedParentIDs.contains(sessionId) + } let droppable = candidates.filter { candidate in guard let sessionId = candidate.usage.sessionId else { return true } return !protectedParentIDs.contains(sessionId) } - guard !droppable.isEmpty else { return false } // Preserve the complete report from the untrimmed cache so catch-up displays full // totals instead of the reduced window after a restart. let preTrimCache = cache @@ -351,10 +354,20 @@ enum CostUsageCacheIO { } CostUsageScanner.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) } + var stripped = false + // A protected parent referenced by an incomplete/buffered child cannot be dropped, + // but its rebuildable detail can still be compacted when it alone exceeds the budget. + let protectedBySize = protected.sorted { lhs, rhs in + Self.estimatedFileUsageBytes(lhs.usage) > Self.estimatedFileUsageBytes(rhs.usage) + } + for candidate in protectedBySize where estimated > target { + Self.stripFileUsageDetail(&cache, key: candidate.key) + stripped = true + estimated -= Self.estimatedFileUsageBytes(candidate.usage) + } // A sole in-window entry can still exceed the budget alone. Strip its rebuildable // detail (keeping identity, day aggregates, totals, and cost data) and force a // bounded full re-read, so the persisted artifact always fits the load cap. - var stripped = false if estimated > target, let survivor = oldestFirst.last { Self.stripFileUsageDetail(&cache, key: survivor.key) stripped = true diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index be8ee21305..73a1244f57 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -973,6 +973,63 @@ struct CostUsageCacheTests { #expect(loaded.codexScanCatchUpPending == true) } + @Test + func `save compacts a protected fork parent that alone exceeds the budget`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + var parent = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 1_000_000, + days: ["2026-06-10": ["gpt-5.5": [1, 0, 0]]]) + parent.sessionId = "parent-session" + parent.parsedBytes = 1_000_000 + parent.codexTokenSnapshots = (0..<1000).map { index in + CostUsageCodexTokenSnapshot( + timestamp: "2026-06-10T00:00:0\(index % 10)Z", + last: nil, + total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) + } + var child = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-28": ["gpt-5.5": [1, 0, 0]]]) + child.sessionId = "child-session" + child.forkedFromId = "parent-session" + child.codexScanComplete = false + cache.files = [ + "/sessions/parent.jsonl": parent, + "/sessions/child.jsonl": child, + ] + cache.days = [ + "2026-06-10": ["gpt-5.5": [1, 0, 0]], + "2026-06-28": ["gpt-5.5": [1, 0, 0]], + ] + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), + maxCacheBytes: 30000, + maxCacheEntries: 100) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + let compacted = try #require(loaded.files["/sessions/parent.jsonl"]) + #expect(compacted.codexTokenSnapshots == nil) + #expect(compacted.parsedBytes == 0) + #expect(compacted.codexScanComplete == false) + #expect(loaded.files["/sessions/child.jsonl"] != nil) + #expect(loaded.codexScanCatchUpPending == true) + } + private func makeTemporaryCacheRoot() throws -> URL { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-cost-cache-\(UUID().uuidString)", isDirectory: true) From 9aac621876040f181451df9413c13274c3c06e20 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:39:37 +0800 Subject: [PATCH 12/22] Enforce byte cap after underestimated encodes --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 41 ++++++++++++++-- Tests/CodexBarTests/CostUsageCacheTests.swift | 47 +++++++++++++++++++ 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 8a1822e95c..d412ac1e1f 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 = "af3c33c6f16e0f8d" + static let value = "a44a0d5b883eceb7" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index cc0072662f..ac84e0addf 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -186,11 +186,13 @@ enum CostUsageCacheIO { maxCacheEntries: maxCacheEntries, previousArtifactBytes: nil, force: true) - Self.trimInWindowEntriesForBudget( - &cache, - calendar: calendar, - maxCacheBytes: maxCacheBytes) - data = (try? JSONEncoder().encode(cache)) ?? Data() + var stripped = true + while data.count > maxCacheBytes, stripped { + stripped = Self.stripAllInWindowDetailForBudget(&cache, calendar: calendar) + if stripped { + data = (try? JSONEncoder().encode(cache)) ?? Data() + } + } } try? data.write(to: url, options: [.atomic]) } @@ -402,6 +404,35 @@ enum CostUsageCacheIO { return CostUsageCodexPreviousReport(report: report, cache: cache) } + /// Last-resort enforcement for payloads the heuristic estimate underestimated: strips + /// rebuildable detail from every completed in-window entry (keeping identity, day + /// aggregates, totals, cost data, and fork metadata) and marks the artifact for + /// catch-up, so the persisted size always fits the load cap. + private static func stripAllInWindowDetailForBudget( + _ cache: inout CostUsageCache, + calendar: Calendar) -> Bool + { + guard let sinceKey = cache.scanSinceKey, let untilKey = cache.scanUntilKey else { return false } + let preStripCache = cache + var strippedAny = false + for key in cache.files.keys { + guard let usage = cache.files[key] else { continue } + let inWindow = usage.touchesCodexScanWindow(sinceKey: sinceKey, untilKey: untilKey) + || Self.isRecentlyActive(usage, calendar: calendar, sinceKey: sinceKey, untilKey: untilKey) + guard inWindow, usage.codexScanComplete != false else { continue } + Self.stripFileUsageDetail(&cache, key: key) + strippedAny = true + } + if strippedAny { + cache.codexScanCatchUpPending = true + cache.lastScanUnixMs = 0 + cache.codexPreviousReport = Self.previousReportForCatchUp( + cache: preStripCache, + calendar: calendar) + } + return strippedAny + } + private static func dayDate(_ key: String, calendar: Calendar) -> Date? { let parts = key.split(separator: "-", omittingEmptySubsequences: true) guard parts.count == 3, diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 73a1244f57..526ddb4e00 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -1030,6 +1030,53 @@ struct CostUsageCacheTests { #expect(loaded.codexScanCatchUpPending == true) } + @Test + func `save enforces the byte cap when the estimate underestimates the payload`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + var entry = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 1_000_000, + days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) + entry.sessionId = "big-session" + entry.parsedBytes = 1_000_000 + let longTimestamp = "2026-06-20T00:00:00.000000000Z-\(String(repeating: "x", count: 80))" + entry.codexTokenSnapshots = (0..<850).map { index in + CostUsageCodexTokenSnapshot( + timestamp: "\(longTimestamp)-\(index)", + last: nil, + total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) + } + cache.files = ["/sessions/big.jsonl": entry] + cache.days = ["2026-06-20": ["gpt-5.5": [1, 0, 0]]] + let maxCacheBytes = 115_000 + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), + maxCacheBytes: maxCacheBytes, + maxCacheEntries: 100) + + let url = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) + let artifactBytes = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)? + .int64Value ?? 0 + #expect(artifactBytes <= maxCacheBytes) + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + #expect(loaded.files["/sessions/big.jsonl"]?.codexTokenSnapshots == nil) + #expect(loaded.codexScanCatchUpPending == true) + #expect(loaded.days["2026-06-20"]?["gpt-5.5"] == [1, 0, 0]) + } + private func makeTemporaryCacheRoot() throws -> URL { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-cost-cache-\(UUID().uuidString)", isDirectory: true) From bc4ad2d979d9c9edfa37df6d38340bf726f96cb3 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 05:07:41 +0800 Subject: [PATCH 13/22] Exclude lineage-only parents from fork protection --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 21 +++++--- Tests/CodexBarTests/CostUsageCacheTests.swift | 53 +++++++++++++++++++ 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index d412ac1e1f..cdd5d62ffa 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 = "a44a0d5b883eceb7" + static let value = "efdbef4dbcb70346" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index ac84e0addf..a4597dce91 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -149,7 +149,7 @@ enum CostUsageCacheIO { cache.timeZoneIdentifier = calendar.timeZone.identifier if provider == .codex { - Self.pruneCodexCacheForBudget( + _ = Self.pruneCodexCacheForBudget( &cache, requestedScanWindow: requestedScanWindow, calendar: calendar, @@ -309,19 +309,24 @@ enum CostUsageCacheIO { } guard !candidates.isEmpty else { return false } // Protect parents referenced by entries that survive this trim; a child that is - // removed here must not keep its stale parent protected. + // removed here must not keep its stale parent protected. Lineage-only children do + // not resolve inherited parent totals, so their parents need no protection either. let candidateKeys = Set(candidates.map(\.key)) - let protectedParentIDs = Set( - cache.files.compactMap { key, usage in - candidateKeys.contains(key) ? nil : usage.forkedFromId - }) + let survivingParentIDs: [String] = cache.files.compactMap { key, usage in + if candidateKeys.contains(key) { return nil } + if usage.forkBaselineDependencyKey == CostUsageScanner.codexForkDependencyNotRequiredKey { + return nil + } + return usage.forkedFromId + } + let protectedParentIDsExcludingLineageOnly = Set(survivingParentIDs) let protected = candidates.filter { candidate in guard let sessionId = candidate.usage.sessionId else { return false } - return protectedParentIDs.contains(sessionId) + return protectedParentIDsExcludingLineageOnly.contains(sessionId) } let droppable = candidates.filter { candidate in guard let sessionId = candidate.usage.sessionId else { return true } - return !protectedParentIDs.contains(sessionId) + return !protectedParentIDsExcludingLineageOnly.contains(sessionId) } // Preserve the complete report from the untrimmed cache so catch-up displays full // totals instead of the reduced window after a restart. diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 526ddb4e00..e1be5d681c 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -1030,6 +1030,59 @@ struct CostUsageCacheTests { #expect(loaded.codexScanCatchUpPending == true) } + @Test + func `save does not protect lineage only fork parents`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + var parent = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 1_000_000, + days: ["2026-06-10": ["gpt-5.5": [1, 0, 0]]]) + parent.sessionId = "parent-session" + parent.codexTokenSnapshots = (0..<1000).map { index in + CostUsageCodexTokenSnapshot( + timestamp: "2026-06-10T00:00:0\(index % 10)Z", + last: nil, + total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) + } + var lineageChild = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-28": ["gpt-5.5": [1, 0, 0]]]) + lineageChild.sessionId = "lineage-child" + lineageChild.forkedFromId = "parent-session" + lineageChild.forkBaselineDependencyKey = CostUsageScanner.codexForkDependencyNotRequiredKey + cache.files = [ + "/sessions/parent.jsonl": parent, + "/sessions/lineage-child.jsonl": lineageChild, + ] + cache.days = [ + "2026-06-10": ["gpt-5.5": [1, 0, 0]], + "2026-06-28": ["gpt-5.5": [1, 0, 0]], + ] + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), + maxCacheBytes: 30000, + maxCacheEntries: 100) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + #expect(loaded.files["/sessions/parent.jsonl"] == nil) + #expect(loaded.files["/sessions/lineage-child.jsonl"] != nil) + #expect(loaded.days["2026-06-10"] == nil) + } + @Test func `save enforces the byte cap when the estimate underestimates the payload`() throws { let root = try self.makeTemporaryCacheRoot() From 2f80534c9cfd7ce61d88d4d47fbf82c93b64c87c Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 05:23:21 +0800 Subject: [PATCH 14/22] Re-encode after forced prune and bound lookback state --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 30 ++++-- Tests/CodexBarTests/CostUsageCacheTests.swift | 98 +++++++++++++++++++ 3 files changed, 122 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index cdd5d62ffa..887c967df5 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 = "efdbef4dbcb70346" + static let value = "84c23c8961afd3b3" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index a4597dce91..ce96a0aadb 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -178,7 +178,7 @@ enum CostUsageCacheIO { if provider == .codex, data.count > maxCacheBytes { // The estimate underestimated the payload; prune again so the artifact stays // loadable and the next refresh never hits the load-refusal rebuild loop. - Self.pruneCodexCacheForBudget( + _ = Self.pruneCodexCacheForBudget( &cache, requestedScanWindow: requestedScanWindow, calendar: calendar, @@ -186,12 +186,12 @@ enum CostUsageCacheIO { maxCacheEntries: maxCacheEntries, previousArtifactBytes: nil, force: true) - var stripped = true - while data.count > maxCacheBytes, stripped { - stripped = Self.stripAllInWindowDetailForBudget(&cache, calendar: calendar) - if stripped { - data = (try? JSONEncoder().encode(cache)) ?? Data() - } + data = (try? JSONEncoder().encode(cache)) ?? Data() + while data.count > maxCacheBytes { + let strippedDetail = Self.stripAllInWindowDetailForBudget(&cache, calendar: calendar) + let clearedLookback = Self.clearActiveLookbackForBudget(&cache) + guard strippedDetail || clearedLookback else { break } + data = (try? JSONEncoder().encode(cache)) ?? Data() } } try? data.write(to: url, options: [.atomic]) @@ -535,9 +535,25 @@ enum CostUsageCacheIO { bytes += discovery.directoryPaths.count * 90 bytes += discovery.directoryStamps.count * 70 } + if let lookback = cache.codexActiveLookbackState { + bytes += lookback.pendingFilePaths.count * 110 + bytes += lookback.legacyRecursivePendingRootPaths.count * 90 + bytes += lookback.completedRootPaths.count * 90 + bytes += lookback.rootPaths.count * 90 + bytes += lookback.nextDayKeyByRoot.count * 60 + } return bytes } + /// Drops the persisted active-lookback queue when it alone keeps the artifact over + /// budget. The queue is rebuildable: the scanner re-discovers pending paths under its + /// bounded per-refresh budget on the next scan. + private static func clearActiveLookbackForBudget(_ cache: inout CostUsageCache) -> Bool { + guard cache.codexActiveLookbackState != nil else { return false } + cache.codexActiveLookbackState = nil + return true + } + private static func estimatedFileUsageBytes(_ usage: CostUsageFileUsage) -> Int { var bytes = 240 for (day, models) in usage.days { diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index e1be5d681c..a816c0fe64 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -1130,6 +1130,104 @@ struct CostUsageCacheTests { #expect(loaded.days["2026-06-20"]?["gpt-5.5"] == [1, 0, 0]) } + @Test + func `save re-encodes after a forced prune removes out-of-window entries`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + var stale = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 1_000_000, + days: ["2026-04-10": ["gpt-5.5": [1, 0, 0]]]) + stale.sessionId = "stale-session" + let longTimestamp = "2026-04-10T00:00:00.000000000Z-\(String(repeating: "x", count: 80))" + stale.codexTokenSnapshots = (0..<850).map { index in + CostUsageCodexTokenSnapshot( + timestamp: "\(longTimestamp)-\(index)", + last: nil, + total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) + } + var inWindow = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) + inWindow.sessionId = "live-session" + cache.files = [ + "/sessions/stale.jsonl": stale, + "/sessions/in-window.jsonl": inWindow, + ] + cache.days = [ + "2026-04-10": ["gpt-5.5": [1, 0, 0]], + "2026-06-20": ["gpt-5.5": [1, 0, 0]], + ] + let maxCacheBytes = 115_000 + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), + maxCacheBytes: maxCacheBytes, + maxCacheEntries: 100) + + let url = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) + let artifactBytes = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)? + .int64Value ?? 0 + #expect(artifactBytes <= maxCacheBytes) + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + #expect(loaded.files["/sessions/stale.jsonl"] == nil) + #expect(loaded.files["/sessions/in-window.jsonl"] != nil) + #expect(loaded.days["2026-04-10"] == nil) + } + + @Test + func `save clears the active lookback queue when it keeps the artifact over budget`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + var inWindow = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) + inWindow.sessionId = "live-session" + cache.files = ["/sessions/in-window.jsonl": inWindow] + cache.days = ["2026-06-20": ["gpt-5.5": [1, 0, 0]]] + cache.codexActiveLookbackState = CostUsageCodexActiveLookbackState( + scanSinceKey: "2026-06-01", + rootPaths: ["/sessions"], + nextDayKeyByRoot: ["/sessions": "2026-06-02"], + completedRootPaths: [], + pendingFilePaths: (0..<3000).map { "/sessions/pending-\($0).jsonl" }, + legacyRecursivePendingRootPaths: []) + let maxCacheBytes = 30000 + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), + maxCacheBytes: maxCacheBytes, + maxCacheEntries: 100) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + #expect(loaded.codexActiveLookbackState == nil) + #expect(loaded.files["/sessions/in-window.jsonl"] != nil) + } + private func makeTemporaryCacheRoot() throws -> URL { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-cost-cache-\(UUID().uuidString)", isDirectory: true) From 530eb6d08c5bba4014e087b5156b6db339ca6fab Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 05:40:49 +0800 Subject: [PATCH 15/22] Use report window for catch-up and exclude lineage parents --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 40 ++++++++++++++----- .../Vendored/CostUsage/CostUsageScanner.swift | 3 +- Tests/CodexBarTests/CostUsageCacheTests.swift | 16 +++++--- 4 files changed, 42 insertions(+), 19 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 887c967df5..f86c6bb9f9 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 = "84c23c8961afd3b3" + static let value = "3dad040a0ce675e7" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index ce96a0aadb..d361d1e4ba 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -137,6 +137,7 @@ enum CostUsageCacheIO { producerKey: String? = nil, calendar: Calendar = .current, requestedScanWindow: (sinceKey: String, untilKey: String)? = nil, + reportWindow: (sinceKey: String, untilKey: String)? = nil, maxCacheBytes: Int = CostUsageCacheIO.maxCacheFileBytes, maxCacheEntries: Int = CostUsageCacheIO.maxCacheFileEntries) { @@ -170,7 +171,8 @@ enum CostUsageCacheIO { Self.trimInWindowEntriesForBudget( &cache, calendar: calendar, - maxCacheBytes: maxCacheBytes) + maxCacheBytes: maxCacheBytes, + reportWindow: reportWindow) } } @@ -188,7 +190,10 @@ enum CostUsageCacheIO { force: true) data = (try? JSONEncoder().encode(cache)) ?? Data() while data.count > maxCacheBytes { - let strippedDetail = Self.stripAllInWindowDetailForBudget(&cache, calendar: calendar) + let strippedDetail = Self.stripAllInWindowDetailForBudget( + &cache, + calendar: calendar, + reportWindow: reportWindow) let clearedLookback = Self.clearActiveLookbackForBudget(&cache) guard strippedDetail || clearedLookback else { break } data = (try? JSONEncoder().encode(cache)) ?? Data() @@ -238,8 +243,14 @@ enum CostUsageCacheIO { // Protect parents referenced by entries that survive pruning. A stale child that is // removed in this pass must not keep its stale parent alive. let survivingKeys = Set(cache.files.keys).subtracting(outOfWindowCandidates) - let survivingParentSessionIDs = Set( - survivingKeys.compactMap { cache.files[$0]?.forkedFromId }) + let survivingParentIDs: [String] = survivingKeys.compactMap { key in + guard let usage = cache.files[key] else { return nil } + if usage.forkBaselineDependencyKey == CostUsageScanner.codexForkDependencyNotRequiredKey { + return nil + } + return usage.forkedFromId + } + let survivingParentSessionIDs = Set(survivingParentIDs) let outOfWindowKeys = outOfWindowCandidates.filter { key in guard let sessionId = cache.files[key]?.sessionId else { return true } return !survivingParentSessionIDs.contains(sessionId) @@ -295,7 +306,8 @@ enum CostUsageCacheIO { private static func trimInWindowEntriesForBudget( _ cache: inout CostUsageCache, calendar: Calendar, - maxCacheBytes: Int) -> Bool + maxCacheBytes: Int, + reportWindow: (sinceKey: String, untilKey: String)?) -> Bool { guard let sinceKey = cache.scanSinceKey, let untilKey = cache.scanUntilKey else { return false } let candidates: [(key: String, usage: CostUsageFileUsage)] = cache.files.compactMap { key, usage in @@ -333,7 +345,8 @@ enum CostUsageCacheIO { let preTrimCache = cache let previousReport = Self.previousReportForCatchUp( cache: preTrimCache, - calendar: calendar) + calendar: calendar, + reportWindow: reportWindow) // Drop oldest usage first so recent sessions keep their fork-baseline detail. let oldestFirst = droppable.sorted { lhs, rhs in @@ -394,10 +407,13 @@ enum CostUsageCacheIO { private static func previousReportForCatchUp( cache: CostUsageCache, - calendar: Calendar) -> CostUsageCodexPreviousReport? + calendar: Calendar, + reportWindow: (sinceKey: String, untilKey: String)?) -> CostUsageCodexPreviousReport? { - guard let sinceKey = cache.scanSinceKey, - let untilKey = cache.scanUntilKey, + // Preserve the user-facing report window, not the scan bounds (which the scanner + // pads by one day on each side). + guard let sinceKey = reportWindow?.sinceKey ?? cache.scanSinceKey, + let untilKey = reportWindow?.untilKey ?? cache.scanUntilKey, let since = dayDate(sinceKey, calendar: calendar), let until = dayDate(untilKey, calendar: calendar) else { return nil } @@ -415,7 +431,8 @@ enum CostUsageCacheIO { /// catch-up, so the persisted size always fits the load cap. private static func stripAllInWindowDetailForBudget( _ cache: inout CostUsageCache, - calendar: Calendar) -> Bool + calendar: Calendar, + reportWindow: (sinceKey: String, untilKey: String)?) -> Bool { guard let sinceKey = cache.scanSinceKey, let untilKey = cache.scanUntilKey else { return false } let preStripCache = cache @@ -433,7 +450,8 @@ enum CostUsageCacheIO { cache.lastScanUnixMs = 0 cache.codexPreviousReport = Self.previousReportForCatchUp( cache: preStripCache, - calendar: calendar) + calendar: calendar, + reportWindow: reportWindow) } return strippedAny } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index c93652bfc0..26da0cb8fe 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -4463,7 +4463,8 @@ enum CostUsageScanner { cache: cache, cacheRoot: options.cacheRoot, calendar: range.calendar, - requestedScanWindow: (sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey)) + requestedScanWindow: (sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey), + reportWindow: (sinceKey: range.sinceKey, untilKey: range.untilKey)) } // swiftlint:disable:next function_body_length diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index a816c0fe64..e7c57c1c0e 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -823,6 +823,8 @@ struct CostUsageCacheTests { cache.days = [ "2026-06-05": ["gpt-5.5": [1, 0, 0]], "2026-06-28": ["gpt-5.5": [1, 0, 0]], + "2026-05-31": ["gpt-5.5": [1, 0, 0]], + "2026-07-02": ["gpt-5.5": [1, 0, 0]], ] CostUsageCacheIO.save( @@ -831,6 +833,7 @@ struct CostUsageCacheTests { cacheRoot: root, producerKey: "codex:cu:p1111111111111111", requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), + reportWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), maxCacheBytes: 30000, maxCacheEntries: 100) @@ -843,6 +846,8 @@ struct CostUsageCacheTests { #expect(loaded.codexPreviousReport != nil) #expect(loaded.codexPreviousReport?.data.contains { $0.date == "2026-06-05" } == true) #expect(loaded.codexPreviousReport?.data.contains { $0.date == "2026-06-28" } == true) + #expect(loaded.codexPreviousReport?.data.contains { $0.date == "2026-05-31" } == false) + #expect(loaded.codexPreviousReport?.data.contains { $0.date == "2026-07-02" } == false) } @Test @@ -1041,11 +1046,11 @@ struct CostUsageCacheTests { var parent = CostUsageFileUsage( mtimeUnixMs: 1, size: 1_000_000, - days: ["2026-06-10": ["gpt-5.5": [1, 0, 0]]]) + days: ["2026-04-10": ["gpt-5.5": [1, 0, 0]]]) parent.sessionId = "parent-session" parent.codexTokenSnapshots = (0..<1000).map { index in CostUsageCodexTokenSnapshot( - timestamp: "2026-06-10T00:00:0\(index % 10)Z", + timestamp: "2026-04-10T00:00:0\(index % 10)Z", last: nil, total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) } @@ -1061,7 +1066,7 @@ struct CostUsageCacheTests { "/sessions/lineage-child.jsonl": lineageChild, ] cache.days = [ - "2026-06-10": ["gpt-5.5": [1, 0, 0]], + "2026-04-10": ["gpt-5.5": [1, 0, 0]], "2026-06-28": ["gpt-5.5": [1, 0, 0]], ] @@ -1071,8 +1076,7 @@ struct CostUsageCacheTests { cacheRoot: root, producerKey: "codex:cu:p1111111111111111", requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheBytes: 30000, - maxCacheEntries: 100) + maxCacheEntries: 1) let loaded = CostUsageCacheIO.load( provider: .codex, @@ -1080,7 +1084,7 @@ struct CostUsageCacheTests { producerKey: "codex:cu:p1111111111111111") #expect(loaded.files["/sessions/parent.jsonl"] == nil) #expect(loaded.files["/sessions/lineage-child.jsonl"] != nil) - #expect(loaded.days["2026-06-10"] == nil) + #expect(loaded.days["2026-04-10"] == nil) } @Test From 13fb17e1b500b527a28e3d2bd32922c5d54beaa2 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 05:57:22 +0800 Subject: [PATCH 16/22] Compact parents required by kept trim survivors --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 30 +++++++++- Tests/CodexBarTests/CostUsageCacheTests.swift | 56 +++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index f86c6bb9f9..a04880568e 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 = "3dad040a0ce675e7" + static let value = "53ee21fef8fa287f" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index d361d1e4ba..fa513b0ad6 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -364,6 +364,11 @@ enum CostUsageCacheIO { droppedKeys.append(candidate.key) estimated -= Self.estimatedFileUsageBytes(candidate.usage) } + // A dropped parent may still be required by the newest survivor we keep. Never delete + // it; compact it instead so the retained child can resolve its fork baseline later. + var stripped = Self.compactParentsRequiredBySurvivors( + &cache, + droppedKeys: &droppedKeys) var removedPaths: Set = [] var removedSessionIDs: Set = [] for key in droppedKeys { @@ -374,7 +379,6 @@ enum CostUsageCacheIO { } CostUsageScanner.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) } - var stripped = false // A protected parent referenced by an incomplete/buffered child cannot be dropped, // but its rebuildable detail can still be compacted when it alone exceeds the budget. let protectedBySize = protected.sorted { lhs, rhs in @@ -405,6 +409,30 @@ enum CostUsageCacheIO { return !droppedKeys.isEmpty || stripped } + /// Compacts (instead of dropping) parents that the entries kept by this trim still + /// reference, so retained fork children can resolve their baselines on later catch-up. + private static func compactParentsRequiredBySurvivors( + _ cache: inout CostUsageCache, + droppedKeys: inout [String]) -> Bool + { + let droppedSet = Set(droppedKeys) + let survivorsAfterDrop = cache.files.keys.filter { !droppedSet.contains($0) } + let neededBySurvivors: Set = Set(survivorsAfterDrop.compactMap { key in + guard let usage = cache.files[key] else { return nil } + if usage.forkBaselineDependencyKey == CostUsageScanner.codexForkDependencyNotRequiredKey { + return nil + } + return usage.forkedFromId + }) + var compactedAny = false + for key in droppedKeys where cache.files[key]?.sessionId.map(neededBySurvivors.contains) == true { + Self.stripFileUsageDetail(&cache, key: key) + droppedKeys.removeAll { $0 == key } + compactedAny = true + } + return compactedAny + } + private static func previousReportForCatchUp( cache: CostUsageCache, calendar: Calendar, diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index e7c57c1c0e..c10d3c8419 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -1232,6 +1232,62 @@ struct CostUsageCacheTests { #expect(loaded.files["/sessions/in-window.jsonl"] != nil) } + @Test + func `save compacts a dropped parent required by the kept survivor`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + var parent = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 1_000_000, + days: ["2026-06-05": ["gpt-5.5": [1, 0, 0]]]) + parent.sessionId = "parent-session" + parent.parsedBytes = 1_000_000 + parent.codexTokenSnapshots = (0..<1000).map { index in + CostUsageCodexTokenSnapshot( + timestamp: "2026-06-05T00:00:0\(index % 10)Z", + last: nil, + total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) + } + var child = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-28": ["gpt-5.5": [1, 0, 0]]]) + child.sessionId = "child-session" + child.forkedFromId = "parent-session" + cache.files = [ + "/sessions/parent.jsonl": parent, + "/sessions/child.jsonl": child, + ] + cache.days = [ + "2026-06-05": ["gpt-5.5": [1, 0, 0]], + "2026-06-28": ["gpt-5.5": [1, 0, 0]], + ] + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), + maxCacheBytes: 30000, + maxCacheEntries: 100) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + let compacted = try #require(loaded.files["/sessions/parent.jsonl"]) + #expect(compacted.codexTokenSnapshots == nil) + #expect(compacted.parsedBytes == 0) + #expect(compacted.codexScanComplete == false) + #expect(loaded.files["/sessions/child.jsonl"] != nil) + #expect(loaded.codexScanCatchUpPending == true) + } + private func makeTemporaryCacheRoot() throws -> URL { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-cost-cache-\(UUID().uuidString)", isDirectory: true) From c97ac24d68b8a4a9cb69250aba344297c4c03d5b Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:13:01 +0800 Subject: [PATCH 17/22] Prune orphaned discovery mappings under budget --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 21 ++++++- Tests/CodexBarTests/CostUsageCacheTests.swift | 56 +++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index a04880568e..4878bcf9d0 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 = "53ee21fef8fa287f" + static let value = "c69e3c942f80b4c9" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index fa513b0ad6..9472ed5554 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -195,7 +195,8 @@ enum CostUsageCacheIO { calendar: calendar, reportWindow: reportWindow) let clearedLookback = Self.clearActiveLookbackForBudget(&cache) - guard strippedDetail || clearedLookback else { break } + let prunedOrphans = Self.pruneOrphanedDiscovery(&cache) + guard strippedDetail || clearedLookback || prunedOrphans else { break } data = (try? JSONEncoder().encode(cache)) ?? Data() } } @@ -600,6 +601,24 @@ enum CostUsageCacheIO { return true } + /// Removes session-id mappings that point to paths neither in the pending discovery + /// queue nor in the parsed `files` set. Such orphaned mappings come from sessions that + /// were deleted or pruned in an earlier pass and can dominate the artifact without + /// contributing anything; the pending queue itself is left untouched. + private static func pruneOrphanedDiscovery(_ cache: inout CostUsageCache) -> Bool { + guard var discovery = cache.codexSessionDiscovery else { return false } + let knownPaths = Set(cache.files.keys) + let queuedPaths = Set(discovery.filePaths) + let before = discovery.filePathBySessionId.count + discovery.filePathBySessionId = discovery.filePathBySessionId.filter { _, path in + queuedPaths.contains(path) || knownPaths.contains(path) + } + let after = discovery.filePathBySessionId.count + guard after != before else { return false } + cache.codexSessionDiscovery = discovery + return true + } + private static func estimatedFileUsageBytes(_ usage: CostUsageFileUsage) -> Int { var bytes = 240 for (day, models) in usage.days { diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index c10d3c8419..f52a9411be 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -1288,6 +1288,62 @@ struct CostUsageCacheTests { #expect(loaded.codexScanCatchUpPending == true) } + @Test + func `save prunes orphaned discovery mappings when they keep the artifact over budget`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + var inWindow = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) + inWindow.sessionId = "live-session" + cache.files = ["/sessions/in-window.jsonl": inWindow] + cache.days = ["2026-06-20": ["gpt-5.5": [1, 0, 0]]] + cache.codexSessionDiscovery = CostUsageCodexSessionDiscovery( + roots: ["/sessions"], + generation: nil, + directoryStamps: [:], + directoryPaths: [], + nextDirectoryIndex: 0, + filePaths: ["/sessions/in-window.jsonl"], + nextFileIndex: 0, + fileStamps: ["/sessions/in-window.jsonl": .init(mtimeUnixMs: 1, size: 100, fileId: nil)], + headScan: nil, + filePathBySessionId: Dictionary( + uniqueKeysWithValues: (0..<3000).map { index in + ("orphan-\(index)", "/sessions/deleted-\(index).jsonl") + }), + missingSessionIds: [], + pendingSessionIds: [], + validationDirectoryIndex: 0, + isComplete: true) + let maxCacheBytes = 30000 + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), + maxCacheBytes: maxCacheBytes, + maxCacheEntries: 100) + + let url = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) + let artifactBytes = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)? + .int64Value ?? 0 + #expect(artifactBytes <= maxCacheBytes) + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + #expect(loaded.codexSessionDiscovery?.filePathBySessionId.isEmpty == true) + #expect(loaded.files["/sessions/in-window.jsonl"] != nil) + } + private func makeTemporaryCacheRoot() throws -> URL { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-cost-cache-\(UUID().uuidString)", isDirectory: true) From 71e2743f33df1bc461714f233c5b6d4fe1550452 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:43:42 +0800 Subject: [PATCH 18/22] Preserve lookback work and align report bounds --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 89 +++++++++++++++---- .../Vendored/CostUsage/CostUsageScanner.swift | 4 +- Tests/CodexBarTests/CostUsageCacheTests.swift | 6 +- 4 files changed, 82 insertions(+), 19 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 4878bcf9d0..7af6bd42a0 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 = "c69e3c942f80b4c9" + static let value = "9eb581327d28b1a3" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 9472ed5554..4b7fad82cc 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -168,7 +168,7 @@ enum CostUsageCacheIO { maxCacheEntries: maxCacheEntries, previousArtifactBytes: nil, force: true) - Self.trimInWindowEntriesForBudget( + _ = Self.trimInWindowEntriesForBudget( &cache, calendar: calendar, maxCacheBytes: maxCacheBytes, @@ -189,13 +189,15 @@ enum CostUsageCacheIO { previousArtifactBytes: nil, force: true) data = (try? JSONEncoder().encode(cache)) ?? Data() - while data.count > maxCacheBytes { + var iterations = 0 + while data.count > maxCacheBytes, iterations < 4 { + iterations += 1 let strippedDetail = Self.stripAllInWindowDetailForBudget( &cache, calendar: calendar, reportWindow: reportWindow) let clearedLookback = Self.clearActiveLookbackForBudget(&cache) - let prunedOrphans = Self.pruneOrphanedDiscovery(&cache) + let prunedOrphans = Self.pruneOrphanedDiscovery(&cache, maxCacheBytes: maxCacheBytes) guard strippedDetail || clearedLookback || prunedOrphans else { break } data = (try? JSONEncoder().encode(cache)) ?? Data() } @@ -451,7 +453,14 @@ enum CostUsageCacheIO { until: until, calendar: calendar) let report = CostUsageScanner.buildCodexReportFromCache(cache: cache, range: range) - return CostUsageCodexPreviousReport(report: report, cache: cache) + guard var previous = CostUsageCodexPreviousReport(report: report, cache: cache) else { + return nil + } + // Persist the bounds that match the report data (the user report window), not the + // scan-padded cache bounds, so matching never serves narrower data than requested. + previous.scanSinceKey = reportWindow?.sinceKey ?? cache.scanSinceKey + previous.scanUntilKey = reportWindow?.untilKey ?? cache.scanUntilKey + return previous } /// Last-resort enforcement for payloads the heuristic estimate underestimated: strips @@ -592,20 +601,55 @@ enum CostUsageCacheIO { return bytes } - /// Drops the persisted active-lookback queue when it alone keeps the artifact over - /// budget. The queue is rebuildable: the scanner re-discovers pending paths under its - /// bounded per-refresh budget on the next scan. + /// Compacts the persisted active-lookback state when it keeps the artifact over budget. + /// Pending paths are moved into the discovery queue (so no queued scan work is lost) + /// and the lookback structure is reduced to its empty shell. private static func clearActiveLookbackForBudget(_ cache: inout CostUsageCache) -> Bool { - guard cache.codexActiveLookbackState != nil else { return false } - cache.codexActiveLookbackState = nil + guard var lookback = cache.codexActiveLookbackState, + !lookback.pendingFilePaths.isEmpty || !lookback.legacyRecursivePendingRootPaths.isEmpty + else { return false } + let pendingPaths = lookback.pendingFilePaths + if !pendingPaths.isEmpty { + var discovery = cache.codexSessionDiscovery + if discovery == nil { + discovery = CostUsageCodexSessionDiscovery( + roots: lookback.rootPaths, + generation: nil, + directoryStamps: [:], + directoryPaths: [], + nextDirectoryIndex: 0, + filePaths: [], + nextFileIndex: 0, + fileStamps: [:], + headScan: nil, + filePathBySessionId: [:], + missingSessionIds: [], + pendingSessionIds: [], + validationDirectoryIndex: 0, + isComplete: false) + } + var seen = Set(discovery?.filePaths ?? []) + for path in pendingPaths where !seen.contains(path) { + discovery?.filePaths.append(path) + seen.insert(path) + } + cache.codexSessionDiscovery = discovery + } + lookback.pendingFilePaths = [] + lookback.legacyRecursivePendingRootPaths = [] + cache.codexActiveLookbackState = lookback return true } /// Removes session-id mappings that point to paths neither in the pending discovery - /// queue nor in the parsed `files` set. Such orphaned mappings come from sessions that - /// were deleted or pruned in an earlier pass and can dominate the artifact without - /// contributing anything; the pending queue itself is left untouched. - private static func pruneOrphanedDiscovery(_ cache: inout CostUsageCache) -> Bool { + /// queue nor in the parsed `files` set, and compacts missing/pending session IDs to the + /// byte budget. Orphaned mappings come from sessions that were deleted or pruned in an + /// earlier pass and can dominate the artifact without contributing anything; the pending + /// path queue itself is left untouched. + private static func pruneOrphanedDiscovery( + _ cache: inout CostUsageCache, + maxCacheBytes: Int) -> Bool + { guard var discovery = cache.codexSessionDiscovery else { return false } let knownPaths = Set(cache.files.keys) let queuedPaths = Set(discovery.filePaths) @@ -613,8 +657,23 @@ enum CostUsageCacheIO { discovery.filePathBySessionId = discovery.filePathBySessionId.filter { _, path in queuedPaths.contains(path) || knownPaths.contains(path) } - let after = discovery.filePathBySessionId.count - guard after != before else { return false } + let mappingsChanged = discovery.filePathBySessionId.count != before + + // Compress missing/pending session-ID lists to what the remaining byte budget can + // hold. They are rediscoverable bookkeeping, not parsed data. + let idBytes = 48 + let baseEstimate = Self.estimatedCodexCacheBytes(cache) + - (discovery.missingSessionIds.count + discovery.pendingSessionIds.count) * idBytes + let keepCount = max(0, (maxCacheBytes - baseEstimate) / idBytes) + let missingChanged = discovery.missingSessionIds.count > keepCount + if missingChanged { + discovery.missingSessionIds = Array(discovery.missingSessionIds.prefix(keepCount)) + } + let pendingChanged = discovery.pendingSessionIds.count > keepCount + if pendingChanged { + discovery.pendingSessionIds = Array(discovery.pendingSessionIds.prefix(keepCount)) + } + guard mappingsChanged || missingChanged || pendingChanged else { return false } cache.codexSessionDiscovery = discovery return true } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 26da0cb8fe..9498618766 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -4449,8 +4449,8 @@ enum CostUsageScanner { guard cache.codexScanCatchUpPending == true, let previous = cache.codexPreviousReport, previous.matches( - scanSinceKey: range.scanSinceKey, - scanUntilKey: range.scanUntilKey, + scanSinceKey: range.sinceKey, + scanUntilKey: range.untilKey, timeZoneIdentifier: range.calendar.timeZone.identifier, roots: rootsFingerprint) else { return nil } diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index f52a9411be..ed82922923 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -848,6 +848,8 @@ struct CostUsageCacheTests { #expect(loaded.codexPreviousReport?.data.contains { $0.date == "2026-06-28" } == true) #expect(loaded.codexPreviousReport?.data.contains { $0.date == "2026-05-31" } == false) #expect(loaded.codexPreviousReport?.data.contains { $0.date == "2026-07-02" } == false) + #expect(loaded.codexPreviousReport?.scanSinceKey == "2026-06-01") + #expect(loaded.codexPreviousReport?.scanUntilKey == "2026-07-01") } @Test @@ -1228,7 +1230,9 @@ struct CostUsageCacheTests { provider: .codex, cacheRoot: root, producerKey: "codex:cu:p1111111111111111") - #expect(loaded.codexActiveLookbackState == nil) + let lookback = try #require(loaded.codexActiveLookbackState) + #expect(lookback.pendingFilePaths.isEmpty) + #expect(loaded.codexSessionDiscovery?.filePaths.contains("/sessions/pending-0.jsonl") == true) #expect(loaded.files["/sessions/in-window.jsonl"] != nil) } From 1f104be21407ffdd66e22c203a72a54365c26e71 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 4 Aug 2026 19:33:21 -0700 Subject: [PATCH 19/22] fix: lower cost-cache load cap and never persist a refusable artifact The 1 GiB load cap still let a 256 MiB-1 GiB legacy artifact be decoded in one shot, which is exactly the MALLOC_LARGE multi-GiB spike #2637 traced. Since save now bounds Codex artifacts to 256 MiB, anything meaningfully above the budget is legacy and cheaper to rebuild bounded; drop the cap to 320 MiB (budget + enforcement slack). Also make save uphold the loader's contract: when budget enforcement cannot shrink the payload below the load cap (unstrippable resume or buffered state), remove the artifact instead of writing one that every launch would decode just to refuse. --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 20 +++++-- Tests/CodexBarTests/CostUsageCacheTests.swift | 53 +++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 7af6bd42a0..7b77b7f35d 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 = "9eb581327d28b1a3" + static let value = "3e2d4edf88672f49" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 4b7fad82cc..77013ecdf2 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -10,8 +10,13 @@ enum CostUsageCacheIO { static let maxCacheFileBytes: Int = 256 * 1024 * 1024 static let maxCacheFileEntries: Int = 25000 /// Artifacts above this size are refused at load time and rebuilt by the bounded - /// scanner instead of being decoded in one shot. - static let maxCacheLoadBytes: Int = 1024 * 1024 * 1024 + /// scanner instead of being decoded in one shot. `JSONDecoder` materializes the whole + /// object graph at roughly an order of magnitude over the artifact size (#2637 traced + /// multi-GiB `MALLOC_LARGE` spikes to exactly this decode), so the cap stays close to + /// the save budget: `save` never persists a Codex artifact above the budget, which + /// means anything bigger is a legacy or foreign artifact that is cheaper to rebuild + /// bounded than to decode in one shot. + static let maxCacheLoadBytes: Int = 320 * 1024 * 1024 /// Producer keys from older parser hashes whose caches are still valid under the current /// delta semantics. Cleared for #2037: interleave containment changed how cumulative @@ -139,7 +144,8 @@ enum CostUsageCacheIO { requestedScanWindow: (sinceKey: String, untilKey: String)? = nil, reportWindow: (sinceKey: String, untilKey: String)? = nil, maxCacheBytes: Int = CostUsageCacheIO.maxCacheFileBytes, - maxCacheEntries: Int = CostUsageCacheIO.maxCacheFileEntries) + maxCacheEntries: Int = CostUsageCacheIO.maxCacheFileEntries, + maxCacheLoadBytes: Int = CostUsageCacheIO.maxCacheLoadBytes) { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) let dir = url.deletingLastPathComponent() @@ -202,6 +208,14 @@ enum CostUsageCacheIO { data = (try? JSONEncoder().encode(cache)) ?? Data() } } + if provider == .codex, data.count > maxCacheLoadBytes { + // Enforcement could not shrink the payload below what `load` accepts (e.g. the + // bulk lives in unstrippable resume/buffered state). Persisting it would make + // every launch decode a multi-GiB document just to refuse it; drop the artifact + // instead so the bounded scanner rebuilds from scratch. + try? FileManager.default.removeItem(at: url) + return + } try? data.write(to: url, options: [.atomic]) } diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index ed82922923..803369c03d 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -1348,6 +1348,59 @@ struct CostUsageCacheTests { #expect(loaded.files["/sessions/in-window.jsonl"] != nil) } + @Test + func `codex load cap keeps headroom over the save budget`() { + // `save` bounds the artifact to `maxCacheFileBytes`; the load cap must stay above it + // (with slack for enforcement overshoot) or every persisted artifact near the budget + // would be refused and rebuilt on the next launch. + #expect(CostUsageCacheIO.maxCacheLoadBytes > CostUsageCacheIO.maxCacheFileBytes) + } + + @Test + func `save removes the artifact when enforcement cannot fit the load cap`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let url = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data(repeating: 0x20, count: 128).write(to: url) + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + // An in-window entry that is still resuming keeps its buffered fork-retry lines: + // pruning, trimming, and detail stripping all skip it, so the payload cannot shrink. + var resuming = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) + resuming.codexScanComplete = false + resuming.codexBufferedSubagentLines = (0..<200).map { index in + CostUsageScanner.CodexBufferedFastLine( + lineIndex: index, + ordinal: nil, + line: .taskStarted(turnID: "turn-\(index)-\(String(repeating: "x", count: 600))")) + } + cache.files = ["/sessions/resuming.jsonl": resuming] + cache.days = ["2026-06-20": ["gpt-5.5": [1, 0, 0]]] + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), + maxCacheBytes: 1024, + maxCacheEntries: 100, + maxCacheLoadBytes: 50000) + + // Persisting an artifact the loader refuses would decode-and-discard it on every + // launch; the stale artifact must be gone so the bounded scanner rebuilds instead. + #expect(!FileManager.default.fileExists(atPath: url.path)) + } + private func makeTemporaryCacheRoot() throws -> URL { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-cost-cache-\(UUID().uuidString)", isDirectory: true) From 3970a96a67ae2e3f78e204d6a60e8fa5c6b4c6d2 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:08:16 +0800 Subject: [PATCH 20/22] Preserve recursive lookback roots and share ID capacity --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 41 ++++++++++++-- Tests/CodexBarTests/CostUsageCacheTests.swift | 55 ++++++++++++++++++- 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 7b77b7f35d..4172704cf8 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 = "3e2d4edf88672f49" + static let value = "0c35c6458f1cd628" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 77013ecdf2..052d87c1c7 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -623,6 +623,7 @@ enum CostUsageCacheIO { !lookback.pendingFilePaths.isEmpty || !lookback.legacyRecursivePendingRootPaths.isEmpty else { return false } let pendingPaths = lookback.pendingFilePaths + let legacyRoots = lookback.legacyRecursivePendingRootPaths if !pendingPaths.isEmpty { var discovery = cache.codexSessionDiscovery if discovery == nil { @@ -649,6 +650,33 @@ enum CostUsageCacheIO { } cache.codexSessionDiscovery = discovery } + if !legacyRoots.isEmpty { + var discovery = cache.codexSessionDiscovery + if discovery == nil { + discovery = CostUsageCodexSessionDiscovery( + roots: lookback.rootPaths, + generation: nil, + directoryStamps: [:], + directoryPaths: [], + nextDirectoryIndex: 0, + filePaths: [], + nextFileIndex: 0, + fileStamps: [:], + headScan: nil, + filePathBySessionId: [:], + missingSessionIds: [], + pendingSessionIds: [], + validationDirectoryIndex: 0, + isComplete: false) + } + var seen = Set(discovery?.directoryPaths ?? []) + for root in legacyRoots where !seen.contains(root) { + discovery?.directoryPaths.append(root) + seen.insert(root) + } + discovery?.nextDirectoryIndex = 0 + cache.codexSessionDiscovery = discovery + } lookback.pendingFilePaths = [] lookback.legacyRecursivePendingRootPaths = [] cache.codexActiveLookbackState = lookback @@ -674,18 +702,21 @@ enum CostUsageCacheIO { let mappingsChanged = discovery.filePathBySessionId.count != before // Compress missing/pending session-ID lists to what the remaining byte budget can - // hold. They are rediscoverable bookkeeping, not parsed data. + // hold, sharing one capacity across both lists. They are rediscoverable bookkeeping, + // not parsed data. let idBytes = 48 let baseEstimate = Self.estimatedCodexCacheBytes(cache) - (discovery.missingSessionIds.count + discovery.pendingSessionIds.count) * idBytes let keepCount = max(0, (maxCacheBytes - baseEstimate) / idBytes) - let missingChanged = discovery.missingSessionIds.count > keepCount + let keepMissing = min(discovery.missingSessionIds.count, keepCount) + let keepPending = min(discovery.pendingSessionIds.count, max(0, keepCount - keepMissing)) + let missingChanged = discovery.missingSessionIds.count > keepMissing if missingChanged { - discovery.missingSessionIds = Array(discovery.missingSessionIds.prefix(keepCount)) + discovery.missingSessionIds = Array(discovery.missingSessionIds.prefix(keepMissing)) } - let pendingChanged = discovery.pendingSessionIds.count > keepCount + let pendingChanged = discovery.pendingSessionIds.count > keepPending if pendingChanged { - discovery.pendingSessionIds = Array(discovery.pendingSessionIds.prefix(keepCount)) + discovery.pendingSessionIds = Array(discovery.pendingSessionIds.prefix(keepPending)) } guard mappingsChanged || missingChanged || pendingChanged else { return false } cache.codexSessionDiscovery = discovery diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 803369c03d..a8db101674 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -1214,7 +1214,7 @@ struct CostUsageCacheTests { nextDayKeyByRoot: ["/sessions": "2026-06-02"], completedRootPaths: [], pendingFilePaths: (0..<3000).map { "/sessions/pending-\($0).jsonl" }, - legacyRecursivePendingRootPaths: []) + legacyRecursivePendingRootPaths: ["/sessions/archive"]) let maxCacheBytes = 30000 CostUsageCacheIO.save( @@ -1232,7 +1232,9 @@ struct CostUsageCacheTests { producerKey: "codex:cu:p1111111111111111") let lookback = try #require(loaded.codexActiveLookbackState) #expect(lookback.pendingFilePaths.isEmpty) + #expect(lookback.legacyRecursivePendingRootPaths.isEmpty) #expect(loaded.codexSessionDiscovery?.filePaths.contains("/sessions/pending-0.jsonl") == true) + #expect(loaded.codexSessionDiscovery?.directoryPaths.contains("/sessions/archive") == true) #expect(loaded.files["/sessions/in-window.jsonl"] != nil) } @@ -1348,6 +1350,57 @@ struct CostUsageCacheTests { #expect(loaded.files["/sessions/in-window.jsonl"] != nil) } + @Test + func `save shares discovery id capacity across missing and pending lists`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + var inWindow = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) + inWindow.sessionId = "live-session" + cache.files = ["/sessions/in-window.jsonl": inWindow] + cache.days = ["2026-06-20": ["gpt-5.5": [1, 0, 0]]] + cache.codexSessionDiscovery = CostUsageCodexSessionDiscovery( + roots: ["/sessions"], + generation: nil, + directoryStamps: [:], + directoryPaths: [], + nextDirectoryIndex: 0, + filePaths: ["/sessions/in-window.jsonl"], + nextFileIndex: 0, + fileStamps: ["/sessions/in-window.jsonl": .init(mtimeUnixMs: 1, size: 100, fileId: nil)], + headScan: nil, + filePathBySessionId: ["live-session": "/sessions/in-window.jsonl"], + missingSessionIds: (0..<2000).map { "missing-\($0)" }, + pendingSessionIds: (0..<2000).map { "pending-\($0)" }, + validationDirectoryIndex: 0, + isComplete: true) + let maxCacheBytes = 30000 + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), + maxCacheBytes: maxCacheBytes, + maxCacheEntries: 100) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + let discovery = try #require(loaded.codexSessionDiscovery) + let combined = discovery.missingSessionIds.count + discovery.pendingSessionIds.count + #expect(combined <= maxCacheBytes / 48) + #expect(combined < 2000) + } + @Test func `codex load cap keeps headroom over the save budget`() { // `save` bounds the artifact to `maxCacheFileBytes`; the load cap must stay above it From 9fdc1a70088a49f05850f61c7580f68e6bfdac0a Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:30:26 +0800 Subject: [PATCH 21/22] Keep legacy lookback roots in the scan queue --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 37 +++---------------- Tests/CodexBarTests/CostUsageCacheTests.swift | 4 +- 3 files changed, 8 insertions(+), 35 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 5818c438bf..e0569d2068 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 = "e6cd0b92f60a0380" + static let value = "3e6ef813758110c1" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index c931eb8458..4556c66198 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -618,14 +618,15 @@ enum CostUsageCacheIO { } /// Compacts the persisted active-lookback state when it keeps the artifact over budget. - /// Pending paths are moved into the discovery queue (so no queued scan work is lost) - /// and the lookback structure is reduced to its empty shell. + /// Pending file paths are moved into the discovery queue (so no queued scan work is + /// lost). Legacy recursive roots are left untouched: the discovery directory queue is + /// only consumed by fork-parent lookup, not by the ordinary refresh file list, so + /// migrating them there would silently skip recently modified archived sessions. private static func clearActiveLookbackForBudget(_ cache: inout CostUsageCache) -> Bool { guard var lookback = cache.codexActiveLookbackState, - !lookback.pendingFilePaths.isEmpty || !lookback.legacyRecursivePendingRootPaths.isEmpty + !lookback.pendingFilePaths.isEmpty else { return false } let pendingPaths = lookback.pendingFilePaths - let legacyRoots = lookback.legacyRecursivePendingRootPaths if !pendingPaths.isEmpty { var discovery = cache.codexSessionDiscovery if discovery == nil { @@ -652,35 +653,7 @@ enum CostUsageCacheIO { } cache.codexSessionDiscovery = discovery } - if !legacyRoots.isEmpty { - var discovery = cache.codexSessionDiscovery - if discovery == nil { - discovery = CostUsageCodexSessionDiscovery( - roots: lookback.rootPaths, - generation: nil, - directoryStamps: [:], - directoryPaths: [], - nextDirectoryIndex: 0, - filePaths: [], - nextFileIndex: 0, - fileStamps: [:], - headScan: nil, - filePathBySessionId: [:], - missingSessionIds: [], - pendingSessionIds: [], - validationDirectoryIndex: 0, - isComplete: false) - } - var seen = Set(discovery?.directoryPaths ?? []) - for root in legacyRoots where !seen.contains(root) { - discovery?.directoryPaths.append(root) - seen.insert(root) - } - discovery?.nextDirectoryIndex = 0 - cache.codexSessionDiscovery = discovery - } lookback.pendingFilePaths = [] - lookback.legacyRecursivePendingRootPaths = [] cache.codexActiveLookbackState = lookback return true } diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index f2d533e983..3fae9e07da 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -1255,9 +1255,9 @@ struct CostUsageCacheTests { producerKey: "codex:cu:p1111111111111111") let lookback = try #require(loaded.codexActiveLookbackState) #expect(lookback.pendingFilePaths.isEmpty) - #expect(lookback.legacyRecursivePendingRootPaths.isEmpty) + #expect(lookback.legacyRecursivePendingRootPaths == ["/sessions/archive"]) #expect(loaded.codexSessionDiscovery?.filePaths.contains("/sessions/pending-0.jsonl") == true) - #expect(loaded.codexSessionDiscovery?.directoryPaths.contains("/sessions/archive") == true) + #expect(loaded.codexSessionDiscovery?.directoryPaths.isEmpty == true) #expect(loaded.files["/sessions/in-window.jsonl"] != nil) } From c6004b3fa0a38b2c5de7d01252359154fc04dd00 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:02:32 +0800 Subject: [PATCH 22/22] Preserve complete report across repeated trims --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 24 ++++--- Tests/CodexBarTests/CostUsageCacheTests.swift | 66 +++++++++++++++++++ 3 files changed, 82 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index e0569d2068..97d361de19 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 = "3e6ef813758110c1" + static let value = "6c0f1fa950e63467" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 4556c66198..715f3af7e4 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -362,10 +362,12 @@ enum CostUsageCacheIO { // Preserve the complete report from the untrimmed cache so catch-up displays full // totals instead of the reduced window after a restart. let preTrimCache = cache - let previousReport = Self.previousReportForCatchUp( - cache: preTrimCache, - calendar: calendar, - reportWindow: reportWindow) + let previousReport = cache.codexPreviousReport == nil + ? Self.previousReportForCatchUp( + cache: preTrimCache, + calendar: calendar, + reportWindow: reportWindow) + : nil // Drop oldest usage first so recent sessions keep their fork-baseline detail. let oldestFirst = droppable.sorted { lhs, rhs in @@ -423,7 +425,9 @@ enum CostUsageCacheIO { // mark the cache as needing catch-up so a cold restart re-scans them promptly. cache.codexScanCatchUpPending = true cache.lastScanUnixMs = 0 - cache.codexPreviousReport = previousReport + if cache.codexPreviousReport == nil { + cache.codexPreviousReport = previousReport + } } return !droppedKeys.isEmpty || stripped } @@ -502,10 +506,12 @@ enum CostUsageCacheIO { if strippedAny { cache.codexScanCatchUpPending = true cache.lastScanUnixMs = 0 - cache.codexPreviousReport = Self.previousReportForCatchUp( - cache: preStripCache, - calendar: calendar, - reportWindow: reportWindow) + if cache.codexPreviousReport == nil { + cache.codexPreviousReport = Self.previousReportForCatchUp( + cache: preStripCache, + calendar: calendar, + reportWindow: reportWindow) + } } return strippedAny } diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 3fae9e07da..c43db3c1bf 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -1424,6 +1424,72 @@ struct CostUsageCacheTests { #expect(combined < 2000) } + @Test + func `save preserves an existing complete report across repeated trims`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.scanSinceKey = "2026-06-01" + cache.scanUntilKey = "2026-07-01" + var older = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 1_000_000, + days: ["2026-06-05": ["gpt-5.5": [1, 0, 0]]]) + older.sessionId = "older-session" + older.codexTokenSnapshots = (0..<1000).map { index in + CostUsageCodexTokenSnapshot( + timestamp: "2026-06-05T00:00:0\(index % 10)Z", + last: nil, + total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) + } + var recent = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 100, + days: ["2026-06-28": ["gpt-5.5": [1, 0, 0]]]) + recent.sessionId = "recent-session" + cache.files = [ + "/sessions/older.jsonl": older, + "/sessions/recent.jsonl": recent, + ] + cache.days = [ + "2026-06-05": ["gpt-5.5": [1, 0, 0]], + "2026-06-28": ["gpt-5.5": [1, 0, 0]], + ] + // Simulate an already pending catch-up pass with a complete previous report. + cache.codexScanCatchUpPending = true + cache.codexPreviousReport = CostUsageCodexPreviousReport( + report: CostUsageDailyReport(data: [ + CostUsageDailyReport.Entry( + date: "2026-06-05", + inputTokens: 1, + outputTokens: 0, + totalTokens: 1, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: nil), + ], summary: nil), + cache: cache) + + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111", + requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), + maxCacheBytes: 30000, + maxCacheEntries: 100) + + let loaded = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: root, + producerKey: "codex:cu:p1111111111111111") + let preserved = try #require(loaded.codexPreviousReport) + #expect(preserved.data.count == 1) + #expect(preserved.data.first?.date == "2026-06-05") + #expect(preserved.data.contains { $0.date == "2026-06-28" } == false) + } + @Test func `codex load cap keeps headroom over the save budget`() { // `save` bounds the artifact to `maxCacheFileBytes`; the load cap must stay above it