diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+Retention.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+Retention.swift index 4eb4e40465..1232b10892 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+Retention.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+Retention.swift @@ -128,11 +128,11 @@ extension CostUsageStore { untilDay: String?, calendar: Calendar) -> Range? { + let scanCalendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar(matching: calendar) guard let sinceDay, let untilDay, - let since = CostUsageScanner.parseDayKey(sinceDay, calendar: calendar), - let until = CostUsageScanner.parseDayKey(untilDay, calendar: calendar) + let since = Self.dayStart(sinceDay, calendar: scanCalendar), + let until = Self.dayStart(untilDay, calendar: scanCalendar) else { return nil } - let scanCalendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar(matching: calendar) let end = scanCalendar.date(byAdding: .day, value: 1, to: until) ?? until let lower = Int64(since.timeIntervalSince1970 * 1000) let upper = Int64(end.timeIntervalSince1970 * 1000) @@ -140,6 +140,21 @@ extension CostUsageStore { return lower.. Date? { + let parts = key.split(separator: "-") + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]) + else { return nil } + return calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: year, + month: month, + day: day)) + } + private static func retentionCandidates( _ database: OpaquePointer, sinceDay: String, diff --git a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift index b376e795f9..9919ff5970 100644 --- a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift +++ b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift @@ -55,6 +55,100 @@ struct CostUsagePerformanceGateTests { #expect(warm.data.first?.totalTokens == cold.data.first?.totalTokens) } + @Test + func `over budget prune retains stale coverage file modified inside the window`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let oldDay = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let windowStart = try env.makeLocalNoon(year: 2026, month: 7, day: 27) + let windowDay = try env.makeLocalNoon(year: 2026, month: 8, day: 2) + let oldISO = env.isoString(for: oldDay) + + // One still-active session whose usage rows are all out of window, plus idle stale + // sessions. The active file lives in the scanned window's directory and keeps an + // in-window mtime, exactly like a session that stopped producing usage weeks ago. + let activeURL = try env.writeCodexSessionFile( + day: windowDay, + filename: "stale-active.jsonl", + contents: [ + #"{"type":"session_meta","timestamp":"\#(oldISO)","payload":{"session_id":"stale-active-session"}}"#, + #"{"type":"turn_context","timestamp":"\#(oldISO)","payload":{"model":"openai/gpt-5.2-codex"}}"#, + #"{"type":"event_msg","timestamp":"\#(oldISO)","payload":{"type":"token_count","info":"# + + #"{"total_token_usage":{"input_tokens":100,"cached_input_tokens":20,"output_tokens":10},"# + + #""model":"openai/gpt-5.2-codex"}}}"#, + ].joined(separator: "\n") + "\n") + try FileManager.default.setAttributes( + [.modificationDate: windowDay], + ofItemAtPath: activeURL.path) + for index in 0..<30 { + let idleURL = try env.writeCodexSessionFile( + day: windowDay, + filename: "idle-\(index).jsonl", + contents: [ + #"{"type":"session_meta","timestamp":"\#(oldISO)","payload":{"session_id":"idle-\#(index)"}}"#, + #"{"type":"event_msg","timestamp":"\#(oldISO)","payload":{"type":"token_count","info":"# + + #"{"total_token_usage":{"input_tokens":10,"cached_input_tokens":2,"output_tokens":1},"# + + #""model":"openai/gpt-5.2-codex"}}}"#, + ].joined(separator: "\n") + "\n") + try FileManager.default.setAttributes( + [.modificationDate: oldDay], + ofItemAtPath: idleURL.path) + } + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowDay, + now: windowDay, + options: options) + + let store = CostUsageStore(cacheRoot: env.cacheRoot) + let activeFile = { (files: [CostUsageStoreFile]) -> CostUsageStoreFile? in + files.first { $0.sessionID == "stale-active-session" } + } + let coldRow = try #require(await activeFile(store.readSnapshot().files)) + #expect(coldRow.scanState.isComplete) + let coldAnchor = coldRow.anchor?.sha256 + let coldParsedBytes = coldRow.parsedBytes + + // Force the over-budget branch of the production save path: 31 retained files + // exceed the 1-file row budget, so the window prune must run before the row cap. + let budget = await store.enforceBudgets( + maxRows: 1, + maxFileBytes: .max, + requestedSinceDay: Self.dayKeyString(for: windowStart), + requestedUntilDay: Self.dayKeyString(for: windowDay), + calendar: .current) + #expect(budget.rowCount == 1) + let retained = try #require(await activeFile(store.readSnapshot().files)) + #expect(retained.scanState.isComplete) + #expect(retained.anchor?.sha256 == coldAnchor) + print("[retention-proof] stale-coverage file retained after over-budget prune: \(retained.path)") + + let warmCounter = HeadParseCounter() + _ = CostUsageScanner.withCodexSessionHeadParseObserverForTesting { + warmCounter.increment() + } operation: { + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowDay, + now: windowDay, + options: options) + } + #expect(warmCounter.value == 0) + let warmRow = try #require(await activeFile(store.readSnapshot().files)) + #expect(warmRow.anchor?.sha256 == coldAnchor) + #expect(warmRow.parsedBytes == coldParsedBytes) + print("[retention-proof] warm refresh reused the cached row, headParses=0") + } + @Test func `priority turns refresh must scan only appended trace rows`() throws { let env = try CostUsageTestEnvironment() @@ -1405,6 +1499,14 @@ extension CostUsagePerformanceGateTests { } return Int64(CostUsageScanner.codexActiveSessionLookbackDays * existingRootCount) } + + private static func dayKeyString(for date: Date) -> String { + let components = Calendar.current.dateComponents([.year, .month, .day], from: date) + let year = components.year ?? 0 + let month = components.month ?? 0 + let day = components.day ?? 0 + return String(format: "%04d-%02d-%02d", year, month, day) + } } private final class HeadParseCounter: @unchecked Sendable { diff --git a/Tests/CodexBarTests/CostUsageStoreTests.swift b/Tests/CodexBarTests/CostUsageStoreTests.swift index bd2843d39b..6fcc5a7a1a 100644 --- a/Tests/CodexBarTests/CostUsageStoreTests.swift +++ b/Tests/CodexBarTests/CostUsageStoreTests.swift @@ -554,6 +554,110 @@ extension CostUsageStoreTests { #expect(await store.fetchFile(path: parent.path) != nil) } + @Test + func `retention keeps recently modified file with stale coverage`() async throws { + let fixture = try StoreFixture() + defer { fixture.remove() } + let store = CostUsageStore(cacheRoot: fixture.root) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "UTC")) + var file = Self.file(path: "/rollouts/stale-but-active.jsonl", day: "2026-07-01") + let recentMtime = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 8, + day: 1, + hour: 6))) + file.mtimeUnixMs = Int64(recentMtime.timeIntervalSince1970 * 1000) + #expect(await store.upsertFile(file)) + + _ = await store.retainDayWindow( + sinceDay: "2026-08-01", + untilDay: "2026-08-03", + calendar: calendar) + #expect(await store.fetchFile(path: file.path) != nil) + } + + @Test + func `retention prunes stale file modified before the window`() async throws { + let fixture = try StoreFixture() + defer { fixture.remove() } + let store = CostUsageStore(cacheRoot: fixture.root) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "UTC")) + var file = Self.file(path: "/rollouts/stale-and-idle.jsonl", day: "2026-07-01") + let oldMtime = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 7, + day: 1, + hour: 12))) + file.mtimeUnixMs = Int64(oldMtime.timeIntervalSince1970 * 1000) + #expect(await store.upsertFile(file)) + + _ = await store.retainDayWindow( + sinceDay: "2026-08-01", + untilDay: "2026-08-03", + calendar: calendar) + #expect(await store.fetchFile(path: file.path) == nil) + } + + @Test + func `retention prunes stale file modified after the window`() async throws { + let fixture = try StoreFixture() + defer { fixture.remove() } + let store = CostUsageStore(cacheRoot: fixture.root) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "UTC")) + var file = Self.file(path: "/rollouts/stale-after-window.jsonl", day: "2026-07-01") + let lateMtime = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 8, + day: 4, + hour: 12))) + file.mtimeUnixMs = Int64(lateMtime.timeIntervalSince1970 * 1000) + #expect(await store.upsertFile(file)) + + _ = await store.retainDayWindow( + sinceDay: "2026-08-01", + untilDay: "2026-08-03", + calendar: calendar) + #expect(await store.fetchFile(path: file.path) == nil) + } + + @Test + func `retention keeps stale file modified at the window edges`() async throws { + let fixture = try StoreFixture() + defer { fixture.remove() } + let store = CostUsageStore(cacheRoot: fixture.root) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "UTC")) + let startMtime = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 8, + day: 1, + hour: 0))) + let endMtime = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 8, + day: 3, + hour: 23, + minute: 59, + second: 59))) + for (index, mtime) in [startMtime, endMtime].enumerated() { + var file = Self.file( + path: "/rollouts/edge-\(index).jsonl", + day: "2026-07-01") + file.mtimeUnixMs = Int64(mtime.timeIntervalSince1970 * 1000) + #expect(await store.upsertFile(file)) + } + + _ = await store.retainDayWindow( + sinceDay: "2026-08-01", + untilDay: "2026-08-03", + calendar: calendar) + #expect(await store.fetchFile(path: "/rollouts/edge-0.jsonl") != nil) + #expect(await store.fetchFile(path: "/rollouts/edge-1.jsonl") != nil) + } + @Test func `retention prunes discovery references for removed files`() async throws { let fixture = try StoreFixture()