diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index f77d752171..62c2f05c93 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 = "4cf70ccd355c42ac" + static let value = "a8a9621fcff92f55" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 2515354d9d..77e4c65a90 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -1,10 +1,10 @@ import Foundation enum CostUsageCacheIO { - private static let compatibleCodexProducerKeys: Set = [ - "codex:cu:p3c27f997569eb3c5", - "codex:cu:pc54070a94f6419ea", - ] + /// 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. + private static let compatibleCodexProducerKeys: Set = [] private static func artifactVersion(for provider: UsageProvider) -> Int { switch provider { @@ -134,7 +134,10 @@ struct CostUsageFileUsage: Codable { var lastTotals: CostUsageCodexTotals? var lastCountedTotals: CostUsageCodexTotals? var lastRawTotalsBaseline: CostUsageCodexTotals? + var lastRawTotalsWatermark: CostUsageCodexTotals? + var seenRawTotals: [CostUsageCodexTotals]? var hasDivergentTotals: Bool? + var hasInterleavedTotals: Bool? var lastCodexTurnID: String? var sessionId: String? var forkedFromId: String? @@ -152,7 +155,7 @@ struct CostUsageFileUsage: Codable { var claudeRows: [CostUsageScanner.ClaudeUsageRow]? } -struct CostUsageCodexTotals: Codable { +struct CostUsageCodexTotals: Codable, Equatable { var input: Int var cached: Int var output: Int diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index d5f78283df..bcc11d0b6f 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -278,7 +278,10 @@ extension CostUsageScanner { lastTotals: CostUsageCodexTotals? = nil, lastCountedTotals: CostUsageCodexTotals? = nil, lastRawTotalsBaseline: CostUsageCodexTotals? = nil, + lastRawTotalsWatermark: CostUsageCodexTotals? = nil, + seenRawTotals: [CostUsageCodexTotals]? = nil, hasDivergentTotals: Bool? = nil, + hasInterleavedTotals: Bool? = nil, lastCodexTurnID: String? = nil, sessionId: String? = nil, forkedFromId: String? = nil, @@ -304,7 +307,10 @@ extension CostUsageScanner { lastTotals: lastTotals, lastCountedTotals: lastCountedTotals, lastRawTotalsBaseline: lastRawTotalsBaseline, + lastRawTotalsWatermark: lastRawTotalsWatermark, + seenRawTotals: seenRawTotals, hasDivergentTotals: hasDivergentTotals, + hasInterleavedTotals: hasInterleavedTotals, lastCodexTurnID: lastCodexTurnID, sessionId: sessionId, forkedFromId: forkedFromId, @@ -692,7 +698,10 @@ extension CostUsageScanner { lastTotals: usage.lastTotals, lastCountedTotals: usage.lastCountedTotals, lastRawTotalsBaseline: usage.lastRawTotalsBaseline, + lastRawTotalsWatermark: usage.lastRawTotalsWatermark, + seenRawTotals: usage.seenRawTotals, hasDivergentTotals: usage.hasDivergentTotals, + hasInterleavedTotals: usage.hasInterleavedTotals, lastCodexTurnID: usage.lastCodexTurnID, sessionId: usage.sessionId, forkedFromId: usage.forkedFromId, @@ -956,10 +965,18 @@ extension CostUsageScanner { let startOffset = cached.parsedBytes ?? cached.size let initialCountedTotals = cached.lastCountedTotals ?? cached.lastTotals let initialRawTotalsBaseline = cached.lastRawTotalsBaseline ?? cached.lastTotals + let initialHasDivergentTotals = cached.hasDivergentTotals ?? (cached.lastTotals == nil) + // Correctness-critical interleave state is watermark + interleaved flag (+ counted/raw). + // `seenRawTotals` is optional precision only and must not gate incremental resume (#2037). + let hasIncompleteInterleaveState = + (cached.hasInterleavedTotals == true && cached.lastRawTotalsWatermark == nil) + || (cached.lastRawTotalsWatermark != nil && cached.hasInterleavedTotals == nil) + || (initialHasDivergentTotals && cached.lastRawTotalsWatermark == nil) let canIncremental = input.metadata.size > cached.size && startOffset > 0 && startOffset <= input.metadata.size && initialCountedTotals != nil && cached.forkedFromId == nil + && !hasIncompleteInterleaveState guard canIncremental else { return false } let delta = try Self.parseCodexFileCancellable( @@ -969,7 +986,10 @@ extension CostUsageScanner { initialModel: cached.lastModel, initialTotals: initialCountedTotals, initialRawTotalsBaseline: initialRawTotalsBaseline, - initialHasDivergentTotals: cached.hasDivergentTotals ?? (cached.lastTotals == nil), + initialRawTotalsWatermark: cached.lastRawTotalsWatermark, + initialSeenRawTotals: cached.seenRawTotals ?? [], + initialHasDivergentTotals: initialHasDivergentTotals, + initialHasInterleavedTotals: cached.hasInterleavedTotals ?? false, initialCodexTurnID: cached.lastCodexTurnID, initialCodexUsageRowIndex: Self.nextCodexUsageRowIndex(cached.codexRows), checkCancellation: context.checkCancellation) @@ -1039,7 +1059,10 @@ extension CostUsageScanner { lastTotals: delta.lastTotals, lastCountedTotals: delta.lastCountedTotals, lastRawTotalsBaseline: delta.lastRawTotalsBaseline, + lastRawTotalsWatermark: delta.lastRawTotalsWatermark, + seenRawTotals: delta.seenRawTotals, hasDivergentTotals: delta.hasDivergentTotals, + hasInterleavedTotals: delta.hasInterleavedTotals, lastCodexTurnID: delta.lastCodexTurnID, sessionId: sessionId, forkedFromId: delta.forkedFromId ?? migratedCached.forkedFromId, @@ -1133,7 +1156,10 @@ extension CostUsageScanner { lastTotals: parsed.lastTotals, lastCountedTotals: parsed.lastCountedTotals, lastRawTotalsBaseline: parsed.lastRawTotalsBaseline, + lastRawTotalsWatermark: parsed.lastRawTotalsWatermark, + seenRawTotals: parsed.seenRawTotals, hasDivergentTotals: parsed.hasDivergentTotals, + hasInterleavedTotals: parsed.hasInterleavedTotals, lastCodexTurnID: parsed.lastCodexTurnID, sessionId: sessionId, forkedFromId: parsed.forkedFromId, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 2328a53a27..fe88c3cc6a 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -55,7 +55,10 @@ enum CostUsageScanner { let lastTotals: CostUsageCodexTotals? let lastCountedTotals: CostUsageCodexTotals? let lastRawTotalsBaseline: CostUsageCodexTotals? + let lastRawTotalsWatermark: CostUsageCodexTotals? + let seenRawTotals: [CostUsageCodexTotals] let hasDivergentTotals: Bool + let hasInterleavedTotals: Bool let lastCodexTurnID: String? let sessionId: String? let forkedFromId: String? @@ -176,6 +179,210 @@ enum CostUsageScanner { output: delta(raw: rawBaseline.output, counted: countedBaseline.output, current: current.output)) } + private static func codexMaxTotals( + _ lhs: CostUsageCodexTotals?, + _ rhs: CostUsageCodexTotals) -> CostUsageCodexTotals + { + guard let lhs else { return rhs } + return CostUsageCodexTotals( + input: max(lhs.input, rhs.input), + cached: max(lhs.cached, rhs.cached), + output: max(lhs.output, rhs.output)) + } + + /// Post-latch totals containment for interleaved cumulative counters (issue #2037 Phase 1). + /// + /// - When `current` is below the watermark, resume from the counted baseline so #968-style + /// recovery still works (`current - counted`). + /// - When `current` is at/above the watermark, advance from `max(watermark, counted)` so a + /// high/low lineage flip cannot re-count the gap between lineages. + private static func codexContainedTotalDelta( + watermark: CostUsageCodexTotals?, + counted: CostUsageCodexTotals?, + current: CostUsageCodexTotals) -> CostUsageCodexTotals + { + let watermark = watermark ?? .init(input: 0, cached: 0, output: 0) + let counted = counted ?? .init(input: 0, cached: 0, output: 0) + + func component(water: Int, counted: Int, current: Int) -> Int { + if current >= water { + return max(0, current - max(water, counted)) + } + return max(0, current - counted) + } + + return CostUsageCodexTotals( + input: component(water: watermark.input, counted: counted.input, current: current.input), + cached: component(water: watermark.cached, counted: counted.cached, current: current.cached), + output: component(water: watermark.output, counted: counted.output, current: current.output)) + } + + /// Post-latch event delta: contained totals growth, optionally capped by `last`. + /// + /// `last` alone must never increase counted usage when the contained totals delta is zero + /// (smaller lineage below the watermark is an accepted Phase 1 undercount). + private static func codexPostLatchEventDelta( + watermark: CostUsageCodexTotals?, + counted: CostUsageCodexTotals?, + current: CostUsageCodexTotals, + adjustedLast: CostUsageCodexTotals?) -> CostUsageCodexTotals + { + let contained = Self.codexContainedTotalDelta( + watermark: watermark, + counted: counted, + current: current) + guard let adjustedLast else { return contained } + return Self.codexMinTotals(adjustedLast, contained) + } + + /// Shared accounting guard for cumulative Codex token counters (issue #2037). + /// + /// Ultra-mode sessions interleave cumulative snapshots from several fork lineages inside one + /// session file. The tracker keeps a monotonic high watermark (never lowered). After a drop + /// latches interleaved mode, deltas use `codexPostLatchEventDelta` so gap recounting is + /// impossible. `seenRawTotals` is an optional precision optimization for exact re-emissions; + /// correctness does not depend on it once post-latch containment is active. + struct CodexTotalsTracker { + static let seenRawTotalsLimit = 64 + + private(set) var watermark: CostUsageCodexTotals? + private(set) var seenRawTotals: [CostUsageCodexTotals] + private(set) var sawInterleavedTotals: Bool + + init( + watermark: CostUsageCodexTotals? = nil, + seenRawTotals: [CostUsageCodexTotals] = [], + sawInterleavedTotals: Bool = false) + { + self.watermark = watermark + self.seenRawTotals = Array(seenRawTotals.suffix(Self.seenRawTotalsLimit)) + self.sawInterleavedTotals = sawInterleavedTotals + } + + func isSeen(_ totals: CostUsageCodexTotals) -> Bool { + self.seenRawTotals.contains(totals) + } + + /// Latches interleaved mode when any component of an observed cumulative snapshot drops + /// strictly below the watermark. A monotonic counter cannot decrease, so a drop means either + /// a second lineage or a reset; both must stop trusting gap-sized totals deltas. + mutating func latchIfBelowWatermark(_ totals: CostUsageCodexTotals) { + guard let watermark = self.watermark else { return } + if totals.input < watermark.input + || totals.cached < watermark.cached + || totals.output < watermark.output + { + self.sawInterleavedTotals = true + } + } + + /// Records an observed cumulative snapshot: raises the watermark and remembers the exact + /// value for best-effort re-emission suppression. Call after computing the event's delta. + mutating func commitObserved(_ totals: CostUsageCodexTotals) { + self.raiseWatermark(to: totals) + if !self.seenRawTotals.contains(totals) { + self.seenRawTotals.append(totals) + if self.seenRawTotals.count > Self.seenRawTotalsLimit { + self.seenRawTotals.removeFirst(self.seenRawTotals.count - Self.seenRawTotalsLimit) + } + } + } + + /// Raises the watermark for baseline assignments that are not observed raw snapshots + /// (for example counted totals in last-only streams). Never lowers it. + mutating func raiseWatermark(to totals: CostUsageCodexTotals) { + self.watermark = CostUsageScanner.codexMaxTotals(self.watermark, totals) + } + } + + /// Cumulative-totals accounting for parent-session snapshot building. Applies the same + /// containment policy as `parseCodexFileCancellable` so fork children inherit baselines + /// computed under identical rules. + private struct CodexSnapshotAccumulator { + var countedTotals: CostUsageCodexTotals? + var rawTotalsBaseline: CostUsageCodexTotals? + var sawDivergentTotals = false + var tracker = CodexTotalsTracker() + + /// Applies one token-count event and returns the counted cumulative totals afterwards. + mutating func apply( + last: CostUsageCodexTotals?, + total: CostUsageCodexTotals?) -> CostUsageCodexTotals + { + let base = self.countedTotals ?? .init(input: 0, cached: 0, output: 0) + if let total { + // Best-effort exact re-emission suppression (precision only; containment is load-bearing). + if self.tracker.isSeen(total) { return base } + self.tracker.latchIfBelowWatermark(total) + } + let watermarkBaseline = self.tracker.watermark ?? self.rawTotalsBaseline + defer { + if let total { self.tracker.commitObserved(total) } + } + + if let last { + var countedDelta = last + if let total { + if self.tracker.sawInterleavedTotals { + countedDelta = CostUsageScanner.codexPostLatchEventDelta( + watermark: watermarkBaseline, + counted: self.countedTotals, + current: total, + adjustedLast: last) + } else { + let totalDelta = CostUsageScanner.codexTotalDelta(from: watermarkBaseline, to: total) + if CostUsageScanner.codexShouldPreferTotalDelta( + rawBaseline: watermarkBaseline, + currentTotal: total, + totalDelta: totalDelta, + lastDelta: last, + sawDivergentTotals: self.sawDivergentTotals) + { + countedDelta = totalDelta + } + } + let next = CostUsageScanner.codexAddTotals(base, countedDelta) + self.countedTotals = next + self.rawTotalsBaseline = total + if !CostUsageScanner.codexTotalsEqual(total, next) { + self.sawDivergentTotals = true + } + return next + } + let next = CostUsageScanner.codexAddTotals(base, countedDelta) + self.countedTotals = next + self.rawTotalsBaseline = next + self.tracker.raiseWatermark(to: next) + return next + } + + if let total { + let delta: CostUsageCodexTotals = if self.tracker.sawInterleavedTotals { + CostUsageScanner.codexContainedTotalDelta( + watermark: watermarkBaseline, + counted: self.countedTotals, + current: total) + } else if self.sawDivergentTotals { + CostUsageScanner.codexDivergentTotalDelta( + rawBaseline: watermarkBaseline, + countedBaseline: self.countedTotals, + current: total) + } else { + CostUsageScanner.codexTotalDelta(from: watermarkBaseline, to: total) + } + let counted = CostUsageScanner.codexAddTotals(base, delta) + self.countedTotals = counted + self.rawTotalsBaseline = total + if !CostUsageScanner.codexTotalsEqual(total, counted) { + self.sawDivergentTotals = true + } + return counted + } + + return base + } + } + struct CodexScanResources { let fileIndex: CodexSessionFileIndex let inheritedResolver: CodexInheritedTotalsResolver @@ -1433,9 +1640,7 @@ enum CostUsageScanner { snapshots: [CodexTimestampedTotals]) { var sessionId: String? - var previousTotals: CostUsageCodexTotals? - var rawTotalsBaseline: CostUsageCodexTotals? - var sawDivergentTotals = false + var accumulator = CodexSnapshotAccumulator() var snapshots: [CodexTimestampedTotals] = [] var warnedAboutUnparsedTimestamp = false @@ -1452,59 +1657,12 @@ enum CostUsageScanner { } func appendSnapshot(timestamp: String, last: CostUsageCodexTotals?, total: CostUsageCodexTotals?) { - if let last { - let rawDelta = last - let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) - var countedDelta = rawDelta - - if let total { - let rawTotals = total - let totalDelta = Self.codexTotalDelta(from: rawTotalsBaseline, to: rawTotals) - if Self.codexShouldPreferTotalDelta( - rawBaseline: rawTotalsBaseline, - currentTotal: rawTotals, - totalDelta: totalDelta, - lastDelta: rawDelta, - sawDivergentTotals: sawDivergentTotals) - { - countedDelta = totalDelta - } - let next = Self.codexAddTotals(base, countedDelta) - previousTotals = next - rawTotalsBaseline = rawTotals - if !Self.codexTotalsEqual(rawTotals, next) { - sawDivergentTotals = true - } - } else { - let next = Self.codexAddTotals(base, countedDelta) - previousTotals = next - rawTotalsBaseline = next - } - - snapshots.append(CodexTimestampedTotals( - timestamp: timestamp, - date: parsedSnapshotDate(timestamp: timestamp), - totals: previousTotals ?? base)) - } else if let total { - let next = total - let delta = sawDivergentTotals - ? Self.codexDivergentTotalDelta( - rawBaseline: rawTotalsBaseline, - countedBaseline: previousTotals, - current: next) - : Self.codexTotalDelta(from: rawTotalsBaseline, to: next) - let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) - let countedTotals = Self.codexAddTotals(base, delta) - previousTotals = countedTotals - rawTotalsBaseline = next - if !Self.codexTotalsEqual(next, countedTotals) { - sawDivergentTotals = true - } - snapshots.append(CodexTimestampedTotals( - timestamp: timestamp, - date: parsedSnapshotDate(timestamp: timestamp), - totals: countedTotals)) - } + guard last != nil || total != nil else { return } + let counted = accumulator.apply(last: last, total: total) + snapshots.append(CodexTimestampedTotals( + timestamp: timestamp, + date: parsedSnapshotDate(timestamp: timestamp), + totals: counted)) } do { @@ -1618,7 +1776,10 @@ enum CostUsageScanner { lastTotals: initialTotals, lastCountedTotals: initialTotals, lastRawTotalsBaseline: initialRawTotalsBaseline, + lastRawTotalsWatermark: initialRawTotalsBaseline, + seenRawTotals: [], hasDivergentTotals: initialHasDivergentTotals, + hasInterleavedTotals: false, lastCodexTurnID: initialCodexTurnID, sessionId: nil, forkedFromId: nil, @@ -1634,7 +1795,10 @@ enum CostUsageScanner { initialModel: String? = nil, initialTotals: CostUsageCodexTotals? = nil, initialRawTotalsBaseline: CostUsageCodexTotals? = nil, + initialRawTotalsWatermark: CostUsageCodexTotals? = nil, + initialSeenRawTotals: [CostUsageCodexTotals] = [], initialHasDivergentTotals: Bool = false, + initialHasInterleavedTotals: Bool = false, initialCodexTurnID: String? = nil, initialCodexUsageRowIndex: Int = 0, inheritedTotalsResolver: ((String, String) throws -> CodexForkBaseline)? = nil, @@ -1654,6 +1818,10 @@ enum CostUsageScanner { var codexUsageRowIndex = initialCodexUsageRowIndex var rawTotalsBaseline = initialRawTotalsBaseline ?? initialTotals var sawDivergentTotals = initialHasDivergentTotals + var tracker = CodexTotalsTracker( + watermark: initialRawTotalsWatermark ?? initialRawTotalsBaseline ?? initialTotals, + seenRawTotals: initialSeenRawTotals, + sawInterleavedTotals: initialHasInterleavedTotals) var deferredError: Error? var days: [String: [String: [Int]]] = [:] @@ -1737,21 +1905,72 @@ enum CostUsageScanner { return adjusted } + // Fork children are measured against the parent-inherited baseline so every cumulative + // comparison in this file happens on a single scale. + let adjustedTotal: CostUsageCodexTotals? = total.map { rawTotals in + guard let inheritedTotals, !hasUnresolvedForkBaseline else { return rawTotals } + return CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } + + if let adjustedTotal { + // Best-effort exact re-emission suppression. Post-latch containment is the + // load-bearing guard; the seen-set is only a precision optimization. + if tracker.isSeen(adjustedTotal) { return } + tracker.latchIfBelowWatermark(adjustedTotal) + } + let watermarkBaseline = tracker.watermark ?? rawTotalsBaseline + defer { + if let adjustedTotal { tracker.commitObserved(adjustedTotal) } + } + + func totalsDerivedDelta(to currentTotals: CostUsageCodexTotals) -> CostUsageCodexTotals { + if tracker.sawInterleavedTotals { + return Self.codexContainedTotalDelta( + watermark: watermarkBaseline, + counted: previousTotals, + current: currentTotals) + } + if sawDivergentTotals { + return Self.codexDivergentTotalDelta( + rawBaseline: watermarkBaseline, + countedBaseline: previousTotals, + current: currentTotals) + } + return Self.codexTotalDelta(from: watermarkBaseline, to: currentTotals) + } + + func commitDelta(_ delta: CostUsageCodexTotals, rawBaseline: CostUsageCodexTotals) { + deltaInput = delta.input + deltaCached = delta.cached + deltaOutput = delta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, delta) + rawTotalsBaseline = rawBaseline + if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { + sawDivergentTotals = true + } + } + let handledUnresolvedForkTotal = hasUnresolvedForkBaseline && total != nil if hasUnresolvedForkBaseline, let total { + // `unresolvedForkTotalWatermark` is a presence sentinel for "skip the first + // unresolved-fork totals row"; delta baselines come from the global tracker. let currentRawTotals = total defer { unresolvedForkTotalWatermark = currentRawTotals } guard let last, - let watermark = unresolvedForkTotalWatermark + unresolvedForkTotalWatermark != nil else { return } - let rawLastDelta = last - let rawTotalDelta = Self.codexTotalDelta(from: watermark, to: currentRawTotals) - let adjustedDelta = Self.codexMinTotals(rawLastDelta, rawTotalDelta) + let adjustedDelta = Self.codexMinTotals( + last, + Self.codexTotalDelta(from: watermarkBaseline, to: currentRawTotals)) deltaInput = adjustedDelta.input deltaCached = adjustedDelta.cached deltaOutput = adjustedDelta.output @@ -1761,107 +1980,63 @@ enum CostUsageScanner { } if !handledUnresolvedForkTotal, - let total, + let currentTotals = adjustedTotal, forkedFromId != nil, !hasUnresolvedForkBaseline { - let rawTotals = total - let currentTotals: CostUsageCodexTotals = if let inheritedTotals { - CostUsageCodexTotals( - input: max(0, rawTotals.input - inheritedTotals.input), - cached: max(0, rawTotals.cached - inheritedTotals.cached), - output: max(0, rawTotals.output - inheritedTotals.output)) + // Non-interleaved forks keep totals-only accounting (#1164 / 45b68c34). + // After latch, use post-latch containment capped by last when present. + let delta: CostUsageCodexTotals = if tracker.sawInterleavedTotals { + Self.codexPostLatchEventDelta( + watermark: watermarkBaseline, + counted: previousTotals, + current: currentTotals, + adjustedLast: last.map { adjustedLastDelta($0) }) } else { - rawTotals - } - let delta = sawDivergentTotals - ? Self.codexDivergentTotalDelta( - rawBaseline: rawTotalsBaseline, - countedBaseline: previousTotals, - current: currentTotals) - : Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) - deltaInput = delta.input - deltaCached = delta.cached - deltaOutput = delta.output - let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) - previousTotals = Self.codexAddTotals(prev, delta) - rawTotalsBaseline = currentTotals - if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { - sawDivergentTotals = true + totalsDerivedDelta(to: currentTotals) } + commitDelta(delta, rawBaseline: currentTotals) remainingInheritedTotals = nil } else if !handledUnresolvedForkTotal, let last { let rawDelta = last let hadRemainingInheritedTotals = remainingInheritedTotals != nil var adjustedDelta = adjustedLastDelta(rawDelta) - deltaInput = adjustedDelta.input - deltaCached = adjustedDelta.cached - deltaOutput = adjustedDelta.output let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) - if let total, !hasUnresolvedForkBaseline { - let rawTotals = total - let currentTotals: CostUsageCodexTotals = if let inheritedTotals { - CostUsageCodexTotals( - input: max(0, rawTotals.input - inheritedTotals.input), - cached: max(0, rawTotals.cached - inheritedTotals.cached), - output: max(0, rawTotals.output - inheritedTotals.output)) - } else { - rawTotals - } - let totalDelta = Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) - if !hadRemainingInheritedTotals, - Self.codexShouldPreferTotalDelta( - rawBaseline: rawTotalsBaseline, - currentTotal: currentTotals, - totalDelta: totalDelta, - lastDelta: rawDelta, - sawDivergentTotals: sawDivergentTotals) - { - adjustedDelta = totalDelta - deltaInput = adjustedDelta.input - deltaCached = adjustedDelta.cached - deltaOutput = adjustedDelta.output + if let currentTotals = adjustedTotal, !hasUnresolvedForkBaseline { + if tracker.sawInterleavedTotals { + adjustedDelta = Self.codexPostLatchEventDelta( + watermark: watermarkBaseline, + counted: previousTotals, + current: currentTotals, + adjustedLast: adjustedDelta) remainingInheritedTotals = nil + } else { + let totalDelta = Self.codexTotalDelta(from: watermarkBaseline, to: currentTotals) + if !hadRemainingInheritedTotals, + Self.codexShouldPreferTotalDelta( + rawBaseline: watermarkBaseline, + currentTotal: currentTotals, + totalDelta: totalDelta, + lastDelta: rawDelta, + sawDivergentTotals: sawDivergentTotals) + { + adjustedDelta = totalDelta + remainingInheritedTotals = nil + } } - let countedTotals = Self.codexAddTotals(prev, adjustedDelta) - previousTotals = countedTotals - rawTotalsBaseline = currentTotals - if !Self.codexTotalsEqual(currentTotals, countedTotals) { - sawDivergentTotals = true - } + commitDelta(adjustedDelta, rawBaseline: currentTotals) } else { let countedTotals = Self.codexAddTotals(prev, adjustedDelta) + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output previousTotals = countedTotals rawTotalsBaseline = countedTotals + tracker.raiseWatermark(to: countedTotals) } - } else if !handledUnresolvedForkTotal, let total { - let rawTotals = total - - let currentTotals: CostUsageCodexTotals = if let inheritedTotals { - CostUsageCodexTotals( - input: max(0, rawTotals.input - inheritedTotals.input), - cached: max(0, rawTotals.cached - inheritedTotals.cached), - output: max(0, rawTotals.output - inheritedTotals.output)) - } else { - rawTotals - } - - let delta = sawDivergentTotals - ? Self.codexDivergentTotalDelta( - rawBaseline: rawTotalsBaseline, - countedBaseline: previousTotals, - current: currentTotals) - : Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) - deltaInput = delta.input - deltaCached = delta.cached - deltaOutput = delta.output - let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) - previousTotals = Self.codexAddTotals(prev, delta) - rawTotalsBaseline = currentTotals - if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { - sawDivergentTotals = true - } + } else if !handledUnresolvedForkTotal, let currentTotals = adjustedTotal { + commitDelta(totalsDerivedDelta(to: currentTotals), rawBaseline: currentTotals) remainingInheritedTotals = nil } else if !handledUnresolvedForkTotal { return @@ -2005,7 +2180,7 @@ enum CostUsageScanner { } guard let tsText = obj["timestamp"] as? String else { return } - guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) + guard Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) != nil else { return } if type == "turn_context" { @@ -2034,7 +2209,6 @@ enum CostUsageScanner { ?? info?["model_name"] as? String ?? payload["model"] as? String ?? obj["model"] as? String - let model = currentModel ?? modelFromInfo ?? "gpt-5" func toInt(_ v: Any?) -> Int { if let n = v as? NSNumber { return n.intValue } @@ -2048,191 +2222,16 @@ enum CostUsageScanner { output: max(0, toInt(usage["output_tokens"]))) } - let total = (info?["total_token_usage"] as? [String: Any]) - let last = (info?["last_token_usage"] as? [String: Any]) - - var deltaInput = 0 - var deltaCached = 0 - var deltaOutput = 0 - - func adjustedLastDelta(_ rawDelta: CostUsageCodexTotals) -> CostUsageCodexTotals { - guard var remaining = remainingInheritedTotals else { return rawDelta } - - let adjusted = CostUsageCodexTotals( - input: max(0, rawDelta.input - remaining.input), - cached: max(0, rawDelta.cached - remaining.cached), - output: max(0, rawDelta.output - remaining.output)) - - remaining.input = max(0, remaining.input - rawDelta.input) - remaining.cached = max(0, remaining.cached - rawDelta.cached) - remaining.output = max(0, remaining.output - rawDelta.output) - remainingInheritedTotals = if remaining.input == 0, remaining.cached == 0, - remaining.output == 0 - { - nil - } else { - remaining - } - - return adjusted - } - - let handledUnresolvedForkTotal = hasUnresolvedForkBaseline && total != nil - if hasUnresolvedForkBaseline, let total { - let currentRawTotals = tokenTotals(total) - defer { - unresolvedForkTotalWatermark = currentRawTotals - } - guard let last, - let watermark = unresolvedForkTotalWatermark - else { - return - } - - let rawLastDelta = tokenTotals(last) - let rawTotalDelta = Self.codexTotalDelta(from: watermark, to: currentRawTotals) - let adjustedDelta = Self.codexMinTotals(rawLastDelta, rawTotalDelta) - deltaInput = adjustedDelta.input - deltaCached = adjustedDelta.cached - deltaOutput = adjustedDelta.output - let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) - previousTotals = Self.codexAddTotals(prev, adjustedDelta) - rawTotalsBaseline = previousTotals - } - - if !handledUnresolvedForkTotal, - let total, - forkedFromId != nil, - !hasUnresolvedForkBaseline - { - let rawTotals = tokenTotals(total) - let currentTotals: CostUsageCodexTotals = if let inheritedTotals { - CostUsageCodexTotals( - input: max(0, rawTotals.input - inheritedTotals.input), - cached: max(0, rawTotals.cached - inheritedTotals.cached), - output: max(0, rawTotals.output - inheritedTotals.output)) - } else { - rawTotals - } - let delta = sawDivergentTotals - ? Self.codexDivergentTotalDelta( - rawBaseline: rawTotalsBaseline, - countedBaseline: previousTotals, - current: currentTotals) - : Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) - deltaInput = delta.input - deltaCached = delta.cached - deltaOutput = delta.output - let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) - previousTotals = Self.codexAddTotals(prev, delta) - rawTotalsBaseline = currentTotals - if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { - sawDivergentTotals = true - } - remainingInheritedTotals = nil - } else if !handledUnresolvedForkTotal, let last { - let rawDelta = CostUsageCodexTotals( - input: max(0, toInt(last["input_tokens"])), - cached: max(0, toInt(last["cached_input_tokens"] ?? last["cache_read_input_tokens"])), - output: max(0, toInt(last["output_tokens"]))) - let hadRemainingInheritedTotals = remainingInheritedTotals != nil - var adjustedDelta = adjustedLastDelta(rawDelta) - deltaInput = adjustedDelta.input - deltaCached = adjustedDelta.cached - deltaOutput = adjustedDelta.output - let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) - - if let total, !hasUnresolvedForkBaseline { - let rawTotals = tokenTotals(total) - let currentTotals: CostUsageCodexTotals = if let inheritedTotals { - CostUsageCodexTotals( - input: max(0, rawTotals.input - inheritedTotals.input), - cached: max(0, rawTotals.cached - inheritedTotals.cached), - output: max(0, rawTotals.output - inheritedTotals.output)) - } else { - rawTotals - } - let totalDelta = Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) - if !hadRemainingInheritedTotals, - Self.codexShouldPreferTotalDelta( - rawBaseline: rawTotalsBaseline, - currentTotal: currentTotals, - totalDelta: totalDelta, - lastDelta: rawDelta, - sawDivergentTotals: sawDivergentTotals) - { - adjustedDelta = totalDelta - deltaInput = adjustedDelta.input - deltaCached = adjustedDelta.cached - deltaOutput = adjustedDelta.output - remainingInheritedTotals = nil - } - let countedTotals = Self.codexAddTotals(prev, adjustedDelta) - previousTotals = countedTotals - rawTotalsBaseline = currentTotals - if !Self.codexTotalsEqual(currentTotals, countedTotals) { - sawDivergentTotals = true - } - } else { - let countedTotals = Self.codexAddTotals(prev, adjustedDelta) - previousTotals = countedTotals - rawTotalsBaseline = countedTotals - } - } else if !handledUnresolvedForkTotal, let total { - let rawTotals = tokenTotals(total) - - let currentTotals: CostUsageCodexTotals = if let inheritedTotals { - CostUsageCodexTotals( - input: max(0, rawTotals.input - inheritedTotals.input), - cached: max(0, rawTotals.cached - inheritedTotals.cached), - output: max(0, rawTotals.output - inheritedTotals.output)) - } else { - rawTotals - } - - let delta = sawDivergentTotals - ? Self.codexDivergentTotalDelta( - rawBaseline: rawTotalsBaseline, - countedBaseline: previousTotals, - current: currentTotals) - : Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) - deltaInput = delta.input - deltaCached = delta.cached - deltaOutput = delta.output - let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) - previousTotals = Self.codexAddTotals(prev, delta) - rawTotalsBaseline = currentTotals - if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { - sawDivergentTotals = true - } - remainingInheritedTotals = nil - } else if !handledUnresolvedForkTotal { - return - } - - if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return } - let eventIndex = codexUsageRowIndex - codexUsageRowIndex += 1 - let normModel = CostUsagePricing.normalizeCodexModel(model) - add( - dayKey: dayKey, - model: normModel, - input: deltaInput, - cached: deltaCached, - output: deltaOutput) - if CostUsageDayRange.isInRange( - dayKey: dayKey, - since: range.scanSinceKey, - until: range.scanUntilKey) - { - rows.append(CodexUsageRow( - day: dayKey, - model: normModel, - turnID: Self.codexTurnID(from: payload) ?? currentTurnID, - eventIndex: eventIndex, - input: deltaInput, - cached: deltaCached, - output: deltaOutput)) + let record = CodexTokenCountRecord( + timestamp: tsText, + model: modelFromInfo, + turnID: Self.codexTurnID(from: payload), + last: (info?["last_token_usage"] as? [String: Any]).map(tokenTotals), + total: (info?["total_token_usage"] as? [String: Any]).map(tokenTotals)) + do { + try handleTokenCount(record) + } catch { + deferredError = error } } }) @@ -2257,7 +2256,10 @@ enum CostUsageScanner { : previousTotals, lastCountedTotals: previousTotals, lastRawTotalsBaseline: rawTotalsBaseline, + lastRawTotalsWatermark: tracker.watermark, + seenRawTotals: tracker.seenRawTotals, hasDivergentTotals: sawDivergentTotals && !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals), + hasInterleavedTotals: tracker.sawInterleavedTotals, lastCodexTurnID: currentTurnID, sessionId: sessionId, forkedFromId: forkedFromId, diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 7ecd2fcdcf..533416078f 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -81,43 +81,27 @@ struct CostUsageCacheTests { } @Test - func `current codex cache accepts parser compatible 0_33 producer`() throws { + func `current codex cache rejects pre interleave containment producers`() throws { + // Interleave containment (#2037) changed cumulative delta semantics, so caches from + // previously compatible parser hashes must be rebuilt instead of reused. let root = try self.makeTemporaryCacheRoot() defer { try? FileManager.default.removeItem(at: root) } - var cache = CostUsageCache() - cache.lastScanUnixMs = 123 - cache.days = ["2026-05-18": ["gpt-5.5": [1, 2, 3]]] - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p3c27f997569eb3c5") + for legacyProducerKey in ["codex:cu:p3c27f997569eb3c5", "codex:cu:pc54070a94f6419ea"] { + var cache = CostUsageCache() + cache.lastScanUnixMs = 123 + cache.days = ["2026-05-18": ["gpt-5.5": [1, 2, 3]]] + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: legacyProducerKey) - let loaded = CostUsageCacheIO.load(provider: .codex, cacheRoot: root) + let loaded = CostUsageCacheIO.load(provider: .codex, cacheRoot: root) - #expect(loaded.lastScanUnixMs == 123) - #expect(loaded.days["2026-05-18"]?["gpt-5.5"] == [1, 2, 3]) - } - - @Test - func `current codex cache accepts project metadata migration producer`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.lastScanUnixMs = 123 - cache.days = ["2026-05-18": ["gpt-5.5": [1, 2, 3]]] - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:pc54070a94f6419ea") - - let loaded = CostUsageCacheIO.load(provider: .codex, cacheRoot: root) - - #expect(loaded.lastScanUnixMs == 123) - #expect(loaded.days["2026-05-18"]?["gpt-5.5"] == [1, 2, 3]) + #expect(loaded.lastScanUnixMs == 0) + #expect(loaded.days.isEmpty) + } } @Test diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index d4b8a054f6..f3fb399982 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -1683,6 +1683,811 @@ struct CostUsageScannerBreakdownTests { #expect(parsed.lastTotals?.input == 150) } + @Test + func `codex interleaved cumulative lineages do not recount the gap`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let model = "openai/gpt-5.5" + // Two interleaved totals-only lineages in one file (Ultra sub-agents, #2037). The old + // single-baseline logic recounted the A/B gap on every flip (100k + 96k + 96k = 292k). + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "interleaved-lineages.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 5000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 101_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(4)), + model: model, + total: (input: 6000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(5)), + model: model, + total: (input: 102_000, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 102_000) + #expect(parsed.rows.count == 3) + #expect(parsed.hasInterleavedTotals) + } + + @Test + func `codex alternating repeated snapshots count zero`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let model = "openai/gpt-5.5" + // Alternating re-emissions with fat `last` on every row. Post-latch containment caps + // `last` by the contained totals delta (zero on lineage flips), so repeats cannot inflate + // even without relying on the seen-set FIFO. + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "alternating-repeats.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 1000, cached: 0, output: 0), + last: (input: 1000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 50, cached: 0, output: 0), + last: (input: 50, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 1000, cached: 0, output: 0), + last: (input: 1000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(4)), + model: model, + total: (input: 50, cached: 0, output: 0), + last: (input: 50, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(5)), + model: model, + total: (input: 1000, cached: 0, output: 0), + last: (input: 1000, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + // Phase 1: smaller lineage below the watermark is dropped (50 never counted). + #expect(packed[safe: 0] == 1000) + #expect(parsed.rows.count == 1) + #expect(parsed.hasInterleavedTotals) + } + + @Test + func `codex totals only growth below watermark is conservatively dropped`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let model = "openai/gpt-5.5" + // Accepted Phase 1 limitation: a totals-only lineage growing beneath another lineage's + // watermark (5000 -> 7000) contributes nothing. Undercount, never inflate. + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "below-watermark-growth.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 5000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 7000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(4)), + model: model, + total: (input: 100_500, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 100_500) + #expect(parsed.rows.count == 2) + } + + @Test + func `codex single lineage counter reset undercounts but never inflates`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let model = "openai/gpt-5.5" + // A genuine counter reset latches interleaved mode; totals-only growth below the old + // peak is dropped and counting resumes once the counter passes the watermark. + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "counter-reset.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 1000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 1200, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 300, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(4)), + model: model, + total: (input: 800, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(5)), + model: model, + total: (input: 1500, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 1500) + #expect(parsed.rows.count == 3) + } + + @Test + func `codex interleaved fork child caps last by contained total delta`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let iso0 = env.isoString(for: day) + let model = "openai/gpt-5.5" + // Phase 1: after latch, min(last, containedTotalDelta). The mid-row last=5 is dropped + // because contained delta is 0 below the watermark; only watermark advances count. + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(iso0)-interleaved-fork-child.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": iso0, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "timestamp": iso0, + ], + ], + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 1010, cached: 0, output: 0), + last: (input: 10, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 505, cached: 0, output: 0), + last: (input: 5, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 1020, cached: 0, output: 0), + last: (input: 10, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { parentSessionId, _ in + #expect(parentSessionId == "parent-session") + return .resolved(.init(input: 1000, cached: 0, output: 0)) + }) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 20) + #expect(parsed.rows.count == 2) + #expect(parsed.hasInterleavedTotals) + } + + @Test + func `codex root interleaved caps last much larger than watermark delta`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let model = "openai/gpt-5.5" + // After latch, a tiny watermark advance with a huge replayed/status `last` must count + // only the contained totals delta (1000), not the full last (100_000). + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "root-last-cap.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 5000, cached: 0, output: 0), + last: (input: 5000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 101_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 101_000) + #expect(parsed.rows.count == 2) + #expect(parsed.hasInterleavedTotals) + } + + @Test + func `codex fork interleaved caps last much larger than watermark delta`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let iso0 = env.isoString(for: day) + let model = "openai/gpt-5.5" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(iso0)-fork-last-cap.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": iso0, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "timestamp": iso0, + ], + ], + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 2000, cached: 0, output: 0), + last: (input: 1000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 100, cached: 0, output: 0), + last: (input: 100, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 2100, cached: 0, output: 0), + last: (input: 50000, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { _, _ in + .resolved(.init(input: 1000, cached: 0, output: 0)) + }) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + // adjusted: 1000, then 0 (latch), then 1100 → contained deltas 1000 + 0 + 100 = 1100 + #expect(packed[safe: 0] == 1100) + #expect(parsed.hasInterleavedTotals) + } + + @Test + func `codex interleaved replay after sixty five unique snapshots stays contained`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let model = "openai/gpt-5.5" + var events: [[String: Any]] = [ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + // Latch interleaved mode with a second lineage. + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 5000, cached: 0, output: 0), + last: (input: 5000, cached: 0, output: 0)), + ] + // 65 unique advances of lineage A — enough to FIFO-evict the B=5000 snapshot. + for index in 0..<65 { + let total = 100_001 + index + events.append(self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(TimeInterval(3 + index))), + model: model, + total: (input: total, cached: 0, output: 0), + last: (input: 1, cached: 0, output: 0))) + } + // Re-emit the evicted B snapshot with a fat last; containment must keep it at zero. + events.append(self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(70)), + model: model, + total: (input: 5000, cached: 0, output: 0), + last: (input: 5000, cached: 0, output: 0))) + + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "eviction-replay.jsonl", + contents: env.jsonl(events)) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 100_065) + #expect(parsed.hasInterleavedTotals) + } + + @Test + func `codex interleaved totals only sequences stay within containment bound`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 11) + let model = "openai/gpt-5.5" + // Property-style: many interleaved totals-only sequences must never exceed the max + // observed cumulative total (the Phase 1 never-inflates bound for totals-only streams). + for seed in 0..<40 { + var a = 10000 + seed * 17 + var b = 100 + seed * 3 + var maxObserved = 0 + var events: [[String: Any]] = [ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + ] + for step in 0..<30 { + let useA = (step + seed) % 3 != 0 + if useA { + a += 1 + (step % 5) + maxObserved = max(maxObserved, a) + events.append(self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(TimeInterval(step + 1))), + model: model, + total: (input: a, cached: 0, output: 0))) + } else { + b += 1 + (step % 3) + maxObserved = max(maxObserved, b) + events.append(self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(TimeInterval(step + 1))), + model: model, + total: (input: b, cached: 0, output: 0))) + } + } + + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "property-\(seed).jsonl", + contents: env.jsonl(events)) + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let counted = parsed.days[dayKey]?["gpt-5.5"]?[safe: 0] ?? 0 + #expect(counted <= maxObserved) + #expect(counted >= 10000 + seed * 17) + } + } + + @Test + func `codex incremental append preserves interleave containment across boundary`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 12) + let iso0 = env.isoString(for: day) + let model = "openai/gpt-5.5" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "timestamp": iso0, + "payload": ["session_id": "interleaved-incremental"], + ] + let turnContext = self.codexTurnContext(timestamp: iso0, model: model) + let initialEvents: [[String: Any]] = [ + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 5000, cached: 0, output: 0), + last: (input: 5000, cached: 0, output: 0)), + ] + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([sessionMeta, turnContext] + initialEvents)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(first.data.first?.totalTokens == 100_000) + + let appendedEvents: [[String: Any]] = [ + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(4)), + model: model, + total: (input: 5000, cached: 0, output: 0), + last: (input: 5000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(5)), + model: model, + total: (input: 101_000, cached: 0, output: 0), + last: (input: 1000, cached: 0, output: 0)), + ] + try env.jsonl([sessionMeta, turnContext] + initialEvents + appendedEvents) + .write(to: fileURL, atomically: true, encoding: .utf8) + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(second.data.first?.totalTokens == 101_000) + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let usage = cache.files.first { URL(fileURLWithPath: $0.key).lastPathComponent == fileURL.lastPathComponent }? + .value + #expect(usage?.hasInterleavedTotals == true) + #expect(usage?.lastRawTotalsWatermark?.input == 101_000) + #expect(usage?.lastCountedTotals?.input == 101_000) + + options.forceRescan = true + let rescanned = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(2), + options: options) + #expect(rescanned.data.first?.totalTokens == 101_000) + + let rescannedCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let rescannedUsage = rescannedCache.files + .first { URL(fileURLWithPath: $0.key).lastPathComponent == fileURL.lastPathComponent }? + .value + #expect(rescannedUsage?.hasInterleavedTotals == usage?.hasInterleavedTotals) + #expect(rescannedUsage?.lastRawTotalsWatermark == usage?.lastRawTotalsWatermark) + #expect(rescannedUsage?.lastCountedTotals == usage?.lastCountedTotals) + #expect(rescannedUsage?.hasDivergentTotals == usage?.hasDivergentTotals) + #expect(rescannedUsage?.codexCostNanos == usage?.codexCostNanos) + #expect(rescanned.data.first?.totalTokens == second.data.first?.totalTokens) + } + + @Test + func `codex missing watermark or interleaved flag forces full rescan`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 12) + let iso0 = env.isoString(for: day) + let model = "openai/gpt-5.5" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "timestamp": iso0, + "payload": ["session_id": "incomplete-interleave-critical"], + ] + let turnContext = self.codexTurnContext(timestamp: iso0, model: model) + let initialEvents: [[String: Any]] = [ + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 5000, cached: 0, output: 0), + last: (input: 5000, cached: 0, output: 0)), + ] + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([sessionMeta, turnContext] + initialEvents)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let replayedSnapshot = self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)) + + // Correctness-critical fields: missing either forces a full rescan rather than an unsafe + // incremental resume. + let mutations: [(String, (inout CostUsageFileUsage) -> Void)] = [ + ("watermark", { $0.lastRawTotalsWatermark = nil }), + ("interleaved flag", { $0.hasInterleavedTotals = nil }), + ] + + for (label, mutate) in mutations { + try env.jsonl([sessionMeta, turnContext] + initialEvents) + .write(to: fileURL, atomically: true, encoding: .utf8) + options.forceRescan = true + let baseline = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(baseline.data.first?.totalTokens == 100_000, "baseline failed for \(label)") + options.forceRescan = false + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + for (path, usage) in cache.files { + var stripped = usage + mutate(&stripped) + cache.files[path] = stripped + } + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + try env.jsonl([sessionMeta, turnContext] + initialEvents + [replayedSnapshot]) + .write(to: fileURL, atomically: true, encoding: .utf8) + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(second.data.first?.totalTokens == 100_000, "failed for missing \(label)") + + let healed = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let usage = healed.files + .first { URL(fileURLWithPath: $0.key).lastPathComponent == fileURL.lastPathComponent }? + .value + #expect(usage?.lastRawTotalsWatermark != nil, "healed watermark missing after \(label)") + #expect(usage?.hasInterleavedTotals == true, "healed interleaved flag missing after \(label)") + } + } + + @Test + func `codex missing optional seen set keeps incremental resume safe`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 12) + let iso0 = env.isoString(for: day) + let model = "openai/gpt-5.5" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "timestamp": iso0, + "payload": ["session_id": "optional-seen-set"], + ] + let turnContext = self.codexTurnContext(timestamp: iso0, model: model) + let initialEvents: [[String: Any]] = [ + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 5000, cached: 0, output: 0), + last: (input: 5000, cached: 0, output: 0)), + ] + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([sessionMeta, turnContext] + initialEvents)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(first.data.first?.totalTokens == 100_000) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let path = try #require(cache.files.keys.first { + URL(fileURLWithPath: $0).lastPathComponent == fileURL.lastPathComponent + }) + var usage = try #require(cache.files[path]) + let parsedBytesBeforeAppend = usage.parsedBytes ?? usage.size + #expect(usage.hasInterleavedTotals == true) + #expect(usage.lastRawTotalsWatermark != nil) + // Optional precision only: stripping the seen-set must not block incremental resume. + usage.seenRawTotals = nil + cache.files[path] = usage + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let appendedEvents: [[String: Any]] = [ + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(4)), + model: model, + total: (input: 101_000, cached: 0, output: 0), + last: (input: 1000, cached: 0, output: 0)), + ] + try env.jsonl([sessionMeta, turnContext] + initialEvents + appendedEvents) + .write(to: fileURL, atomically: true, encoding: .utf8) + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(second.data.first?.totalTokens == 101_000) + + let after = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let afterUsage = try #require(after.files[path]) + #expect(afterUsage.hasInterleavedTotals == true) + #expect(afterUsage.lastRawTotalsWatermark?.input == 101_000) + #expect((afterUsage.parsedBytes ?? afterUsage.size) > parsedBytesBeforeAppend) + } + + @Test + func `codex divergent cache entry without watermark forces full rescan`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 12) + let iso0 = env.isoString(for: day) + let model = "openai/gpt-5.5" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "timestamp": iso0, + "payload": ["session_id": "legacy-divergent"], + ] + let turnContext = self.codexTurnContext(timestamp: iso0, model: model) + let initialEvents: [[String: Any]] = [ + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 5000, cached: 0, output: 0), + last: (input: 5000, cached: 0, output: 0)), + ] + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([sessionMeta, turnContext] + initialEvents)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(first.data.first?.totalTokens == 100_000) + + // Simulate a cache entry written before the interleave tracker existed: divergent totals + // but no watermark. Resuming incrementally from it would be unsafe. + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + for (path, usage) in cache.files { + var stripped = usage + stripped.lastRawTotalsWatermark = nil + stripped.seenRawTotals = nil + stripped.hasInterleavedTotals = nil + cache.files[path] = stripped + } + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let replayedSnapshot = self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)) + try env.jsonl([sessionMeta, turnContext] + initialEvents + [replayedSnapshot]) + .write(to: fileURL, atomically: true, encoding: .utf8) + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(second.data.first?.totalTokens == 100_000) + + let healed = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let usage = healed.files.first { URL(fileURLWithPath: $0.key).lastPathComponent == fileURL.lastPathComponent }? + .value + #expect(usage?.lastRawTotalsWatermark != nil) + #expect(usage?.hasInterleavedTotals == true) + } + @Test func `codex daily report includes archived sessions and dedupes`() throws { let env = try CostUsageTestEnvironment() diff --git a/docs/issue-2037-ultra-fork-overcount-spec.md b/docs/issue-2037-ultra-fork-overcount-spec.md new file mode 100644 index 0000000000..c8531db906 --- /dev/null +++ b/docs/issue-2037-ultra-fork-overcount-spec.md @@ -0,0 +1,246 @@ +# Spec: Contain Ultra-mode interleaved-lineage token overcounting (issue #2037) + +- **Issue:** [steipete/CodexBar#2037](https://github.com/steipete/CodexBar/issues/2037) — "Ultra-mode Terra and Sol sessions can overcount forked context" +- **Status:** Implemented (rev 4 — Phase 1 post-latch containment + min-cap) +- **Affected code:** `Sources/CodexBarCore/Vendored/CostUsage/` (Codex session scanner) +- **Related prior fixes:** #968 (divergent totals), #1062 (repeated total snapshots), commit `45b68c34` (fork replay) + +## 0. Framing (read first) + +This spec deliberately ships in two phases: + +- **Phase 1 (this PR): containment.** Stop the multiplicative blowup and guarantee a *never-inflates* property. In multi-lineage files the result is an explicitly **conservative estimate** — it can undercount genuine totals-only sub-agent usage. We do not claim "true per-lineage usage." +- **Phase 2 (follow-up, fixture-gated): per-lineage accounting.** Candidate-baseline run tracking to recover undercounted totals-only lineages. Blocked on obtaining a sanitized real Ultra fixture (§8), because its correctness depends on empirical `last_token_usage` semantics that cannot be asserted from first principles. + +## 1. Problem + +CodexBar massively overstates usage for `gpt-5.6-terra` and especially `gpt-5.6-sol` when they run in Ultra mode. In one reported Sol session, the raw log reached roughly **268M** cumulative input tokens while CodexBar attributed **3.29B** input tokens and about $4,000 of standard cost. A single forked turn contributed more than 3B tokens across hundreds of rows. Pricing tables are correct; the inflation comes from usage accounting. + +Ultra sessions fork multiple sub-agents that: + +1. write **interleaved cumulative token snapshots** (`total_token_usage`) into the *same* session JSONL file, and +2. **replay large portions of the parent context** into sub-agent turns. + +## 2. Definitions + +**Lineage:** one monotonic cumulative-counter sequence (`total_token_usage`) produced by one agent/sub-agent. Ultra files interleave several lineages with no reliable lineage identifier on token events (`turn_id` cannot be used: cumulative counters span turns in normal sessions). + +**Replay vs. genuine context — two different things this spec must not conflate:** + +- *Copied cumulative history:* a child counter initialized with (or re-emitting) the parent's accumulated totals. Counting this again is double counting. Must always be excluded. +- *Context actually sent in a new sub-agent request:* the replayed parent context is transmitted as input tokens of a real API call, typically billed at the **cached-input** rate. This may be genuine usage. + +Which of these `last_token_usage` represents on a sub-agent's first turn in Ultra logs is an **empirical question** (§8). Phase 1 takes the conservative-for-inflation stance: after latch, `last` is capped by the contained totals delta (so below-watermark `last` is dropped). Phase 2 revisits this against the fixture. + +## 3. Root cause + +`CostUsageScanner` assumes **one monotonic cumulative counter lineage per session file**. `handleTokenCount` (in `parseCodexFileCancellable`, `CostUsageScanner.swift`) keeps a single `rawTotalsBaseline` and computes totals-derived deltas as `max(0, current − baseline)` via `codexTotalDelta`. + +With two interleaved lineages A and B in one file the event stream looks like: + +``` +A: total=100M → delta 100M, baseline=100M +B: total=5M → clamped to 0 (divergent fallback), baseline=5M ← baseline lowered +A: total=101M → delta = 101M − 5M = 96M ← gap recounted +B: total=6M → clamped to 0, baseline=6M +A: total=102M → delta = 102M − 6M = 96M ← gap recounted again +... +``` + +Every lineage flip recounts nearly the entire gap between the two counters. Hundreds of interleaved snapshots inflate ~268M real tokens into billions. + +Exposure by path: + +- **Resolved fork child path** (`handleTokenCount`, the `forkedFromId != nil` branch) runs totals-only — it deliberately ignores `last` to avoid replayed per-turn snapshots (`45b68c34`). Worst offender; matches "a single forked turn contributed 3B+". +- **Total-only events** (no `last_token_usage`) hit the same gap-recount mechanism. +- **Root sessions with `last` present** degrade differently: the divergent flag disables the #1062 guard (`codexShouldPreferTotalDelta`), so re-emitted snapshots can re-count `last`. + +Existing mitigations do not cover this failure mode: + +| Mitigation | Covers | Gap | +|---|---|---| +| #968 divergent totals (`codexDivergentTotalDelta`) | Counter *decreases* within one lineage → fall back to counted baseline | Lowers the effective baseline, so the *next* event of the larger lineage recounts the gap | +| #1062 (`codexShouldPreferTotalDelta`) | Repeated identical total snapshots re-adding `last` | Only active in the non-divergent path, and only compares adjacent totals; interleaving disables it and defeats adjacency (see §5.2) | +| Fork handling (`forked_from_id`, `CodexInheritedTotalsResolver`) | Separate child *files* replaying parent history | No concept of multiple lineages interleaved *inside one file* | + +## 4. Goals / non-goals + +**Goals (Phase 1)** + +- **Never-inflates invariant:** for any input, tokens attributed from totals-derived deltas never exceed the maximum raw cumulative total observed in the file (fork-inheritance adjusted). No sequence of interleaved or re-emitted snapshots can multiply usage. +- Exact re-emissions of previously seen snapshots contribute zero — including **alternating** re-emissions across lineages (this is where rev 1 of this spec was broken; see §5.2). +- Fork-replayed parent history stays excluded (existing behavior preserved). +- Single-lineage files behave exactly as today; all existing #968/#1062/fork tests pass unchanged. +- Incremental (append-only) scans resume with all correctness-critical reducer state and produce byte-identical results to a full rescan; the optional seen-snapshot FIFO may be absent without affecting containment. +- Multi-lineage results are explicitly documented as a conservative estimate (undercount bias), not exact usage. + +**Explicitly accepted Phase 1 limitation** + +A smaller lineage growing beneath another lineage's watermark (e.g. 5M → 50M under a 100M watermark) contributes nothing in Phase 1, even when it supplies `last_token_usage`: the contained totals delta is zero, so `min(last, 0) = 0`. This is **normal Ultra behavior, not a rare edge case**, and it is the deliberate trade: undercounting bounded genuine usage beats multiplying it. Phase 2 (§7) exists to recover it. + +**Non-goals** + +- Per-sub-agent usage attribution/breakdown UI. +- Changing pricing, priority/Ultra cost splitting (`CostUsageScanner+CodexPriority.swift`), or non-Codex providers. +- Phase 2 candidate-run tracking (specified in outline only; separate PR). + +## 5. Phase 1 design + +All post-latch token-count accounting uses the shared tracker and delta helpers (§5.5), with correctness-critical state persisted for incremental scans (§5.6). Rules below are the shared policy, in precedence order. + +### 5.1 High-watermark containment (load-bearing) + +Track `rawTotalsWatermark`: the component-wise maximum of every raw cumulative total observed (after fork-inheritance adjustment). + +- **Interleaving detection:** an event whose total has **any component strictly below** the corresponding watermark component latches `sawInterleavedTotals` for the file (persisted, permanent). Mixed movement (input ↓ while output ↑) cannot come from one monotonic counter, so "any component" is deliberate. Legitimate single-lineage resets (compaction, restart, corrupt log) also latch the flag; that is accepted — it converts a potential overcount into an undercount. +- **Never lower** the watermark or the baseline on detection. +- Once latched, totals-derived deltas use `codexContainedTotalDelta` (§5.3.1), not a lowerable per-event baseline. Lineage flips cannot re-count the high/low gap. + +**Supersedes divergent mode:** once `sawInterleavedTotals` is latched, `codexDivergentTotalDelta` (whose counted-baseline fallback *is* the gap-recount mechanism) and `codexShouldPreferTotalDelta` are not consulted for this file. `sawDivergentTotals` continues to work unchanged for never-interleaved files and for fork-parent snapshot resolution. + +### 5.2 Seen-snapshot suppression (optional precision) + +Maintain `seenRawTotals`: a **bounded FIFO** (~64) of raw cumulative totals for best-effort exact re-emission suppression. + +- Exact matches can short-circuit to zero before delta math. +- After post-latch containment (§5.3), eviction cannot inflate usage: a re-emitted below-watermark total has contained delta 0, so `min(last, 0) = 0`. +- Therefore the seen set is **not** load-bearing for correctness and must not gate incremental resume. + +### 5.3 Counting rule in interleaved mode + +For each token-count event once `sawInterleavedTotals` is latched and a `total` is present: + +1. Optionally, total exactly matches `seenRawTotals` → count **0** (precision optimization; not required for correctness). +2. Compute the **contained totals delta** component-wise (§5.3.1). +3. If `last_token_usage` is present → `delta = min(adjustedLastDelta(last), containedTotalDelta)`. +4. Otherwise → `delta = containedTotalDelta`. + +`last` alone must never increase counted usage when the contained totals delta is zero. Smaller-lineage genuine `last` below the watermark is an accepted Phase 1 undercount. + +#### 5.3.1 Contained totals delta (not plain watermark delta) + +Do **not** use plain `codexTotalDelta(from: watermark, …)` after latch — that breaks #968 “resume from counted baseline” (growth below the old raw watermark that still exceeds counted totals). + +Use a dedicated helper, component-wise: + +``` +if current >= watermark { + delta = max(0, current - max(watermark, counted)) +} else { + delta = max(0, current - counted) +} +``` + +This preserves counted-baseline recovery without allowing high/low lineage gaps to be recounted. Bound after latch: + +`counted ≤ max(counted_when_latched, subsequent_watermark)` (and for totals-only streams, `counted ≤ max observed cumulative total`). + +### 5.4 Fork children + +- **Non-interleaved** resolved forks keep totals-only accounting (`45b68c34` / #1164). Do not apply a global `min(last, total)` cap there — established tests require totals-derived deltas that can exceed `last`. +- **After latch**, resolved forks use the same post-latch rule as root sessions (§5.3), including `min(adjustedLast, containedTotalDelta)`. +- **Unresolved forks** keep skip-first + `min(last, totalDelta)`; `unresolvedForkTotalWatermark` is a presence sentinel while the global tracker supplies the delta baseline. + +### 5.5 Shared policy surface + +`CodexTotalsTracker` (watermark + optional seen-set + latch) is shared. Post-latch delta policy (`codexContainedTotalDelta` / `codexPostLatchEventDelta`) must be applied by all three consumers: + +1. root / non-fork parsing (`handleTokenCount`) +2. resolved-fork parsing (`handleTokenCount`) +3. parent snapshot accumulation (`CodexSnapshotAccumulator`) + +Otherwise fork children can inherit baselines computed under a different policy. + +### 5.6 Cache: persist correctness-critical state + +`CostUsageFileUsage` gains: + +- `lastRawTotalsWatermark: CostUsageCodexTotals?` (**required** for interleaved / divergent resume) +- `hasInterleavedTotals: Bool?` (**required** when watermark is present; partial XOR → full rescan) +- `seenRawTotals: [CostUsageCodexTotals]?` (**optional** precision only — missing must not force rescan) + +**Invalidation:** regenerate `CodexParserHash`; clear `compatibleCodexProducerKeys`. Legacy divergent entries without a watermark force a per-file full rescan. + +## 6. Acceptance criteria + +1. **Exact expectations (primary):** fixtures assert exact counted totals / row counts for each rule branch. +2. **Never-inflates property (merge bar):** generated totals-only interleaved sequences satisfy `counted ≤ max observed cumulative total`. +3. **Floor:** fixtures assert a conservative minimum so a degenerate ~0 parser fails. +4. **Manual / real Ultra log:** no multiplied-gap inflation; satisfies the formal containment bound; above the fixture's conservative floor; plausible relative to the raw log — **not** required to land “near” 268M, because Phase 1 intentionally drops smaller-lineage usage. + +## 7. Phase 2 outline (separate PR, fixture-gated) + +Recover totals-only / below-watermark smaller-lineage usage with candidate-baseline run tracking, gated on a sanitized real Ultra fixture that establishes `last_token_usage` semantics. + +## 8. Prerequisite: sanitized real Ultra fixture + +Needed before claiming accurate multi-lineage recovery (Phase 2). Phase 1 ships on synthetic fixtures plus the containment property. + +## 9. Touched files + +| File | Change | +|---|---| +| `CostUsageScanner.swift` | `codexContainedTotalDelta` / `codexPostLatchEventDelta`; tracker + accumulator; post-latch policy in all three consumers | +| `CostUsageScanner+CacheHelpers.swift` | Persist watermark + interleaved flag; seen-set optional; incomplete-state rescan | +| `CostUsageCache.swift` | New fields; clear compatible producer keys | +| `CodexParserHash.generated.swift` | Regenerated | +| `CostUsageScannerBreakdownTests.swift` | Containment / cap / eviction / cache / property tests | +| `docs/issue-2037-ultra-fork-overcount-spec.md` | This spec | + +## 10. Test plan (merge bar) + +1. `codex interleaved cumulative lineages do not recount the gap` +2. `codex alternating repeated snapshots count zero` — containment (not FIFO) keeps repeats at zero +3. `codex totals only growth below watermark is conservatively dropped` +4. `codex single lineage counter reset undercounts but never inflates` — preserves #968-style recovery past the peak +5. `codex interleaved fork child caps last by contained total delta` +6. `codex root interleaved caps last much larger than watermark delta` +7. `codex fork interleaved caps last much larger than watermark delta` +8. `codex interleaved replay after sixty five unique snapshots stays contained` +9. `codex interleaved totals only sequences stay within containment bound` (property) +10. `codex incremental append preserves interleave containment across boundary` — full state equality vs forceRescan +11. `codex missing watermark or interleaved flag forces full rescan` +12. `codex missing optional seen set keeps incremental resume safe` +13. `codex divergent cache entry without watermark forces full rescan` +14. Existing `#968` / `#1062` / fork replay tests unchanged + +Regression: `make check`, focused scanner tests, then `make test`. + +--- + +# PR documentation (draft body — Phase 1) + +## Title + +Contain Ultra-mode interleaved-lineage token overcounting (#2037) + +## Summary + +- Ultra-mode Terra/Sol sessions can interleave cumulative `total_token_usage` snapshots from multiple lineages in one JSONL file. A single file-global baseline then recounts the high/low gap on every flip — turning ~268M real input tokens into ~3.29B (~$4,000) in a reported session. +- Phase 1 makes inflation **provably bounded**: a never-lower watermark latches interleaved mode on any component drop; post-latch deltas use a dedicated containment helper (preserving #968 counted-baseline recovery) and `min(adjustedLast, containedTotalDelta)` so `last` cannot grow usage when the contained totals delta is zero. +- **Single-lineage and pre-latch fork behavior is preserved** (including #1164 totals-only fork replay handling). The slow JSON path and fork-parent snapshot builder share the same policy. +- Cache persists watermark + interleaved flag (`seenRawTotals` is optional precision only); incomplete critical state forces a full rescan. Parser hash invalidation rebuilds old caches once. +- **Smaller-lineage undercount is intentional** and deferred to fixture-gated Phase 2. Latched files are a conservative estimate, not claimed true per-lineage usage. + +Fixes #2037. Related: #968, #1062, `45b68c34` (fork replay). + +## Behavior changes + +- Interleaved (Ultra) files: no multiplied-gap inflation; after latch, counted totals stay within the containment bound and `last` cannot exceed the contained totals delta. +- Single-lineage / never-latched files: unchanged counting semantics. +- Pre-latch resolved forks: still totals-only (replay-safe). +- Post-latch: below-watermark smaller-lineage usage may be dropped until Phase 2. + +## Test plan + +- [x] Focused `CostUsageScannerBreakdownTests` / `CostUsageCacheTests` (containment, caps, eviction, property bound, cache gates) +- [x] Existing #968 / #1062 / fork replay tests +- [x] `make check` +- [ ] `make test` +- [ ] Optional follow-up: rescan a real Sol/Ultra log for plausibility (not a merge blocker) + +## Notes for reviewers + +- Design is lineage-ID-free; value-based multi-run recovery is Phase 2 behind a sanitized Ultra fixture. +- `seenRawTotals` is **not** load-bearing after the post-latch min-cap; missing it must not force a rescan. +- A real Ultra log is valuable validation, not required to merge Phase 1 containment.