diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 09cd6eccf3..deb71cf96e 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 = "4969a789db679c93" + static let value = "f5fdba377006d7be" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 8301f6fd60..9f10df5d39 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -3059,6 +3059,7 @@ enum CostUsageScanner { state: &state) } + /// Keep waiting paths ahead of revisits when changing pricing or priority metadata reseeds the inventory. private static func reseedCodexActiveLookbackPathKeys( _ pathKeys: some Sequence, state: inout CostUsageCodexActiveLookbackState) @@ -3070,10 +3071,10 @@ enum CostUsageScanner { guard queuedPaths.insert(pathKey).inserted else { return } reseededPaths.append(pathKey) } - for path in pathKeys { + for path in state.pendingFilePaths { append(path) } - for path in state.pendingFilePaths { + for path in pathKeys { append(path) } state.pendingFilePaths = reseededPaths diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift index ec86bac5b9..835cb305ae 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift @@ -80,6 +80,7 @@ actor CostUsageStore { parserHash: CodexParserHash.value) static let cacheGeneration = "sqlite:\(CostUsageStore.schemaVersion)" static let compatiblePredecessorParserHashes: Set = [ + "4969a789db679c93", // 0.58.0 native rows, checkpoints, and reports survive queue reordering. "c4fa7db2cf54bc41", // Parser revisions reparse older native files while preserving stored rows and checkpoints. "ca4bc3875600536f", // Reserve pricing stores retain compatible rows and checkpoints. "7f00691fa96c78d1", // Current-main row and checkpoint formats remain compatible. diff --git a/Tests/CodexBarTests/CostUsageBoundedProgressTests.swift b/Tests/CodexBarTests/CostUsageBoundedProgressTests.swift index fb32b85510..2bcba55e88 100644 --- a/Tests/CodexBarTests/CostUsageBoundedProgressTests.swift +++ b/Tests/CodexBarTests/CostUsageBoundedProgressTests.swift @@ -1106,6 +1106,73 @@ struct CostUsageBoundedProgressTests { completedCurrentWindowFlatRootPaths: roots) } + @Test + func `cache-wide migration reseed keeps the stale queue head ahead of revisited files`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + var options = Self.boundedOptions(env: env) + let since = try #require(options.calendar.date(byAdding: .day, value: -364, to: day)) + let files = try Self.writeSyntheticCorpus(env: env, day: day, fileCount: 600) + for (index, file) in files.enumerated() { + try FileManager.default.setAttributes( + [.modificationDate: day.addingTimeInterval(Double(-index))], ofItemAtPath: file.path) + } + options.maxCodexScanDurationPerRefresh = nil + _ = CostUsageScanner.loadDailyReport( + provider: .codex, since: since, until: day, now: day, options: options) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) + #expect(cache.files.count == 600) + #expect(cache.codexScanCatchUpPending == false) + + // The 88 oldest files still carry the previous parser revision; the newest 512 are current. + let stalePaths = files[512...].map { $0.resolvingSymlinksInPath().path } + for path in stalePaths { + #expect(cache.files[path] != nil) + cache.files[path]?.codexParserRevision = nil + } + cache.codexPricingKey = "migration-generation-1" + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) + + options.maxCodexScanDurationPerRefresh = 60 + for pass in 1...2 { + if pass > 1 { + // A cache-wide requirement (pricing key, priority turns) can change on every pass. + var mutated = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) + mutated.codexPricingKey = "migration-generation-\(pass)" + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: mutated) + } + let recorder = CostUsageScanner.CodexScanWorkRecorder() + options.codexScanWorkRecorderForTesting = recorder + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: since, + until: day, + now: day.addingTimeInterval(Double(pass)), + options: options) + #expect(recorder.snapshot().codexFileScanAttempts == CostUsageScanner.codexCatchUpScanCandidateLimit) + } + let migrated = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) + let remainingStale = stalePaths.filter { migrated.files[$0]?.hasCurrentCodexParser != true } + #expect(remainingStale.isEmpty, "stale files never reached the bounded pass: \(remainingStale.count)") + #expect(migrated.codexActiveLookbackState?.pendingFilePaths.count == 88) + + options.codexScanWorkRecorderForTesting = nil + for index in 0..<3 { + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: since, + until: day, + now: day.addingTimeInterval(Double(index + 10)), + options: options) + } + let completed = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) + #expect(completed.codexScanCatchUpPending == false) + #expect(completed.codexActiveLookbackState == nil) + #expect(completed.codexScanCompletedFiles == 600) + #expect(completed.codexScanTotalFiles == 600) + } + private static func boundedOptions(env: CostUsageTestEnvironment) -> CostUsageScanner.Options { var options = CostUsageScanner.Options( codexSessionsRoot: env.codexSessionsRoot, diff --git a/Tests/CodexBarTests/CostUsageCacheWideMigrationTests.swift b/Tests/CodexBarTests/CostUsageCacheWideMigrationTests.swift index 3b88e4b887..9e7fc21bca 100644 --- a/Tests/CodexBarTests/CostUsageCacheWideMigrationTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheWideMigrationTests.swift @@ -51,7 +51,7 @@ struct CostUsageCacheWideMigrationTests { } @Test - func `cache wide migration reorders a partially drained queue newest first`() throws { + func `cache wide migration preserves discovered waiters before reseeded files`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) @@ -90,10 +90,10 @@ struct CostUsageCacheWideMigrationTests { let migratedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) #expect(recorder.snapshot().codexFileScanAttempts == CostUsageScanner.codexCatchUpScanCandidateLimit) #expect(recorder.attemptedCodexFilePaths().contains(newestURL.path)) - #expect(!recorder.attemptedCodexFilePaths().contains(oldestURL.path)) + #expect(recorder.attemptedCodexFilePaths().contains(oldestURL.path)) #expect(migratedCache.codexActiveLookbackState?.pendingFilePaths.count == corpusSize - CostUsageScanner.codexCatchUpScanCandidateLimit) - #expect(migratedCache.codexActiveLookbackState?.pendingFilePaths.contains(oldestURL.path) == true) + #expect(migratedCache.codexActiveLookbackState?.pendingFilePaths.contains(oldestURL.path) == false) } @Test(arguments: CacheWideMigrationCase.allCases) diff --git a/Tests/CodexBarTests/CostUsageStoreTests.swift b/Tests/CodexBarTests/CostUsageStoreTests.swift index 8559d0f942..d83ca53f26 100644 --- a/Tests/CodexBarTests/CostUsageStoreTests.swift +++ b/Tests/CodexBarTests/CostUsageStoreTests.swift @@ -1006,6 +1006,7 @@ extension CostUsageStoreTests { extension CostUsageStoreTests { @Test(arguments: [ + "4969a789db679c93", // Released in 0.58.0. "c4fa7db2cf54bc41", "ca4bc3875600536f", "7f00691fa96c78d1", @@ -1034,6 +1035,7 @@ extension CostUsageStoreTests { let fixture = try StoreFixture() defer { fixture.remove() } #expect(CostUsageStore.compatiblePredecessorParserHashes == [ + "4969a789db679c93", "c4fa7db2cf54bc41", "ca4bc3875600536f", "7f00691fa96c78d1", diff --git a/docs/codex.md b/docs/codex.md index a37112de80..ab3cb9ea7e 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -272,6 +272,7 @@ is limited, using additional rows when needed. - Automatic Codex catch-up scheduling in both usage and Spend Dashboard honors the app’s 30-minute Low Power Mode minimum after each pass. Explicit acceleration remains immediate, and physical low-power/thermal pauses retain their own retry policy. The setting applies when the next delay is computed; an already pending sleep is not replanned. - Automatic catch-up reports thermal pressure when serious heat and Low Power Mode coexist. Both constraints keep the existing 60-second pause before rechecking resource state. - A catch-up worker that loses its account or settings scope clears its abandoned Refreshing activity on exit. Legitimate pauses remain visible, and an older worker cannot clear a replacement worker's activity. +- Cache-wide migration reseeding keeps paths already waiting ahead of new revisits. Repeated pricing or priority-turn changes therefore cannot keep the same completed files ahead of the stale tail in each 512-candidate pass. Initial seeding still honors newest-first preference, and publication waits for exact inventory validation. Native Codex stores from published parser fingerprint `4969a789db679c93` adopt the new generation without rebuilding rows, checkpoints, or retained reports; Pi/OMP retains its existing one-time reparse on a parser-hash change. - When a warm cost refresh reaches its time limit, it saves the remaining file work and completed discovery. Compatible shorter/wider history requests resume that work across the retained scan range; publication still waits for exact inventory validation. - Inline cost charts preserve a slot for every day in that window, using the selected cost-bucket time zone and the snapshot's date. Missing days are zero only after history coverage is established; unscanned days and entries without prices remain unknown. Long windows fit within the menu width without dropping dates. - **Hide personal information** replaces project/source names with numbered labels and hides their paths in the cost-history submenu; Usage & Spend also masks project names. Costs, tokens, grouping, and stored history are unchanged, and disabling the setting restores the original labels. This is display masking, not data deletion or export sanitization.