Skip to content
Closed
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
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 = "cdef6eb9658a43e2"
static let value = "172b6f1dac9df699"
}
3 changes: 3 additions & 0 deletions Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ enum CostUsageCacheIO {
/// totals are counted, so every earlier cache must be rebuilt.
private static let compatibleCodexProducerKeys: Set<String> = []

/// Parser and attribution changes invalidate Codex caches through CodexParserHash (#1042).
/// Bump this only for an incompatible on-disk schema or cache filename/layout change.
private static func artifactVersion(for provider: UsageProvider) -> Int {
switch provider {
case .codex:
Expand Down Expand Up @@ -141,6 +143,7 @@ struct CostUsageFileUsage: Codable {
var lastCodexTurnID: String?
var sessionId: String?
var forkedFromId: String?
var forkBaselineDependencyKey: String?
var projectPath: String?
var canonicalProjectPath: String?
var codexCostCacheComplete: Bool?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ extension CostUsageScanner {
lastCodexTurnID: String? = nil,
sessionId: String? = nil,
forkedFromId: String? = nil,
forkBaselineDependencyKey: String? = nil,
projectPath: String? = nil,
canonicalProjectPath: String? = nil,
codexCostCacheComplete: Bool? = true,
Expand Down Expand Up @@ -314,6 +315,7 @@ extension CostUsageScanner {
lastCodexTurnID: lastCodexTurnID,
sessionId: sessionId,
forkedFromId: forkedFromId,
forkBaselineDependencyKey: forkBaselineDependencyKey,
projectPath: projectPath,
canonicalProjectPath: canonicalProjectPath,
codexCostCacheComplete: codexCostCacheComplete,
Expand Down Expand Up @@ -705,6 +707,7 @@ extension CostUsageScanner {
lastCodexTurnID: usage.lastCodexTurnID,
sessionId: usage.sessionId,
forkedFromId: usage.forkedFromId,
forkBaselineDependencyKey: usage.forkBaselineDependencyKey,
projectPath: usage.projectPath,
canonicalProjectPath: usage.canonicalProjectPath,
codexCostNanos: Self.mergeCostMaps(
Expand Down Expand Up @@ -883,7 +886,7 @@ extension CostUsageScanner {
input: CodexFileScanInput,
context: CodexFileScanContext,
cache: inout CostUsageCache,
state: inout CodexScanState) -> Bool
state: inout CodexScanState) throws -> Bool
{
guard let cached = input.cached else { return false }
let needsSessionId = cached.sessionId == nil
Expand All @@ -900,8 +903,14 @@ extension CostUsageScanner {
if Self.cachedCodexRowsNeedIdentityRescan(cached) {
return false
}
if sessionAlreadyContributed, cachedRows.isEmpty {
return false
}
if let parentSessionId = cached.forkedFromId {
let currentDependencyKey = try context.resources.inheritedResolver.dependencyKey(for: parentSessionId)
guard cached.forkBaselineDependencyKey == currentDependencyKey else { return false }
}
if sessionAlreadyContributed {
guard !cachedRows.isEmpty else { return false }
let uniqueRows = Self.uniqueCodexRows(
rows: cachedRows,
sessionId: cached.sessionId,
Expand Down Expand Up @@ -1119,6 +1128,11 @@ extension CostUsageScanner {
range: context.range,
inheritedTotalsResolver: context.resources.inheritedResolver.inheritedTotals(for:atOrBefore:),
checkCancellation: context.checkCancellation)
let forkBaselineDependencyKey: String? = if let parentSessionId = parsed.forkedFromId {
try context.resources.inheritedResolver.dependencyKey(for: parentSessionId)
} else {
nil
}
let sessionId = parsed.sessionId ?? input.cached?.sessionId
let projectPath = parsed.projectPath ?? input.cached?.projectPath
let canonicalProjectPath = parsed.projectPath.map {
Expand Down Expand Up @@ -1163,6 +1177,7 @@ extension CostUsageScanner {
lastCodexTurnID: parsed.lastCodexTurnID,
sessionId: sessionId,
forkedFromId: parsed.forkedFromId,
forkBaselineDependencyKey: forkBaselineDependencyKey,
projectPath: projectPath,
canonicalProjectPath: canonicalProjectPath,
codexCostNanos: Self.mergeCostMaps(
Expand Down
31 changes: 23 additions & 8 deletions Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,21 @@ enum CostUsageScanner {
return .resolved(inherited)
}

func dependencyKey(for sessionId: String) throws -> String {
guard let fileURL = try self.fileIndex.fileURL(for: sessionId) else {
return "missing:\(sessionId)"
}
let metadata = CostUsageScanner.codexFileMetadata(fileURL: fileURL)
return [
"file",
sessionId,
fileURL.standardizedFileURL.path,
metadata.fileId ?? "unknown",
String(metadata.mtimeUnixMs),
String(metadata.size),
].joined(separator: "|")
}

private func snapshots(for sessionId: String) throws -> [CodexTimestampedTotals]? {
if let cached = self.snapshotsBySessionId[sessionId] {
return cached
Expand Down Expand Up @@ -1351,7 +1366,7 @@ enum CostUsageScanner {
payloadRange: Range<Int>?) -> String?
{
if let payloadRange {
for key in [self.codexJSONFieldSessionId, self.codexJSONFieldSessionIdCamel, self.codexJSONFieldId] {
for key in [self.codexJSONFieldId, self.codexJSONFieldSessionId, self.codexJSONFieldSessionIdCamel] {
if let value = extractJSONByteStringField(key, from: bytes, in: payloadRange, atDepth: 1),
!value.isEmpty
{
Expand Down Expand Up @@ -1601,9 +1616,9 @@ enum CostUsageScanner {
guard obj["type"] as? String == "session_meta" else { return nil }
let payload = obj["payload"] as? [String: Any]
return CodexSessionMetadata(
sessionId: payload?["session_id"] as? String
sessionId: payload?["id"] as? String
?? payload?["session_id"] as? String
?? payload?["sessionId"] as? String
?? payload?["id"] as? String
?? obj["session_id"] as? String
?? obj["sessionId"] as? String
?? obj["id"] as? String,
Expand Down Expand Up @@ -1739,9 +1754,9 @@ enum CostUsageScanner {
if obj["type"] as? String == "session_meta" {
let payload = obj["payload"] as? [String: Any]
if sessionId == nil {
sessionId = payload?["session_id"] as? String
sessionId = payload?["id"] as? String
?? payload?["session_id"] as? String
?? payload?["sessionId"] as? String
?? payload?["id"] as? String
?? obj["session_id"] as? String
?? obj["sessionId"] as? String
?? obj["id"] as? String
Expand Down Expand Up @@ -2210,9 +2225,9 @@ enum CostUsageScanner {
if type == "session_meta" {
let payload = obj["payload"] as? [String: Any]
if sessionId == nil {
sessionId = payload?["session_id"] as? String
sessionId = payload?["id"] as? String
?? payload?["session_id"] as? String
?? payload?["sessionId"] as? String
?? payload?["id"] as? String
?? obj["session_id"] as? String
?? obj["sessionId"] as? String
?? obj["id"] as? String
Expand Down Expand Up @@ -2355,7 +2370,7 @@ enum CostUsageScanner {
let cached = cache.files[metadata.path]

let input = CodexFileScanInput(fileURL: fileURL, metadata: metadata, cached: cached)
if Self.keepCachedCodexFileIfFresh(input: input, context: context, cache: &cache, state: &state) {
if try Self.keepCachedCodexFileIfFresh(input: input, context: context, cache: &cache, state: &state) {
return
}
if try Self.appendCodexFileIncrementIfPossible(input: input, context: context, cache: &cache, state: &state) {
Expand Down
164 changes: 164 additions & 0 deletions Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3940,6 +3940,170 @@ struct CostUsageScannerBreakdownTests {
#expect(abs((report.data[0].costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001)
}

@Test
func `codex forked child prefers leaf id over parent session id`() throws {
let env = try CostUsageTestEnvironment()
defer { env.cleanup() }

let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 1)
let childDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11)
let model = "openai/gpt-5.2-codex"
let parentSessionId = "sess-parent-lineage"
let childSessionId = "sess-child-leaf"
let forkTimestamp = env.isoString(for: parentDay.addingTimeInterval(2))

let parentURL = try env.writeCodexSessionFile(
day: parentDay,
filename: "rollout-2026-02-01T12-00-00-\(parentSessionId).jsonl",
contents: env.jsonl([
["type": "session_meta", "payload": ["id": parentSessionId]],
self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model),
self.codexTokenCount(
timestamp: env.isoString(for: parentDay.addingTimeInterval(1)),
model: model,
total: (input: 20, cached: 5, output: 2)),
]))
try FileManager.default.setAttributes([.modificationDate: parentDay], ofItemAtPath: parentURL.path)

let childURL = try env.writeCodexSessionFile(
day: childDay,
filename: "rollout-2026-03-11T12-00-00-\(childSessionId).jsonl",
contents: env.jsonl([
[
"type": "session_meta",
"payload": [
"id": childSessionId,
"session_id": parentSessionId,
"forked_from_id": parentSessionId,
"timestamp": forkTimestamp,
],
],
self.codexTurnContext(timestamp: env.isoString(for: childDay), model: model),
self.codexTokenCount(
timestamp: env.isoString(for: childDay.addingTimeInterval(1)),
model: model,
total: (input: 20, cached: 5, output: 2)),
self.codexTokenCount(
timestamp: env.isoString(for: childDay.addingTimeInterval(2)),
model: model,
total: (input: 27, cached: 7, output: 4)),
]))
#expect(try CostUsageScanner.parseCodexSessionIdentifier(fileURL: childURL) == childSessionId)

var options = CostUsageScanner.Options(
codexSessionsRoot: env.codexSessionsRoot,
claudeProjectsRoots: nil,
cacheRoot: env.cacheRoot)
options.refreshMinIntervalSeconds = 0

let report = CostUsageScanner.loadDailyReport(
provider: .codex,
since: childDay,
until: childDay,
now: childDay,
options: options)
#expect(report.data.count == 1)
#expect(report.data[0].inputTokens == 7)
#expect(report.data[0].cacheReadTokens == 2)
#expect(report.data[0].outputTokens == 2)
}

@Test
func `codex warm cache invalidates fork when parent baseline changes`() throws {
let env = try CostUsageTestEnvironment()
defer { env.cleanup() }

let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 1)
let childDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11)
let model = "openai/gpt-5.2-codex"
let parentSessionId = "sess-parent-growing"
let childSessionId = "sess-child-cached"
let forkTimestamp = env.isoString(for: parentDay.addingTimeInterval(3))
let parentMetadata: [String: Any] = [
"type": "session_meta",
"payload": ["id": parentSessionId],
]
let firstParentUsage = self.codexTokenCount(
timestamp: env.isoString(for: parentDay.addingTimeInterval(1)),
model: model,
total: (input: 20, cached: 5, output: 2))

let parentURL = try env.writeCodexSessionFile(
day: parentDay,
filename: "rollout-2026-02-01T12-00-00-\(parentSessionId).jsonl",
contents: env.jsonl([
parentMetadata,
self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model),
firstParentUsage,
]))
try FileManager.default.setAttributes([.modificationDate: parentDay], ofItemAtPath: parentURL.path)

_ = try env.writeCodexSessionFile(
day: childDay,
filename: "rollout-2026-03-11T12-00-00-\(childSessionId).jsonl",
contents: env.jsonl([
[
"type": "session_meta",
"payload": [
"id": childSessionId,
"forked_from_id": parentSessionId,
"timestamp": forkTimestamp,
],
],
self.codexTurnContext(timestamp: env.isoString(for: childDay), model: model),
self.codexTokenCount(
timestamp: env.isoString(for: childDay.addingTimeInterval(1)),
model: model,
total: (input: 20, cached: 5, output: 2)),
self.codexTokenCount(
timestamp: env.isoString(for: childDay.addingTimeInterval(2)),
model: model,
total: (input: 30, cached: 8, output: 3)),
self.codexTokenCount(
timestamp: env.isoString(for: childDay.addingTimeInterval(3)),
model: model,
total: (input: 37, cached: 10, output: 5)),
]))

var options = CostUsageScanner.Options(
codexSessionsRoot: env.codexSessionsRoot,
claudeProjectsRoots: nil,
cacheRoot: env.cacheRoot)
options.refreshMinIntervalSeconds = 0

let first = CostUsageScanner.loadDailyReport(
provider: .codex,
since: childDay,
until: childDay,
now: childDay,
options: options)
#expect(first.data.first?.inputTokens == 17)
#expect(first.data.first?.cacheReadTokens == 5)
#expect(first.data.first?.outputTokens == 3)

try env.jsonl([
parentMetadata,
self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model),
firstParentUsage,
self.codexTokenCount(
timestamp: env.isoString(for: parentDay.addingTimeInterval(2)),
model: model,
total: (input: 30, cached: 8, output: 3)),
]).write(to: parentURL, atomically: true, encoding: .utf8)

let second = CostUsageScanner.loadDailyReport(
provider: .codex,
since: childDay,
until: childDay,
now: childDay.addingTimeInterval(1),
options: options)

#expect(second.data.count == 1)
#expect(second.data[0].inputTokens == 7)
#expect(second.data[0].cacheReadTokens == 2)
#expect(second.data[0].outputTokens == 2)
}

@Test
func `codex forked child skips cumulative totals when parent session is missing`() throws {
let env = try CostUsageTestEnvironment()
Expand Down