Skip to content

Commit 185c516

Browse files
committed
Fix Finish now history catch-up
1 parent e5528d4 commit 185c516

5 files changed

Lines changed: 311 additions & 24 deletions

File tree

Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ extension UsageStore {
3131
return
3232
}
3333

34-
let scopeSignature = accounts
34+
let historyDays = max(SpendDashboardSource.scanDays, self.settings.costUsageHistoryDays)
35+
let accountScopeSignature = accounts
3536
.map { "\($0.id)|\($0.cacheIdentity)" }
3637
.joined(separator: "\u{0}")
38+
let scopeSignature = "\(historyDays)\u{0}\(accountScopeSignature)"
3739
if self.spendDashboardCodexCostCatchUpTask != nil,
3840
self.spendDashboardCodexCostCatchUpScopeSignature == scopeSignature
3941
{
@@ -51,7 +53,7 @@ extension UsageStore {
5153
let context = SpendDashboardCodexCostCatchUpContext(
5254
token: token,
5355
accounts: accounts,
54-
historyDays: SpendDashboardSource.scanDays,
56+
historyDays: historyDays,
5557
scopeSignature: scopeSignature,
5658
providerConfigRevision: self.settings.providerConfigRevision(for: .codex),
5759
costUsageSettingsRevision: self.settings.costUsageSettingsRevision)
@@ -245,6 +247,7 @@ extension UsageStore {
245247
&& self.spendDashboardCodexCostCatchUpScopeSignature == context.scopeSignature
246248
&& self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision
247249
&& self.settings.costUsageSettingsRevision == context.costUsageSettingsRevision
250+
&& max(SpendDashboardSource.scanDays, self.settings.costUsageHistoryDays) == context.historyDays
248251
&& self.settings.isCostUsageEffectivelyEnabled(for: .codex)
249252
&& self.isEnabled(.codex)
250253
&& context.accounts.allSatisfy(SpendDashboardSource.codexAuthFingerprintMatches)

Sources/CodexBarCore/CostUsageFetcher.swift

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -312,26 +312,121 @@ public struct CostUsageFetcher: Sendable {
312312
}
313313

314314
let scoped = CostUsageScanner.codexCache(cache, scopedTo: roots)
315-
var progressHasher = Hasher()
316-
for (path, usage) in scoped.files.sorted(by: { $0.key < $1.key }) {
317-
progressHasher.combine(path)
318-
progressHasher.combine(usage.codexScanFileId)
319-
progressHasher.combine(usage.parsedBytes)
320-
progressHasher.combine(usage.size)
321-
progressHasher.combine(usage.codexScanComplete)
322-
}
323315
let hasIncompleteFile = scoped.files.values.contains { $0.codexScanComplete == false }
324316
let pending = cache.codexScanCatchUpPending == true || hasIncompleteFile
325317
return CodexScanCatchUpStatus(
326318
pending: pending,
327-
progressKey: "\(scoped.files.count):\(progressHasher.finalize())",
319+
progressKey: Self.codexScanCatchUpProgressKey(cache: cache, scoped: scoped),
328320
processedBytes: cache.codexScanProcessedBytes ?? 0,
329321
totalBytes: cache.codexScanTotalBytes ?? 0,
330322
completedFiles: cache.codexScanCompletedFiles ?? 0,
331323
totalFiles: cache.codexScanTotalFiles ?? 0,
332324
staleSnapshotUpdatedAt: pending ? cache.codexPreviousReport?.updatedAt : nil)
333325
}
334326

327+
private static func codexScanCatchUpProgressKey(
328+
cache: CostUsageCache,
329+
scoped: CostUsageCache) -> String
330+
{
331+
var hasher = Hasher()
332+
hasher.combine(cache.codexScanProcessedBytes)
333+
hasher.combine(cache.codexScanTotalBytes)
334+
hasher.combine(cache.codexScanCompletedFiles)
335+
hasher.combine(cache.codexScanTotalFiles)
336+
337+
for (path, usage) in scoped.files.sorted(by: { $0.key < $1.key }) {
338+
hasher.combine(path)
339+
hasher.combine(usage.codexScanFileId)
340+
hasher.combine(usage.parsedBytes)
341+
hasher.combine(usage.size)
342+
hasher.combine(usage.codexScanTargetSize)
343+
hasher.combine(usage.codexScanComplete)
344+
hasher.combine(usage.codexJSONLResumeState?.offset)
345+
hasher.combine(usage.forkBaselineDependencyKey)
346+
Self.combineCodexBufferedProgress(usage.codexBufferedSubagentLines, into: &hasher)
347+
Self.combineCodexBufferedProgress(usage.codexBufferedUnresolvedForkLines, into: &hasher)
348+
}
349+
Self.combineCodexDiscoveryProgress(cache.codexSessionDiscovery, into: &hasher)
350+
Self.combineCodexActiveLookbackProgress(cache.codexActiveLookbackState, into: &hasher)
351+
return "\(scoped.files.count):\(hasher.finalize())"
352+
}
353+
354+
private static func combineCodexBufferedProgress(
355+
_ lines: [CostUsageScanner.CodexBufferedFastLine]?,
356+
into hasher: inout Hasher)
357+
{
358+
hasher.combine(lines?.count)
359+
for line in lines ?? [] {
360+
hasher.combine(line.lineIndex)
361+
hasher.combine(line.ordinal)
362+
hasher.combine(line.endOffset)
363+
}
364+
}
365+
366+
private static func combineCodexDiscoveryProgress(
367+
_ discovery: CostUsageCodexSessionDiscovery?,
368+
into hasher: inout Hasher)
369+
{
370+
hasher.combine(discovery != nil)
371+
guard let discovery else { return }
372+
for root in discovery.roots.sorted() {
373+
hasher.combine(root)
374+
}
375+
hasher.combine(discovery.generation)
376+
hasher.combine(discovery.directoryPaths.count)
377+
hasher.combine(discovery.nextDirectoryIndex)
378+
hasher.combine(discovery.filePaths.count)
379+
hasher.combine(discovery.nextFileIndex)
380+
hasher.combine(discovery.directoryStamps.count)
381+
hasher.combine(discovery.fileStamps.count)
382+
hasher.combine(discovery.validationDirectoryIndex)
383+
hasher.combine(discovery.isComplete)
384+
if discovery.directoryPaths.indices.contains(discovery.nextDirectoryIndex) {
385+
hasher.combine(discovery.directoryPaths[discovery.nextDirectoryIndex])
386+
}
387+
if discovery.filePaths.indices.contains(discovery.nextFileIndex) {
388+
hasher.combine(discovery.filePaths[discovery.nextFileIndex])
389+
}
390+
hasher.combine(discovery.headScan?.path)
391+
hasher.combine(discovery.headScan?.offset)
392+
hasher.combine(discovery.headScan?.resumeState?.offset)
393+
for (sessionID, path) in discovery.filePathBySessionId.sorted(by: { $0.key < $1.key }) {
394+
hasher.combine(sessionID)
395+
hasher.combine(path)
396+
}
397+
for sessionID in discovery.missingSessionIds.sorted() {
398+
hasher.combine(sessionID)
399+
}
400+
for sessionID in discovery.pendingSessionIds.sorted() {
401+
hasher.combine(sessionID)
402+
}
403+
}
404+
405+
private static func combineCodexActiveLookbackProgress(
406+
_ lookback: CostUsageCodexActiveLookbackState?,
407+
into hasher: inout Hasher)
408+
{
409+
hasher.combine(lookback != nil)
410+
guard let lookback else { return }
411+
hasher.combine(lookback.scanSinceKey)
412+
for root in lookback.rootPaths.sorted() {
413+
hasher.combine(root)
414+
}
415+
for (root, day) in lookback.nextDayKeyByRoot.sorted(by: { $0.key < $1.key }) {
416+
hasher.combine(root)
417+
hasher.combine(day)
418+
}
419+
for root in lookback.completedRootPaths.sorted() {
420+
hasher.combine(root)
421+
}
422+
for path in lookback.pendingFilePaths.sorted() {
423+
hasher.combine(path)
424+
}
425+
for root in lookback.legacyRecursivePendingRootPaths.sorted() {
426+
hasher.combine(root)
427+
}
428+
}
429+
335430
private static func codexHistoryCoverageIsEstablished(
336431
options: CostUsageScanner.Options) -> Bool
337432
{

Tests/CodexBarTests/CostUsageFetcherTests.swift

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,119 @@ extension CostUsageFetcherTests {
9797
#expect(covered.historyCoverageIsEstablished)
9898
}
9999

100+
@Test
101+
func `codex catch-up progress includes discovery and active lookback cursors`() async throws {
102+
let env = try CostUsageTestEnvironment()
103+
defer { env.cleanup() }
104+
105+
let options = CostUsageScanner.Options(
106+
codexSessionsRoot: env.codexSessionsRoot,
107+
cacheRoot: env.cacheRoot)
108+
let rootPath = env.codexSessionsRoot.path
109+
let firstPath = env.codexSessionsRoot.appendingPathComponent("first.jsonl").path
110+
let secondPath = env.codexSessionsRoot.appendingPathComponent("second.jsonl").path
111+
var cache = CostUsageCache()
112+
cache.roots = CostUsageScanner.codexRootsFingerprint(options: options)
113+
cache.codexScanCatchUpPending = true
114+
cache.codexSessionDiscovery = CostUsageCodexSessionDiscovery(
115+
roots: [rootPath],
116+
generation: "generation",
117+
directoryStamps: [:],
118+
directoryPaths: [rootPath],
119+
nextDirectoryIndex: 0,
120+
filePaths: [firstPath, secondPath],
121+
nextFileIndex: 0,
122+
fileStamps: [:],
123+
headScan: .init(path: firstPath, offset: 32, resumeState: nil),
124+
filePathBySessionId: [:],
125+
missingSessionIds: [],
126+
pendingSessionIds: ["pending-session"],
127+
validationDirectoryIndex: 0,
128+
isComplete: false)
129+
cache.codexActiveLookbackState = CostUsageCodexActiveLookbackState(
130+
scanSinceKey: "2026-04-01",
131+
rootPaths: [rootPath],
132+
nextDayKeyByRoot: [rootPath: "2026-04-02"],
133+
pendingFilePaths: [firstPath])
134+
CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache)
135+
136+
let fetcher = CostUsageFetcher(scannerOptions: options)
137+
let baseline = await fetcher.codexScanCatchUpStatus()
138+
let unchanged = await fetcher.codexScanCatchUpStatus()
139+
#expect(unchanged.progressKey == baseline.progressKey)
140+
141+
var discovery = try #require(cache.codexSessionDiscovery)
142+
discovery.headScan = .init(path: firstPath, offset: 64, resumeState: nil)
143+
cache.codexSessionDiscovery = discovery
144+
CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache)
145+
let advancedHead = await fetcher.codexScanCatchUpStatus()
146+
#expect(advancedHead.progressKey != baseline.progressKey)
147+
148+
discovery.nextFileIndex = 1
149+
discovery.headScan = nil
150+
cache.codexSessionDiscovery = discovery
151+
CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache)
152+
let advancedFile = await fetcher.codexScanCatchUpStatus()
153+
#expect(advancedFile.progressKey != advancedHead.progressKey)
154+
155+
var lookback = try #require(cache.codexActiveLookbackState)
156+
lookback.nextDayKeyByRoot[rootPath] = "2026-04-03"
157+
cache.codexActiveLookbackState = lookback
158+
CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache)
159+
let advancedLookback = await fetcher.codexScanCatchUpStatus()
160+
#expect(advancedLookback.progressKey != advancedFile.progressKey)
161+
}
162+
163+
@Test
164+
func `codex catch-up progress includes buffered retry cursors`() async throws {
165+
let env = try CostUsageTestEnvironment()
166+
defer { env.cleanup() }
167+
168+
let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8)
169+
try Self.writeCodexSessionFile(
170+
homeRoot: env.codexHomeRoot,
171+
env: env,
172+
day: day,
173+
filename: "buffered.jsonl",
174+
tokens: 42)
175+
var options = CostUsageScanner.Options(
176+
codexSessionsRoot: env.codexSessionsRoot,
177+
cacheRoot: env.cacheRoot)
178+
options.refreshMinIntervalSeconds = 0
179+
_ = try await CostUsageFetcher.loadTokenSnapshot(
180+
provider: .codex,
181+
now: day,
182+
historyDays: 1,
183+
includePiSessions: false,
184+
scannerOptions: options)
185+
186+
var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot)
187+
let path = try #require(cache.files.keys.first)
188+
var usage = try #require(cache.files[path])
189+
usage.codexBufferedSubagentLines = [CostUsageScanner.CodexBufferedFastLine(
190+
lineIndex: 1,
191+
ordinal: 1,
192+
endOffset: 64,
193+
line: .taskStarted(turnID: "synthetic-turn"))]
194+
cache.files[path] = usage
195+
cache.codexScanCatchUpPending = true
196+
CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache)
197+
198+
let fetcher = CostUsageFetcher(scannerOptions: options)
199+
let baseline = await fetcher.codexScanCatchUpStatus()
200+
201+
usage.codexBufferedSubagentLines = [CostUsageScanner.CodexBufferedFastLine(
202+
lineIndex: 1,
203+
ordinal: 1,
204+
endOffset: 128,
205+
line: .taskStarted(turnID: "synthetic-turn"))]
206+
cache.files[path] = usage
207+
CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache)
208+
let advancedBuffer = await fetcher.codexScanCatchUpStatus()
209+
210+
#expect(advancedBuffer.progressKey != baseline.progressKey)
211+
}
212+
100213
@Test
101214
func `fetcher refreshes codex cache when legacy roots metadata is missing`() async throws {
102215
let env = try CostUsageTestEnvironment()

Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,19 +1118,19 @@ struct ProviderArchitectureGatekeeperTests {
11181118
reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."),
11191119
SuppressedProviderReference(
11201120
path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift",
1121-
line: 56,
1121+
line: 58,
11221122
anchor: "providerConfigRevision: self.settings.providerConfigRevision(for: .codex),",
11231123
expectedProviderIDs: ["codex"],
11241124
reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."),
11251125
SuppressedProviderReference(
11261126
path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift",
1127-
line: 248,
1127+
line: 251,
11281128
anchor: "&& self.settings.isCostUsageEffectivelyEnabled(for: .codex)",
11291129
expectedProviderIDs: ["codex"],
11301130
reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."),
11311131
SuppressedProviderReference(
11321132
path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift",
1133-
line: 249,
1133+
line: 252,
11341134
anchor: "&& self.isEnabled(.codex)",
11351135
expectedProviderIDs: ["codex"],
11361136
reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."),
@@ -1353,19 +1353,19 @@ struct ProviderArchitectureGatekeeperTests {
13531353
reason: "This provider-specific core branch passes its already-selected identity to a shared helper."),
13541354
SuppressedProviderReference(
13551355
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
1356-
line: 715,
1356+
line: 810,
13571357
anchor: "provider: .codex,",
13581358
expectedProviderIDs: ["codex"],
13591359
reason: "This provider-specific core branch passes its already-selected identity to a shared helper."),
13601360
SuppressedProviderReference(
13611361
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
1362-
line: 790,
1362+
line: 885,
13631363
anchor: "provider: .codex,",
13641364
expectedProviderIDs: ["codex"],
13651365
reason: "This provider-specific core branch passes its already-selected identity to a shared helper."),
13661366
SuppressedProviderReference(
13671367
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
1368-
line: 865,
1368+
line: 960,
13691369
anchor: "provider: .codex,",
13701370
expectedProviderIDs: ["codex"],
13711371
reason: "This provider-specific core branch passes its already-selected identity to a shared helper."),
@@ -2909,7 +2909,7 @@ struct ProviderArchitectureGatekeeperTests {
29092909
reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."),
29102910
AllowedProviderConstruct(
29112911
path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift",
2912-
line: 246,
2912+
line: 248,
29132913
anchor: "&& self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision",
29142914
expectedProviderIDs: ["codex"],
29152915
expectedReferenceCount: 1,
@@ -3379,47 +3379,47 @@ struct ProviderArchitectureGatekeeperTests {
33793379
reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."),
33803380
AllowedProviderConstruct(
33813381
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
3382-
line: 539,
3382+
line: 634,
33833383
anchor: "if provider == .codex {",
33843384
expectedProviderIDs: ["codex"],
33853385
expectedReferenceCount: 1,
33863386
expectedReferenceFingerprint: ["codex@0"],
33873387
reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."),
33883388
AllowedProviderConstruct(
33893389
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
3390-
line: 567,
3390+
line: 662,
33913391
anchor: "provider == .claude || (provider == .codex && options.shouldMergePiUsage)",
33923392
expectedProviderIDs: ["claude", "codex"],
33933393
expectedReferenceCount: 5,
33943394
expectedReferenceFingerprint: ["claude@0", "codex@0", "codex@10", "codex@15", "codex@27"],
33953395
reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."),
33963396
AllowedProviderConstruct(
33973397
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
3398-
line: 614,
3398+
line: 709,
33993399
anchor: "options.provider == .codex || options.provider == .claude",
34003400
expectedProviderIDs: ["claude", "codex"],
34013401
expectedReferenceCount: 2,
34023402
expectedReferenceFingerprint: ["claude@0", "codex@0"],
34033403
reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."),
34043404
AllowedProviderConstruct(
34053405
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
3406-
line: 641,
3406+
line: 736,
34073407
anchor: "guard provider == .codex || provider == .claude else { return nil }",
34083408
expectedProviderIDs: ["claude", "codex", "openai"],
34093409
expectedReferenceCount: 5,
34103410
expectedReferenceFingerprint: ["claude@0", "codex@0", "codex@4", "codex@15", "openai@15"],
34113411
reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."),
34123412
AllowedProviderConstruct(
34133413
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
3414-
line: 1114,
3414+
line: 1209,
34153415
anchor: "if provider == .vertexai {",
34163416
expectedProviderIDs: ["claude", "vertexai"],
34173417
expectedReferenceCount: 2,
34183418
expectedReferenceFingerprint: ["vertexai@0", "claude@2"],
34193419
reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."),
34203420
AllowedProviderConstruct(
34213421
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
3422-
line: 1365,
3422+
line: 1460,
34233423
anchor: "if provider == .cursor {",
34243424
expectedProviderIDs: ["cursor"],
34253425
expectedReferenceCount: 1,

0 commit comments

Comments
 (0)