Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
- Hide untouched Antigravity model families in the `codexbar serve` web dashboard, matching the menu and widgets (#3061). Thanks @urda!
- Documented the AI Usage Limits Stream Deck plugin in the README integrations list (#3066). Thanks @lenadweb!
- OpenCode Go: use the public authenticated usage API when `OPENCODE_API_KEY` is configured, overlaying authoritative rolling/weekly/monthly windows on local history with cookie fallback (#2879, #3065). Thanks @akshayprabhu200!
### Fixed
- Fixed Codex cost catch-up getting stuck when recently touched session files contain only out-of-window usage (#3071).

## 0.54.0 — 2026-08-18

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand.

enum CodexParserHash {
static let value = "6293f505c4cbbce8"
static let value = "2d17f4981b78d07f"
}
50 changes: 40 additions & 10 deletions Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3078,6 +3078,7 @@ enum CostUsageScanner {
scheduledFiles: [URL],
pendingPaths: Set<String>,
attemptedPaths: Set<String>,
processedPaths: Set<String>,
cache: CostUsageCache) -> Set<String>
{
Set(scheduledFiles.compactMap { fileURL -> String? in
Expand All @@ -3088,6 +3089,9 @@ enum CostUsageScanner {
if metadata.fileId == nil, !FileManager.default.fileExists(atPath: fileURL.path) {
return resolvedPath
}
if processedPaths.contains(fileURL.path), cache.files[fileURL.path] == nil {
return resolvedPath
}
guard let usage = cache.files[fileURL.path],
usage.codexScanComplete == true,
!usage.hasBufferedCodexForkRetryLines,
Expand Down Expand Up @@ -4908,11 +4912,16 @@ enum CostUsageScanner {
return nil
}

private enum CodexFileScanOutcome {
case processed
case deferred
}

private static func scanCodexFile(
fileURL: URL,
context: CodexFileScanContext,
cache: inout CostUsageCache,
state: inout CodexScanState) throws
state: inout CodexScanState) throws -> CodexFileScanOutcome
{
try context.checkCancellation?()
let metadata = Self.codexFileMetadata(fileURL: fileURL)
Expand All @@ -4923,7 +4932,7 @@ enum CostUsageScanner {
}
if let fileId = metadata.fileId, state.seenFileIds.contains(fileId) {
Self.dropCachedCodexFile(path: metadata.path, cached: cache.files[metadata.path], cache: &cache)
return
return .processed
}
Self.reconcileCodexCachePathAliases(
metadata: metadata,
Expand All @@ -4934,7 +4943,7 @@ enum CostUsageScanner {

let input = CodexFileScanInput(fileURL: fileURL, metadata: metadata, cached: cached)
if try Self.keepCachedCodexFileIfFresh(input: input, context: context, cache: &cache, state: &state) {
return
return .processed
}

let pendingWorkBytes = Self.pendingCodexScanWorkBytes(metadata: metadata, cached: cached)
Expand All @@ -4953,7 +4962,7 @@ enum CostUsageScanner {
"limit": "\(budget.maxBytesPerRefresh)",
])
// Preserve stale cache so later refreshes can resume catch-up.
return
return .deferred
}
} else {
allowedWorkBytes = pendingWorkBytes
Expand All @@ -4967,7 +4976,7 @@ enum CostUsageScanner {
maxBytesToRead: allowedWorkBytes)
{
context.scanBudget?.consume(workBytes: allowedWorkBytes)
return
return .processed
}
let fullRescanWorkBytes = max(0, metadata.size)
let fullRescanAllowedBytes: Int64
Expand All @@ -4981,7 +4990,7 @@ enum CostUsageScanner {
case .deferBudget:
// No work was consumed by the rejected incremental path, so this is only
// reachable when the refresh budget has no allowance for the full rescan.
return
return .deferred
}
} else {
fullRescanAllowedBytes = fullRescanWorkBytes
Expand All @@ -4994,6 +5003,7 @@ enum CostUsageScanner {
state: &state,
maxBytesToRead: fullRescanAllowedBytes)
context.scanBudget?.consume(workBytes: fullRescanAllowedBytes)
return .processed

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep partially scanned cacheless files queued

When a time- or byte-bounded rescan reads only a prefix of a file whose session already contributed through another rollout, rescanCodexFile can remove the cache entry because the prefix contains no unique rows, even though parsedBytes has not reached the file size. Returning .processed unconditionally here makes the new cacheless-path logic drain that file and exclude it from the exact inventory, so an old touched/resumed session can permanently lose valid in-window usage in its unread suffix. The outcome must remain deferred unless the rescan actually reached EOF or otherwise proved completion.

Useful? React with 👍 / 👎.

}

static func pendingCodexScanWorkBytes(metadata: CodexFileMetadata, cached: CostUsageFileUsage?) -> Int64 {
Expand Down Expand Up @@ -5486,6 +5496,11 @@ enum CostUsageScanner {
filePathsInScan.formUnion(scanResult.scannedPaths.map {
Self.codexPathKey(URL(fileURLWithPath: $0))
})
let processedWithoutCachePathKeys = Set(scanResult.processedPaths.compactMap { path -> String? in
guard cache.files[path] == nil else { return nil }
return Self.codexPathKey(URL(fileURLWithPath: path))
})
filePathsInScan.subtract(processedWithoutCachePathKeys)
let pendingLookbackPathCount = shouldBoundCatchUp
? boundedQueuePathCount
: activeLookbackState.pendingFilePaths.count
Expand All @@ -5494,6 +5509,7 @@ enum CostUsageScanner {
scheduledFiles: filesScheduledForRefresh,
pendingPaths: pendingLookbackPaths,
attemptedPaths: scanResult.attemptedPaths,
processedPaths: scanResult.processedPaths,
cache: cache)
cache.codexActiveLookbackState = Self.finalizedCodexActiveLookbackState(
activeLookbackState,
Expand Down Expand Up @@ -5799,6 +5815,7 @@ enum CostUsageScanner {
private struct CodexFileScanResult {
let scannedPaths: Set<String>
let attemptedPaths: Set<String>
let processedPaths: Set<String>
}

private static func scanCodexFiles(
Expand All @@ -5812,17 +5829,21 @@ enum CostUsageScanner {
var visitedPaths = Set(files.map(\.standardizedFileURL.path))
var scannedPaths = Set(files.map(\.path))
var attemptedPaths: Set<String> = []
var processedPaths: Set<String> = []
for fileURL in files {
if context.scanBudget?.shouldStopBeforeNextFile() == true {
break
}
context.workRecorder?.recordCodexFileScanAttempt(path: Self.codexPathKey(fileURL))
attemptedPaths.insert(fileURL.path)
try Self.scanCodexFile(
let outcome = try Self.scanCodexFile(
fileURL: fileURL,
context: context,
cache: &cache,
state: &scanState)
if case .processed = outcome {
processedPaths.insert(fileURL.path)
}
let usage = cache.files[fileURL.path]
inheritedResolver.updateCachedUsage(fileURL: fileURL, usage: usage)
if Self.shouldRetryBufferedCodexFork(usage) {
Expand All @@ -5846,11 +5867,14 @@ enum CostUsageScanner {
context.workRecorder?.recordCodexFileScanAttempt(path: Self.codexPathKey(fileURL))
scannedPaths.insert(fileURL.path)
attemptedPaths.insert(fileURL.path)
try Self.scanCodexFile(
let outcome = try Self.scanCodexFile(
fileURL: fileURL,
context: context,
cache: &cache,
state: &dependencyState)
if case .processed = outcome {
processedPaths.insert(fileURL.path)
}
let usage = cache.files[fileURL.path]
inheritedResolver.updateCachedUsage(fileURL: fileURL, usage: usage)
if Self.shouldRetryBufferedCodexFork(usage) {
Expand All @@ -5866,16 +5890,22 @@ enum CostUsageScanner {
var retriedPaths: Set<String> = []
for fileURL in bufferedForkRetries where retriedPaths.insert(fileURL.path).inserted {
guard Self.shouldRetryBufferedCodexFork(cache.files[fileURL.path]) else { continue }
try Self.scanCodexFile(
let outcome = try Self.scanCodexFile(
fileURL: fileURL,
context: context,
cache: &cache,
state: &retryState)
if case .processed = outcome {
processedPaths.insert(fileURL.path)
}
inheritedResolver.updateCachedUsage(
fileURL: fileURL,
usage: cache.files[fileURL.path])
}
return CodexFileScanResult(scannedPaths: scannedPaths, attemptedPaths: attemptedPaths)
return CodexFileScanResult(
scannedPaths: scannedPaths,
attemptedPaths: attemptedPaths,
processedPaths: processedPaths)
}

private static func shouldRetryBufferedCodexFork(_ usage: CostUsageFileUsage?) -> Bool {
Expand Down
157 changes: 157 additions & 0 deletions Tests/CodexBarTests/CostUsageCatchUpCompletionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,163 @@ import Testing

@Suite(.serialized)
struct CostUsageCatchUpCompletionTests {
@Test
func `touched old Codex file drains catch-up and permits exact proof`() throws {
let env = try CostUsageTestEnvironment()
defer { env.cleanup() }
let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10)
let oldDay = try #require(Calendar.current.date(byAdding: .day, value: -10, to: day))
let oldISO = env.isoString(for: oldDay)
let currentISO = env.isoString(for: day)
let oldURL = try env.writeCodexSessionFile(
day: oldDay,
filename: "rollout-old.jsonl",
contents: [
#"{"type":"session_meta","timestamp":"\#(oldISO)","payload":{"session_id":"resumed-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":50,"cached_input_tokens":10,"output_tokens":5},"#
+ #""model":"openai/gpt-5.2-codex"}}}"#,
].joined(separator: "\n") + "\n")
let currentURL = try env.writeCodexSessionFile(
day: day,
filename: "rollout-current.jsonl",
contents: [
#"{"type":"session_meta","timestamp":"\#(currentISO)","payload":{"session_id":"resumed-session"}}"#,
#"{"type":"turn_context","timestamp":"\#(currentISO)","payload":{"model":"openai/gpt-5.2-codex"}}"#,
#"{"type":"event_msg","timestamp":"\#(currentISO)","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: day.addingTimeInterval(60)],
ofItemAtPath: oldURL.path)
try FileManager.default.setAttributes(
[.modificationDate: day.addingTimeInterval(120)],
ofItemAtPath: currentURL.path)

var options = CostUsageScanner.Options(
codexSessionsRoot: env.codexSessionsRoot,
claudeProjectsRoots: nil,
cacheRoot: env.cacheRoot,
codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"))
options.refreshMinIntervalSeconds = 0
let proofRecorder = CostUsageScanner.CodexScanWorkRecorder()
options.codexScanWorkRecorderForTesting = proofRecorder

_ = CostUsageScanner.loadDailyReport(
provider: .codex,
since: day,
until: day,
now: day.addingTimeInterval(180),
options: options)

let completedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot)
#expect(completedCache.files[oldURL.path] == nil)
#expect(completedCache.files[currentURL.path] != nil)
#expect(proofRecorder.snapshot().codexProgressAccountingVisits == 1)
#expect(completedCache.codexActiveLookbackState == nil)
#expect(completedCache.codexScanInventoryPaths == [currentURL.path])
#expect(completedCache.codexScanCatchUpPending == false)

let roots = CostUsageScanner.codexSessionsRoots(options: options)
.map { $0.resolvingSymlinksInPath().standardizedFileURL.path }
.sorted()
var damagedCache = completedCache
damagedCache.codexActiveLookbackState = try CostUsageCodexActiveLookbackState(
scanSinceKey: #require(damagedCache.scanSinceKey),
rootPaths: roots,
completedRootPaths: roots,
pendingFilePaths: [oldURL.path],
completedCurrentWindowRootPaths: roots,
completedCurrentWindowFlatRootPaths: roots)
damagedCache.codexScanInventoryPaths = nil
damagedCache.codexScanCatchUpPending = true
CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: damagedCache)

options.codexScanWorkRecorderForTesting = nil
_ = CostUsageScanner.loadDailyReport(
provider: .codex,
since: day,
until: day,
now: day.addingTimeInterval(181),
options: options)
let repairedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot)
#expect(repairedCache.codexActiveLookbackState == nil)
#expect(repairedCache.codexScanCatchUpPending == false)
}

@Test
func `unprocessed pending Codex file remains queued`() throws {
let env = try CostUsageTestEnvironment()
defer { env.cleanup() }
let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10)
let iso = env.isoString(for: day)
let cachedURL = try env.writeCodexSessionFile(
day: day,
filename: "a-cached.jsonl",
contents: [
#"{"type":"session_meta","timestamp":"\#(iso)","payload":{"session_id":"cached"}}"#,
#"{"type":"turn_context","timestamp":"\#(iso)","payload":{"model":"openai/gpt-5.2-codex"}}"#,
#"{"type":"event_msg","timestamp":"\#(iso)","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")
var options = CostUsageScanner.Options(
codexSessionsRoot: env.codexSessionsRoot,
claudeProjectsRoots: nil,
cacheRoot: env.cacheRoot,
codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"))
options.refreshMinIntervalSeconds = 0
options.preferNewestCodexSessionsFirst = false
_ = CostUsageScanner.loadDailyReport(
provider: .codex,
since: day,
until: day,
now: day,
options: options)

let pendingURL = try env.writeCodexSessionFile(
day: day,
filename: "b-pending.jsonl",
contents: [
#"{"type":"session_meta","timestamp":"\#(iso)","payload":{"session_id":"pending"}}"#,
#"{"type":"turn_context","timestamp":"\#(iso)","payload":{"model":"openai/gpt-5.2-codex"}}"#,
].joined(separator: "\n") + "\n")
let cachedHandle = try FileHandle(forWritingTo: cachedURL)
try cachedHandle.seekToEnd()
try cachedHandle.write(contentsOf: Data("\n".utf8))
try cachedHandle.close()

var pendingCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot)
let roots = CostUsageScanner.codexSessionsRoots(options: options)
.map { $0.resolvingSymlinksInPath().standardizedFileURL.path }
.sorted()
pendingCache.codexActiveLookbackState = try CostUsageCodexActiveLookbackState(
scanSinceKey: #require(pendingCache.scanSinceKey),
rootPaths: roots,
completedRootPaths: roots,
pendingFilePaths: [cachedURL.path, pendingURL.path],
completedCurrentWindowRootPaths: roots,
completedCurrentWindowFlatRootPaths: roots)
pendingCache.codexScanCatchUpPending = true
CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: pendingCache)

options.maxCodexScanBytesPerRefresh = 1
options.maxCodexScanDurationPerRefresh = 60
_ = CostUsageScanner.loadDailyReport(
provider: .codex,
since: day,
until: day,
now: day.addingTimeInterval(1),
options: options)

let deferredCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot)
#expect(deferredCache.files[pendingURL.path] == nil)
#expect(deferredCache.codexActiveLookbackState?.pendingFilePaths.contains(pendingURL.path) == true)
#expect(deferredCache.codexScanCatchUpPending == true)
}

@Test
func `device identity restoration queues validation beyond the bounded slice`() async throws {
let env = try CostUsageTestEnvironment()
Expand Down