Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
dd2fa7d
Bound Codex cost cache persistence size
Yuxin-Qiao Aug 4, 2026
2fb4d4c
Preserve resumable and fork-dependent cache entries
Yuxin-Qiao Aug 4, 2026
88aa138
Prune cache against requested window
Yuxin-Qiao Aug 4, 2026
7883c72
Prune candidate artifacts over byte budget
Yuxin-Qiao Aug 4, 2026
2ad965d
Scope cache guards and preserve active sessions
Yuxin-Qiao Aug 4, 2026
8cd629e
Keep cache loadable under byte budget
Yuxin-Qiao Aug 4, 2026
e1894f2
Mark trimmed caches for catch-up and prune discovery
Yuxin-Qiao Aug 4, 2026
1ed0514
Bound sole oversized entries and reset discovery cursors
Yuxin-Qiao Aug 4, 2026
98a640d
Mark stripped entries incomplete
Yuxin-Qiao Aug 4, 2026
0c26743
Preserve full report and live fork protection during trim
Yuxin-Qiao Aug 4, 2026
f8acd53
Compact protected fork parents over budget
Yuxin-Qiao Aug 4, 2026
9aac621
Enforce byte cap after underestimated encodes
Yuxin-Qiao Aug 4, 2026
bc4ad2d
Exclude lineage-only parents from fork protection
Yuxin-Qiao Aug 4, 2026
2f80534
Re-encode after forced prune and bound lookback state
Yuxin-Qiao Aug 4, 2026
530eb6d
Use report window for catch-up and exclude lineage parents
Yuxin-Qiao Aug 4, 2026
13fb17e
Compact parents required by kept trim survivors
Yuxin-Qiao Aug 4, 2026
c97ac24
Prune orphaned discovery mappings under budget
Yuxin-Qiao Aug 4, 2026
71e2743
Preserve lookback work and align report bounds
Yuxin-Qiao Aug 4, 2026
1f104be
fix: lower cost-cache load cap and never persist a refusable artifact
steipete Aug 5, 2026
af49fd9
Merge branch 'main' into codex/bound-cost-cache-2637
steipete Aug 5, 2026
3970a96
Preserve recursive lookback roots and share ID capacity
Yuxin-Qiao Aug 5, 2026
a0c7b2d
Merge origin/main into codex/bound-cost-cache-2637
steipete Aug 5, 2026
959b466
Merge PR head (lookback-root preservation) with the main merge
steipete Aug 5, 2026
9fdc1a7
Keep legacy lookback roots in the scan queue
Yuxin-Qiao Aug 5, 2026
c6004b3
Preserve complete report across repeated trims
Yuxin-Qiao Aug 5, 2026
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
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 = "843ca061c36bbea1"
static let value = "8456c68643ab5e5e"
}
134 changes: 125 additions & 9 deletions Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import Foundation

enum CostUsageCacheIO {
/// Persistence budgets for the Codex cost cache. The artifact holds one entry per
/// scanned session file plus per-file detail (rows, turn IDs, token snapshots) and is
/// decoded and encoded as a single JSON document on every scan, so an unbounded corpus
/// can otherwise grow it to multiple gigabytes. These bounds mirror the scan-side byte
/// budgets; in-window data is never dropped, so reports stay complete.
static let maxCacheFileBytes: Int = 256 * 1024 * 1024
static let maxCacheFileEntries: Int = 25000
/// Artifacts above this size are refused at load time and rebuilt by the bounded
/// scanner instead of being decoded in one shot.
static let maxCacheLoadBytes: Int = 1024 * 1024 * 1024

/// Producer keys from older parser hashes whose caches are still valid under the current
/// delta semantics. Cleared for #2037: interleave containment changed how cumulative
/// totals are counted, so every earlier cache must be rebuilt.
Expand Down Expand Up @@ -36,7 +47,8 @@ enum CostUsageCacheIO {
provider: UsageProvider,
cacheRoot: URL? = nil,
producerKey: String? = nil,
calendar: Calendar? = nil) -> CostUsageCache
calendar: Calendar? = nil,
maxCacheBytes: Int = CostUsageCacheIO.maxCacheLoadBytes) -> CostUsageCache
{
let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot)
let expectedProducerKey = producerKey ?? self.currentProducerKey(provider: provider)
Expand All @@ -46,7 +58,8 @@ enum CostUsageCacheIO {
if let decoded = self.loadCache(
at: url,
expectedProducerKey: expectedProducerKey,
compatibleProducerKeys: compatibleProducerKeys)
compatibleProducerKeys: compatibleProducerKeys,
maxBytes: maxCacheBytes)
{
if let calendar, decoded.timeZoneIdentifier != calendar.timeZone.identifier {
return CostUsageCache()
Expand All @@ -59,10 +72,11 @@ enum CostUsageCacheIO {
static func loadCodexForMigration(
cacheRoot: URL? = nil,
producerKey: String? = nil,
calendar: Calendar? = nil) -> CostUsageCodexCacheLoadResult
calendar: Calendar? = nil,
maxCacheBytes: Int = CostUsageCacheIO.maxCacheLoadBytes) -> CostUsageCodexCacheLoadResult
{
let url = self.cacheFileURL(provider: .codex, cacheRoot: cacheRoot)
guard let decoded = self.decodeCache(at: url) else {
guard let decoded = self.decodeCache(at: url, maxBytes: maxCacheBytes) else {
return CostUsageCodexCacheLoadResult(cache: CostUsageCache(), incompatibleCache: nil)
}
if let calendar, decoded.timeZoneIdentifier != calendar.timeZone.identifier {
Expand All @@ -89,9 +103,10 @@ enum CostUsageCacheIO {
private static func loadCache(
at url: URL,
expectedProducerKey: String?,
compatibleProducerKeys: Set<String>) -> CostUsageCache?
compatibleProducerKeys: Set<String>,
maxBytes: Int) -> CostUsageCache?
{
guard let decoded = self.decodeCache(at: url) else { return nil }
guard let decoded = self.decodeCache(at: url, maxBytes: maxBytes) else { return nil }
if let expectedProducerKey {
guard decoded.producerKey == expectedProducerKey
|| decoded.producerKey.map(compatibleProducerKeys.contains) == true
Expand All @@ -100,7 +115,10 @@ enum CostUsageCacheIO {
return decoded
}

private static func decodeCache(at url: URL) -> CostUsageCache? {
private static func decodeCache(at url: URL, maxBytes: Int) -> CostUsageCache? {
let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?
.int64Value ?? 0
guard fileSize <= maxBytes else { return nil }
Comment on lines +129 to +132

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 Apply the hard load cap only to bounded providers

The size guard is shared by every CostUsageCacheIO.load call, but the inspected Claude path in loadClaudeDaily uses an unbounded recursive scanner and save prunes only when provider == .codex. Once a Claude or Vertex cache exceeds 1 GiB, each load now returns an empty cache, the provider performs a full corpus rebuild, and then writes another oversized artifact that will be rejected again on the next refresh. Either scope this refusal to Codex or add equivalent bounded persistence/rebuild behavior for the other providers.

Useful? React with 👍 / 👎.

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 Avoid rejecting caches that persistence cannot shrink

When active-window or resumable entries alone encode above 1 GiB, save deliberately cannot prune those entries and still writes the oversized artifact, but this guard discards it on the next load. The bounded scanner then starts from an empty cache, eventually recreates the same oversized active cache, and repeats the rebuild cycle, leaving large-corpus users with recurring catch-up work and incomplete/stale reports. The load policy needs to remain compatible with artifacts that the save policy cannot reduce, or save must enforce a representation that stays below the load cap while preserving resume state.

Useful? React with 👍 / 👎.

guard let data = try? Data(contentsOf: url) else { return nil }
guard let decoded = try? JSONDecoder().decode(CostUsageCache.self, from: data)
else { return nil }
Expand All @@ -113,7 +131,9 @@ enum CostUsageCacheIO {
cache: CostUsageCache,
cacheRoot: URL? = nil,
producerKey: String? = nil,
calendar: Calendar = .current)
calendar: Calendar = .current,
maxCacheBytes: Int = CostUsageCacheIO.maxCacheFileBytes,
maxCacheEntries: Int = CostUsageCacheIO.maxCacheFileEntries)
{
let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot)
let dir = url.deletingLastPathComponent()
Expand All @@ -123,10 +143,106 @@ enum CostUsageCacheIO {
cache.producerKey = producerKey ?? self.currentProducerKey(provider: provider)
cache.timeZoneIdentifier = calendar.timeZone.identifier

let data = (try? JSONEncoder().encode(cache)) ?? Data()
if provider == .codex {
Self.pruneCodexCacheForBudget(
&cache,
maxCacheBytes: maxCacheBytes,
maxCacheEntries: maxCacheEntries,
previousArtifactBytes: Self.fileSize(at: url))
}

var data = (try? JSONEncoder().encode(cache)) ?? Data()

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 Prune before materializing an oversized candidate

When the previous artifact is within budget and the cache has at most 25,000 files, but a refresh greatly expands per-file rows or token snapshots, this encoding allocates the entire candidate before the data.count guard can trigger pruning. The default scanner can ingest 512 MiB per refresh, whose JSON representation and encoder working set can be much larger, so an affected process can hit the same multi-gigabyte RSS/OOM condition and never reach the pruning branch. Trigger pruning from a pre-encode estimate or use a bounded/incremental encoding strategy.

Useful? React with 👍 / 👎.

if data.count > maxCacheBytes {
Self.stripCodexTokenDetailForBudget(&cache)
data = (try? JSONEncoder().encode(cache)) ?? Data()
}
try? data.write(to: url, options: [.atomic])
}

/// Bounds the Codex cache artifact when the corpus has outgrown the persistence budget.
/// The all-time accumulation lives in per-file entries whose usage days fall outside the
/// current scan window; the current report never reads those entries, and dropping them
/// (with the same day-aggregate subtraction the scanner uses) keeps the artifact from
/// growing without limit. Priority turn IDs outside the window are only consulted for
/// in-window rows, so they are trimmed to the window as well.
private static func pruneCodexCacheForBudget(
_ cache: inout CostUsageCache,
maxCacheBytes: Int,
maxCacheEntries: Int,
previousArtifactBytes: Int64?)
{
guard let sinceKey = cache.scanSinceKey, let untilKey = cache.scanUntilKey else { return }
let overBudget = cache.files.count > maxCacheEntries
|| (previousArtifactBytes ?? 0) > Int64(maxCacheBytes)

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 Check the new artifact against the byte budget

When the existing cache is below 256 MiB but one refresh adds enough rows or token snapshots to cross that limit, this condition remains false as long as there are at most 25,000 entries because it checks only previousArtifactBytes. A production refresh can admit up to 512 MiB of new session data, so save can write a 256 MiB–1 GiB document that the next load will decode, recreating the memory spike this change is intended to prevent. Base pruning on the candidate artifact being written, or otherwise trigger it before the write when the current cache crosses the byte budget.

Useful? React with 👍 / 👎.

guard overBudget else { return }

let outOfWindowKeys = cache.files.keys.filter { key in
guard let usage = cache.files[key] else { return false }
return !usage.touchesCodexScanWindow(sinceKey: sinceKey, untilKey: untilKey)

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 Preserve resumable out-of-window fork parents

When the cache is over budget, this removes every file with no usage day in the current window, including an incomplete old parent rollout that was discovered while resolving an in-window fork. Production scans read at most 256 MiB per file per refresh and rely on the cached offset to resume; pruning that entry on every save makes a larger parent restart from byte zero indefinitely, so the child baseline never resolves and catch-up cannot complete. Exclude incomplete files and active fork dependencies from this pruning.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 2fb4d4c: pruning now preserves entries that are still resuming (incomplete scans, resume state, in-progress scan ids, buffered fork retries) and out-of-window fork parents referenced by in-window children; the token-snapshot stripping step was removed entirely, so incremental-resume state is never invalidated.

}
for key in outOfWindowKeys {
guard let old = cache.files.removeValue(forKey: key) else { continue }
CostUsageScanner.applyFileDays(cache: &cache, fileDays: old.days, sign: -1)
Comment on lines +279 to +285

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 Prune discovery records for removed sessions

When budget pruning removes stale cache.files entries, it leaves codexSessionDiscovery untouched even though that state also contains per-session/path maps (filePaths, fileStamps, filePathBySessionId) for the same all-time corpus and is encoded with the cache. For large completed discovery states, the saved artifact can therefore remain hundreds of thousands of entries and still exceed the byte/load budgets even after the file usages are removed; also estimatedCodexCacheBytes does not count this state, so the pre-encode guard will not help. Remove the corresponding discovery records or rebuild discovery from the surviving file set when pruning.

Useful? React with 👍 / 👎.

}

let inWindow: (String) -> Bool = { key in
CostUsageScanner.CostUsageDayRange.isInRange(
dayKey: key,
since: sinceKey,
until: untilKey)
}
if let idsByDay = cache.codexPriorityTurnIDsByDay {
let trimmed = idsByDay.filter { inWindow($0.key) }
cache.codexPriorityTurnIDsByDay = trimmed.isEmpty ? nil : trimmed
}
if let turnKeys = cache.codexPriorityTurnKeys {
let trimmed = turnKeys.filter { inWindow($0.key) }
cache.codexPriorityTurnKeys = trimmed.isEmpty ? nil : trimmed
}
}

/// Last-resort trim for in-window corpora that still exceed the byte budget. Strips
/// fork-baseline token snapshots and divergence bookkeeping from files whose newest
/// usage day is outside the most recent week. These fields are optimizations with
/// on-disk re-read fallbacks; day aggregates, totals, rows, and cost data are kept,
/// so reports and pricing remain correct.
private static func stripCodexTokenDetailForBudget(_ cache: inout CostUsageCache) {
guard let untilKey = cache.scanUntilKey,
let cutoffKey = dayKey(untilKey, addingDays: -7)
else { return }
for (path, var usage) in cache.files {
let newestDay = usage.days.keys.max()
guard let newestDay, newestDay < cutoffKey else { continue }
usage.codexTokenSnapshots = nil

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 Force full rereads after stripping token snapshots

For an older cached parent rollout that is later appended, clearing its snapshots does not invalidate parsedBytes, size, or the incremental-resume state. The next scan therefore reads only the appended tail and rebuilds codexTokenSnapshots from that tail, yet marks the resulting index as covering the whole file; a child fork whose cutoff predates the append then receives a missing or incorrect inherited baseline and can overcount replayed tokens. Retain these snapshots or invalidate the cached parse so the next change forces a full reread.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 2fb4d4c: pruning now preserves entries that are still resuming (incomplete scans, resume state, in-progress scan ids, buffered fork retries) and out-of-window fork parents referenced by in-window children; the token-snapshot stripping step was removed entirely, so incremental-resume state is never invalidated.

usage.codexTokenCheckpoints = nil
usage.codexTokenTimestampsMonotonic = nil
usage.codexTokenIndexAnchor = nil
usage.seenRawTotals = nil
usage.hasDivergentTotals = nil
usage.hasInterleavedTotals = nil
usage.lastRawTotalsBaseline = nil
usage.lastRawTotalsWatermark = nil
cache.files[path] = usage
}
}

private static func dayKey(_ key: String, addingDays days: Int) -> String? {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone.current
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
guard let date = formatter.date(from: key),
let shifted = calendar.date(byAdding: .day, value: days, to: date)
else { return nil }
return formatter.string(from: shifted)
}

private static func fileSize(at url: URL) -> Int64? {
(try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?.int64Value
}

static func currentProducerKey(
provider: UsageProvider,
parserHash: String = CodexParserHash.value) -> String?
Expand Down
Loading