Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
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 = "3c03b1d5045174e2"
}
113 changes: 105 additions & 8 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,10 @@ enum CostUsageCacheIO {
cache: CostUsageCache,
cacheRoot: URL? = nil,
producerKey: String? = nil,
calendar: Calendar = .current)
calendar: Calendar = .current,
requestedScanWindow: (sinceKey: String, untilKey: String)? = nil,
maxCacheBytes: Int = CostUsageCacheIO.maxCacheFileBytes,
maxCacheEntries: Int = CostUsageCacheIO.maxCacheFileEntries)
{
let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot)
let dir = url.deletingLastPathComponent()
Expand All @@ -123,10 +144,86 @@ enum CostUsageCacheIO {
cache.producerKey = producerKey ?? self.currentProducerKey(provider: provider)
cache.timeZoneIdentifier = calendar.timeZone.identifier

if provider == .codex {
Self.pruneCodexCacheForBudget(
&cache,
requestedScanWindow: requestedScanWindow,
maxCacheBytes: maxCacheBytes,
maxCacheEntries: maxCacheEntries,
previousArtifactBytes: Self.fileSize(at: url))
}

let 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. Entries that are still resuming or that in-window forks depend
/// on are preserved so bounded scans and fork baselines keep making progress. 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,
requestedScanWindow: (sinceKey: String, untilKey: String)?,
maxCacheBytes: Int,
maxCacheEntries: Int,
previousArtifactBytes: Int64?)
{
// Prune against the active requested scan window (what the current report reads),
// not the historically widened retained union persisted in the cache.
let sinceKey = requestedScanWindow?.sinceKey ?? cache.scanSinceKey
let untilKey = requestedScanWindow?.untilKey ?? cache.scanUntilKey
guard let sinceKey, let untilKey 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 neededParentSessionIDs = Set(cache.files.values.compactMap(\.forkedFromId))

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 Exempt parents only for children that survive pruning

The revised dependency calculation includes forkedFromId from every cached child, including out-of-window children removed by this same pass. In a fork-heavy stale corpus, pruning can therefore delete the children while retaining tens of thousands of now-unreferenced parents, leaving the resulting artifact above both budgets; because pruned is then true, the candidate-size fallback at line 158 is skipped, and an artifact above the 1 GiB load limit can be written and discarded on the next refresh. Build the dependency set from in-window/resumable children that will actually remain, or recompute it after candidate removal.

Useful? React with 👍 / 👎.

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

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 against the requested window instead of the retained union

On normal rolling-window refreshes, CostUsageScanner.swift:4646-4655 keeps scanSinceKey at the minimum historical start and scanUntilKey at the maximum end. Testing entries against those retained bounds means a session that was once requested remains “in window” forever, even after it falls outside the current report, so a long-running installation still accumulates entries without limit. Pass the actual requested range into persistence pruning and adjust the cache coverage bounds when older entries are discarded.

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.

P2 Badge Retain zero-day files from the active scan window

When the cache is over budget, a completed session in the currently requested partition that produces no usage rows—such as a suppressed subagent or idless copied prefix—fails touchesCodexScanWindow because days is empty and is therefore removed. The next refresh rediscovers and fully parses that same file; with enough zero-day files, every bounded pass can spend its budget reparsing them and repeatedly defer usage-bearing files. Pass the active scan paths into pruning or otherwise distinguish active zero-day entries from stale out-of-window entries.

Useful? React with 👍 / 👎.

if usage.codexScanComplete == false { return false }
if usage.codexJSONLResumeState != nil { return false }
if usage.hasBufferedCodexForkRetryLines { return false }
if let sessionId = usage.sessionId, neededParentSessionIDs.contains(sessionId) {
return false
}
return true
}
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 👍 / 👎.

}
if !outOfWindowKeys.isEmpty, requestedScanWindow != nil {
// Entries outside the requested window are gone; narrow persisted coverage so a
// later refresh does not treat them as in-window again.
cache.scanSinceKey = requestedScanWindow?.sinceKey ?? cache.scanSinceKey
cache.scanUntilKey = requestedScanWindow?.untilKey ?? cache.scanUntilKey
}

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
}
}

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
Original file line number Diff line number Diff line change
Expand Up @@ -4462,7 +4462,8 @@ enum CostUsageScanner {
provider: .codex,
cache: cache,
cacheRoot: options.cacheRoot,
calendar: range.calendar)
calendar: range.calendar,
requestedScanWindow: (sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey))
}

// swiftlint:disable:next function_body_length
Expand Down
Loading