diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 085fda188b..3ffbba1cae 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -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" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index ce5cad9cf9..0bc5818e03 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -6,6 +6,8 @@ enum CostUsageCacheIO { /// totals are counted, so every earlier cache must be rebuilt. private static let compatibleCodexProducerKeys: Set = [] + /// 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: @@ -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? diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index bcc11d0b6f..f7cde1b2a5 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -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, @@ -314,6 +315,7 @@ extension CostUsageScanner { lastCodexTurnID: lastCodexTurnID, sessionId: sessionId, forkedFromId: forkedFromId, + forkBaselineDependencyKey: forkBaselineDependencyKey, projectPath: projectPath, canonicalProjectPath: canonicalProjectPath, codexCostCacheComplete: codexCostCacheComplete, @@ -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( @@ -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 @@ -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, @@ -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 { @@ -1163,6 +1177,7 @@ extension CostUsageScanner { lastCodexTurnID: parsed.lastCodexTurnID, sessionId: sessionId, forkedFromId: parsed.forkedFromId, + forkBaselineDependencyKey: forkBaselineDependencyKey, projectPath: projectPath, canonicalProjectPath: canonicalProjectPath, codexCostNanos: Self.mergeCostMaps( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index fef88de04f..f2def3c64f 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -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 @@ -1351,7 +1366,7 @@ enum CostUsageScanner { payloadRange: Range?) -> 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 { @@ -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, @@ -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 @@ -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 @@ -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) { diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index 8a3b8ad4c3..b78e371add 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -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()