diff --git a/CHANGELOG.md b/CHANGELOG.md index 46776593db..55e310c1c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.49.6 — Unreleased +### Fixed +- Codex: preserve the wider retained cost history when Usage & Spend saves a 7- or 30-day projection under cache row/byte pressure, instead of pruning older rows and narrowing the persisted scan window (#2914, #2915). Thanks @thomaschow19! + ## 0.49.5 — 2026-08-13 ### Fixed diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift index 2bb0991e7d..e9001f077c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift @@ -68,6 +68,9 @@ extension CostUsageStore { var cache = cache Self.reconcileCompletedCodexCatchUp(cache: &cache) let previous = self.readSnapshot() + let budgetProtectionWindow = Self.budgetProtectionWindow( + cache: cache, + requestedScanWindow: requestedScanWindow) if skipIdenticalContent, Self.persistedContentMatches( previous: previous, @@ -79,8 +82,8 @@ extension CostUsageStore { let result = self.enforceBudgets( maxRows: rowBudget, maxFileBytes: fileBudgetBytes, - requestedSinceDay: requestedScanWindow.sinceKey, - requestedUntilDay: requestedScanWindow.untilKey, + requestedSinceDay: budgetProtectionWindow.sinceKey, + requestedUntilDay: budgetProtectionWindow.untilKey, calendar: calendar) guard !result.catchUpRequired else { return result } Self.identicalContentPreLockCheckpointForTesting?() @@ -159,8 +162,8 @@ extension CostUsageStore { let result = self.enforceBudgets( maxRows: rowBudget, maxFileBytes: fileBudgetBytes, - requestedSinceDay: requestedScanWindow.sinceKey, - requestedUntilDay: requestedScanWindow.untilKey, + requestedSinceDay: budgetProtectionWindow.sinceKey, + requestedUntilDay: budgetProtectionWindow.untilKey, calendar: calendar) if result.catchUpRequired, self.fetchMetadata().previousReportPayload == nil, let previous = Self.previousReport(cache: cache, calendar: calendar, reportWindow: reportWindow) @@ -207,6 +210,21 @@ extension CostUsageStore { usage.codexScanComplete = usage.codexScanComplete ?? true return usage } + + private static func budgetProtectionWindow( + cache: CostUsageCache, + requestedScanWindow: (sinceKey: String, untilKey: String)) -> (sinceKey: String, untilKey: String) + { + // Report and dashboard windows are projections, not retention boundaries. Preserve the + // cache's retained coverage while still protecting any newly requested extension. + guard let retainedSinceKey = cache.scanSinceKey, + let retainedUntilKey = cache.scanUntilKey, + retainedSinceKey <= retainedUntilKey + else { return requestedScanWindow } + return ( + sinceKey: min(retainedSinceKey, requestedScanWindow.sinceKey), + untilKey: max(retainedUntilKey, requestedScanWindow.untilKey)) + } } // MARK: - Cache conversion diff --git a/Tests/CodexBarTests/CostUsageStoreTests.swift b/Tests/CodexBarTests/CostUsageStoreTests.swift index 3e6c3e4da9..6b4b87a67e 100644 --- a/Tests/CodexBarTests/CostUsageStoreTests.swift +++ b/Tests/CodexBarTests/CostUsageStoreTests.swift @@ -1621,6 +1621,74 @@ extension CostUsageStoreTests { } extension CostUsageStoreTests { + @Test + func `narrow dashboard windows preserve wider retained cache under byte pressure`() async throws { + let fixture = try StoreFixture() + defer { fixture.remove() } + let store = CostUsageStore(cacheRoot: fixture.root) + let calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar() + let retainedWindow = (sinceKey: "2025-08-15", untilKey: "2026-08-14") + let olderDay = "2026-01-15" + let recentDay = "2026-08-14" + let model = "gpt-5.5" + let olderPath = "/rollouts/older.jsonl" + let recentPath = "/rollouts/recent.jsonl" + + func usage(day: String, mtimeUnixMs: Int64) -> CostUsageFileUsage { + var value = CostUsageFileUsage( + mtimeUnixMs: mtimeUnixMs, + size: 500, + days: [day: [model: [1, 0, 0]]]) + value.parsedBytes = 500 + value.codexScanComplete = true + return value + } + + let olderUsage = usage(day: olderDay, mtimeUnixMs: 1) + let recentUsage = usage(day: recentDay, mtimeUnixMs: 2) + var cache = CostUsageCache() + cache.scanSinceKey = retainedWindow.sinceKey + cache.scanUntilKey = retainedWindow.untilKey + cache.timeZoneIdentifier = calendar.timeZone.identifier + cache.files = [olderPath: olderUsage, recentPath: recentUsage] + cache.days = olderUsage.days.merging(recentUsage.days) { _, recent in recent } + + let seeded = store.syncSaveCodexCache( + cache, + calendar: calendar, + requestedScanWindow: retainedWindow, + fileBudgetBytes: 1) + #expect(seeded.deletedRows == 0) + #expect(seeded.rowCount == 2) + + let dashboardWindows = [ + (sinceKey: "2026-07-16", skipIdenticalContent: true), + (sinceKey: "2026-08-08", skipIdenticalContent: false), + ] + for dashboardWindow in dashboardWindows { + let result = store.syncSaveCodexCache( + cache, + calendar: calendar, + requestedScanWindow: (sinceKey: dashboardWindow.sinceKey, untilKey: recentDay), + fileBudgetBytes: 1, + skipIdenticalContent: dashboardWindow.skipIdenticalContent) + let report = await store.readReport( + sinceDay: retainedWindow.sinceKey, + untilDay: retainedWindow.untilKey) + let metadata = await store.fetchMetadata() + + #expect(result.catchUpRequired == false) + #expect(result.deletedRows == 0) + #expect(result.rowCount == 2) + #expect(result.fileBytes > 1) + #expect(await store.fetchFile(path: olderPath) != nil) + #expect(await store.fetchFile(path: recentPath) != nil) + #expect(report.aggregates.map(\.day) == [olderDay, recentDay]) + #expect(metadata.scanSinceDay == retainedWindow.sinceKey) + #expect(metadata.scanUntilDay == retainedWindow.untilKey) + } + } + @Test func `row budget deletes oldest rows to cap`() async throws { let fixture = try StoreFixture()