From f9aebeb47f5ac1fbf22bd034292561d13ada91bc Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Wed, 22 Jul 2026 23:09:26 +0800 Subject: [PATCH 1/2] Fix compact Codex fork attribution Separate raw cumulative fork-boundary proof from owned usage subtraction and parent invalidation. Selectively migrate legacy parent-dependent cache rows, including known-model artifacts and later last-only snapshots. --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 8 +- .../CostUsageScanner+CacheHelpers.swift | 31 ++- .../Vendored/CostUsage/CostUsageScanner.swift | 106 ++++++++-- .../CodexForkAttributionMigrationTests.swift | 191 ++++++++++++++++++ .../CostUsageScannerBreakdownTests.swift | 141 +++++++++++++ 6 files changed, 462 insertions(+), 17 deletions(-) create mode 100644 Tests/CodexBarTests/CodexForkAttributionMigrationTests.swift diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index d46bf4b5b2..79e2d57ae4 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 = "48ac20dad61e9a7f" + static let value = "88425e27ddb94f22" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 7323a07c43..1776d3f9d7 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -4,7 +4,9 @@ enum CostUsageCacheIO { /// 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 = [] + /// The previous producer is structurally compatible. The scanner selectively reparses only + /// parent-dependent forked files via `codexForkAttributionVersion`. + private static let compatibleCodexProducerKeys: Set = ["codex:cu:p48ac20dad61e9a7f"] /// Parsing and attribution changes rotate the Codex parser producer key. /// Increment this artifact version only when the stored schema or cache layout becomes incompatible. @@ -114,6 +116,8 @@ struct CostUsageCache: Codable { var codexPricingKey: String? var codexPriorityMetadataKey: String? var codexProjectMetadataVersion: Int? + /// Optional migration marker; absent caches must inspect parent-dependent fork candidates. + var codexForkAttributionVersion: Int? var codexPriorityTurnKeys: [String: String]? var codexPriorityTurnIDsByDay: [String: [String]]? @@ -144,6 +148,8 @@ struct CostUsageFileUsage: Codable { var sessionId: String? var forkedFromId: String? var forkBaselineDependencyKey: String? + /// Set after this file has passed the fork-attribution parser; nil requires the dependency-key check. + var codexForkAttributionVersion: Int? 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 8bdbeec25d..0a3ec8db6d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -8,6 +8,14 @@ import Darwin #endif extension CostUsageScanner { + /// #2285 persisted this key for every compact parent candidate, including known-model rows. + /// Only the sentinel proves the file never depended on parent context. + static func isLegacyForkAttributionCandidate(_ usage: CostUsageFileUsage) -> Bool { + usage.forkedFromId != nil + && usage.codexForkAttributionVersion != codexForkAttributionVersion + && usage.forkBaselineDependencyKey != codexForkDependencyNotRequiredKey + } + private final class CodexModelsDevCatalogResolver { private var catalog: ModelsDevCatalog? private let cacheRoot: URL? @@ -899,6 +907,11 @@ extension CostUsageScanner { else { return false } guard !Self.cachedCodexFileNeedsPriorityRescan(cached, context: context) else { return false } + if context.needsForkAttributionMigration, + Self.isLegacyForkAttributionCandidate(cached) + { + return false + } let sessionAlreadyContributed = cached.sessionId.map { state.contributingSessionIds.contains($0) } ?? false let cachedRows = cached.codexRows ?? [] @@ -1131,7 +1144,13 @@ extension CostUsageScanner { if let cached = input.cached { self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1) } - let migratedCached = input.cached.map { Self.codexFileUsageWithCostCache($0, context: context) } + // A legacy parent-dependent fork file cannot carry unreparsed days across this migration: the + // current request may not cover its suspect day. Drop its retained projection and stamp + // the file current only from fresh source rows. + let hasLegacyForkCandidate = input.cached.map { Self.isLegacyForkAttributionCandidate($0) } ?? false + let migratedCached = hasLegacyForkCandidate + ? nil + : input.cached.map { Self.codexFileUsageWithCostCache($0, context: context) } var usageDays = context.dropDeferredCodexRows ? [:] : Self.fileDaysOutsideScanWindow(migratedCached?.days ?? [:], range: context.range) @@ -1140,6 +1159,7 @@ extension CostUsageScanner { fileURL: input.fileURL, range: context.range, inheritedTotalsResolver: context.resources.inheritedResolver.inheritedTotals(for:atOrBefore:), + inheritedRawTotalsResolver: context.resources.inheritedResolver.rawTotals(for:atOrBefore:), checkCancellation: context.checkCancellation) let forkBaselineDependencyKey = Self.codexForkBaselineDependencyKey( parentSessionId: parsed.forkedFromId, @@ -1237,6 +1257,7 @@ extension CostUsageScanner { codexRows: context.dropDeferredCodexRows ? nil : Self.mergeCodexRows(migratedCached?.codexRows, rows: uniqueRows, sessionId: sessionId)) + cache.files[input.metadata.path]?.codexForkAttributionVersion = Self.codexForkAttributionVersion Self.applyFileDays(cache: &cache, fileDays: cache.files[input.metadata.path]?.days ?? [:], sign: 1) Self.rememberScannedCodexFile( input: input, @@ -1367,7 +1388,13 @@ extension CostUsageScanner { catalog: modelsDevCatalog, cacheRoot: modelsDevCacheRoot) var reportCache = cache - for (path, usage) in cache.files where self.needsCodexCostCache(usage, range: range) { + // A compatible p48 cache may hydrate before migration completes. Do not present a + // parent-dependent candidate; current files and sentinel-owned forks remain visible. + for (path, usage) in cache.files where Self.isLegacyForkAttributionCandidate(usage) { + Self.applyFileDays(cache: &reportCache, fileDays: usage.days, sign: -1) + reportCache.files.removeValue(forKey: path) + } + for (path, usage) in reportCache.files where self.needsCodexCostCache(usage, range: range) { reportCache.files[path] = self.codexFileUsageWithCostCache( usage, range: range, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 7ff583f33d..c9fa158e0e 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -9,6 +9,7 @@ import Foundation // swiftlint:disable type_body_length file_length enum CostUsageScanner { static let codexProjectMetadataVersion = 1 + static let codexForkAttributionVersion = 1 typealias CancellationCheck = () throws -> Void static let log = CodexBarLog.logger(LogCategories.tokenCost) @@ -406,6 +407,7 @@ enum CostUsageScanner { let dropDeferredCodexRows: Bool let requiresTurnIDCache: Bool let changedPriorityTurnIDs: Set + let needsForkAttributionMigration: Bool let resources: CodexScanResources let checkCancellation: CancellationCheck? } @@ -517,6 +519,7 @@ enum CostUsageScanner { let priorityMetadataChanged: Bool let priorityTurnsChanged: Bool let needsTurnIDCacheMigration: Bool + let needsForkAttributionMigration: Bool let changedPriorityTurnIDs: Set let shouldRefresh: Bool } @@ -611,10 +614,12 @@ enum CostUsageScanner { } } + /// Reads each parent once and exposes every fork-boundary fact from the same stable snapshot. final class CodexInheritedTotalsResolver { private struct SnapshotResolution { let dependencyKey: String? - let snapshots: [CodexTimestampedTotals]? + let ownedSnapshots: [CodexTimestampedTotals]? + let rawSnapshots: [CodexTimestampedTotals]? } private let fileIndex: CodexSessionFileIndex @@ -639,7 +644,7 @@ enum CostUsageScanner { "Codex cost usage could not parse fork timestamp; falling back to lexical comparison", metadata: ["sessionId": sessionId, "timestamp": cutoffTimestamp]) } - guard let snapshots = try self.snapshotResolution(for: sessionId).snapshots else { return .unresolved } + guard let snapshots = try self.snapshotResolution(for: sessionId).ownedSnapshots else { return .unresolved } var inherited: CostUsageCodexTotals? for snapshot in snapshots { let isAtOrBefore: Bool = if let snapshotDate = snapshot.date, let cutoffDate { @@ -654,6 +659,18 @@ enum CostUsageScanner { return .resolved(inherited) } + /// Exact raw equality is the only proof that a compact child prefix is copied parent + /// history. Owned/deduplicated totals are intentionally not used for this decision. + func rawTotals(for sessionId: String, atOrBefore cutoffTimestamp: String) throws -> CodexForkBaseline { + guard !cutoffTimestamp.isEmpty, + let snapshots = try self.snapshotResolution(for: sessionId).rawSnapshots + else { return .unresolved } + guard let totals = Self.lastTotals(in: snapshots, atOrBefore: cutoffTimestamp) else { + return .unresolved + } + return .resolved(totals) + } + func currentDependencyKey(for sessionId: String) throws -> String { guard let fileURL = try self.fileIndex.fileURL(for: sessionId) else { return "missing:\(sessionId)" @@ -688,7 +705,8 @@ enum CostUsageScanner { metadata: ["sessionId": sessionId]) let resolution = SnapshotResolution( dependencyKey: "missing:\(sessionId)", - snapshots: nil) + ownedSnapshots: nil, + rawSnapshots: nil) self.snapshotResolutions[sessionId] = resolution return resolution } @@ -707,7 +725,8 @@ enum CostUsageScanner { metadata: ["sessionId": sessionId, "path": fileURL.path]) let resolution = SnapshotResolution( dependencyKey: dependencyKeyAfterParse, - snapshots: nil) + ownedSnapshots: nil, + rawSnapshots: nil) self.snapshotResolutions[sessionId] = resolution return resolution } @@ -721,13 +740,15 @@ enum CostUsageScanner { ]) let resolution = SnapshotResolution( dependencyKey: dependencyKeyAfterParse, - snapshots: nil) + ownedSnapshots: nil, + rawSnapshots: nil) self.snapshotResolutions[sessionId] = resolution return resolution } let resolution = SnapshotResolution( dependencyKey: dependencyKeyAfterParse, - snapshots: parsed.snapshots) + ownedSnapshots: parsed.ownedSnapshots, + rawSnapshots: parsed.rawSnapshots) self.snapshotResolutions[sessionId] = resolution return resolution } @@ -735,10 +756,32 @@ enum CostUsageScanner { CostUsageScanner.log.warning( "Codex cost usage parent session changed while reading; deferring inherited baseline", metadata: ["sessionId": sessionId, "path": fileURL.path]) - let resolution = SnapshotResolution(dependencyKey: nil, snapshots: nil) + let resolution = SnapshotResolution( + dependencyKey: nil, + ownedSnapshots: nil, + rawSnapshots: nil) self.snapshotResolutions[sessionId] = resolution return resolution } + + private static func lastTotals( + in snapshots: [CodexTimestampedTotals], + atOrBefore cutoffTimestamp: String) -> CostUsageCodexTotals? + { + let cutoffDate = CostUsageScanner.dateFromTimestamp(cutoffTimestamp) + var result: CostUsageCodexTotals? + for snapshot in snapshots { + let isAtOrBefore: Bool = if let date = snapshot.date, let cutoffDate { + date <= cutoffDate + } else { + snapshot.timestamp <= cutoffTimestamp + } + if isAtOrBefore { + result = snapshot.totals + } + } + return result + } } struct ClaudeParseResult { @@ -1860,11 +1903,13 @@ enum CostUsageScanner { fileURL: URL, checkCancellation: CancellationCheck? = nil) throws -> ( sessionId: String?, - snapshots: [CodexTimestampedTotals]) + ownedSnapshots: [CodexTimestampedTotals], + rawSnapshots: [CodexTimestampedTotals]) { var sessionId: String? var accumulator = CodexSnapshotAccumulator() - var snapshots: [CodexTimestampedTotals] = [] + var ownedSnapshots: [CodexTimestampedTotals] = [] + var rawSnapshots: [CodexTimestampedTotals] = [] var warnedAboutUnparsedTimestamp = false func parsedSnapshotDate(timestamp: String) -> Date? { @@ -1882,10 +1927,16 @@ enum CostUsageScanner { func appendSnapshot(timestamp: String, last: CostUsageCodexTotals?, total: CostUsageCodexTotals?) { guard last != nil || total != nil else { return } let counted = accumulator.apply(last: last, total: total) - snapshots.append(CodexTimestampedTotals( + ownedSnapshots.append(CodexTimestampedTotals( timestamp: timestamp, date: parsedSnapshotDate(timestamp: timestamp), totals: counted)) + if let raw = total { + rawSnapshots.append(CodexTimestampedTotals( + timestamp: timestamp, + date: parsedSnapshotDate(timestamp: timestamp), + totals: raw)) + } } do { @@ -1963,7 +2014,7 @@ enum CostUsageScanner { metadata: ["path": fileURL.path, "error": error.localizedDescription]) } - return (sessionId, snapshots) + return (sessionId, ownedSnapshots, rawSnapshots) } static func parseCodexFile( @@ -2028,6 +2079,7 @@ enum CostUsageScanner { initialCodexTurnID: String? = nil, initialCodexUsageRowIndex: Int = 0, inheritedTotalsResolver: ((String, String) throws -> CodexForkBaseline)? = nil, + inheritedRawTotalsResolver: ((String, String) throws -> CodexForkBaseline)? = nil, checkCancellation: CancellationCheck? = nil) throws -> CodexParseResult { var currentModel = initialModel @@ -2616,9 +2668,11 @@ enum CostUsageScanner { let parentSessionID = forkedFromId { candidateBoundaryDependsOnParentTotals = true - if let inheritedTotalsResolver { - switch try inheritedTotalsResolver(parentSessionID, forkTimestamp ?? "") { + if let inheritedRawTotalsResolver { + switch try inheritedRawTotalsResolver(parentSessionID, forkTimestamp ?? "") { case let .resolved(parentTotals): + // Candidate baseline and parent totals are both raw cumulative values. + // Do not accept a deduplicated/owned equality as copied-prefix proof. if Self.codexTotalsEqual(parentTotals, candidate.parentTotalsAtBoundary) { subagentCounterSemantics = .copiedPrefix ownedSuffix = candidate.ownedSuffix @@ -2752,6 +2806,7 @@ enum CostUsageScanner { let windowExpanded = Self.requestedWindowExpandsCache(range: range, cache: cache) let needsCostCacheMigration = cache.files.values.contains { Self.needsCodexCostCache($0, range: range) } let needsProjectMetadataMigration = cache.codexProjectMetadataVersion != Self.codexProjectMetadataVersion + let needsForkAttributionMigration = cache.files.values.contains { Self.isLegacyForkAttributionCandidate($0) } let modelsDevLoad = ModelsDevCache.load(now: now, cacheRoot: options.cacheRoot) let modelsDevCatalog = modelsDevLoad.artifact?.catalog let codexPricingKey = Self.codexPricingKey(modelsDevArtifact: modelsDevLoad.artifact) @@ -2771,6 +2826,7 @@ enum CostUsageScanner { || rootsChanged || needsCostCacheMigration || needsProjectMetadataMigration + || needsForkAttributionMigration || needsTurnIDCacheMigration || pricingChanged || priorityMetadataChanged @@ -2802,6 +2858,7 @@ enum CostUsageScanner { || rootsChanged || needsCostCacheMigration || needsProjectMetadataMigration + || needsForkAttributionMigration || needsTurnIDCacheMigration || pricingChanged || priorityMetadataChanged @@ -2829,10 +2886,12 @@ enum CostUsageScanner { priorityMetadataChanged: priorityMetadataChanged, priorityTurnsChanged: priorityTurnsChanged, needsTurnIDCacheMigration: needsTurnIDCacheMigration, + needsForkAttributionMigration: needsForkAttributionMigration, changedPriorityTurnIDs: changedPriorityTurnIDs, shouldRefresh: shouldRefresh) } + // swiftlint:disable:next function_body_length private static func loadCodexDaily( range: CostUsageDayRange, now: Date, @@ -2848,6 +2907,21 @@ enum CostUsageScanner { if options.forceRescan { cache = CostUsageCache() } + if plan.needsForkAttributionMigration { + // A retained legacy suspect outside this request cannot be certified without a + // full source read. Remove it now; a later wider request reparses it from disk. + let stalePaths = cache.files.compactMap { path, usage in + Self.isLegacyForkAttributionCandidate(usage) + && !usage.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + ? path + : nil + } + for path in stalePaths { + guard let usage = cache.files[path] else { continue } + Self.applyFileDays(cache: &cache, fileDays: usage.days, sign: -1) + cache.files.removeValue(forKey: path) + } + } let cachedSinceKey = cache.scanSinceKey let cachedUntilKey = cache.scanUntilKey @@ -2959,6 +3033,7 @@ enum CostUsageScanner { let shouldRetainWiderWindow = !options.forceRescan && !plan.pricingChanged && !plan .priorityMetadataChanged && !plan.needsTurnIDCacheMigration && !plan.needsProjectMetadataMigration + && !plan.needsForkAttributionMigration let retainedSinceKey = shouldRetainWiderWindow ? [cachedSinceKey, range.scanSinceKey].compactMap(\.self).min() ?? range.scanSinceKey : range.scanSinceKey @@ -2987,6 +3062,10 @@ enum CostUsageScanner { retainedUntilKey: retainedUntilKey) } cache.lastScanUnixMs = nowMs + cache.codexForkAttributionVersion = cache.files.values + .contains { Self.isLegacyForkAttributionCandidate($0) } + ? nil + : Self.codexForkAttributionVersion try checkCancellation?() CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: options.cacheRoot) } @@ -3014,6 +3093,7 @@ enum CostUsageScanner { || plan.needsTurnIDCacheMigration, requiresTurnIDCache: plan.needsTurnIDCacheMigration, changedPriorityTurnIDs: plan.changedPriorityTurnIDs, + needsForkAttributionMigration: plan.needsForkAttributionMigration, resources: resources, checkCancellation: checkCancellation) } diff --git a/Tests/CodexBarTests/CodexForkAttributionMigrationTests.swift b/Tests/CodexBarTests/CodexForkAttributionMigrationTests.swift new file mode 100644 index 0000000000..e2c8287757 --- /dev/null +++ b/Tests/CodexBarTests/CodexForkAttributionMigrationTests.swift @@ -0,0 +1,191 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexForkAttributionMigrationTests { + private func options(_ env: CostUsageTestEnvironment) -> CostUsageScanner.Options { + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + return options + } + + private func writeSession( + _ env: CostUsageTestEnvironment, + day: Date, + events: [(Date, Int)], + model: String? = nil) throws + { + let lines: [[String: Any]] = [[ + "type": "session_meta", + "timestamp": env.isoString(for: day), + "payload": ["id": "migration-session"], + ]] + events.map { timestamp, input in + var info: [String: Any] = [ + "last_token_usage": ["input_tokens": input, "cached_input_tokens": 0, "output_tokens": 0], + ] + if let model { + info["model"] = model + } + return [ + "type": "event_msg", + "timestamp": env.isoString(for: timestamp), + "payload": [ + "type": "token_count", + "info": info, + ], + ] + } + _ = try env.writeCodexSessionFile(day: day, filename: "migration.jsonl", contents: env.jsonl(lines)) + } + + private func markLegacyForkCandidate(_ env: CostUsageTestEnvironment) { + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + for path in cache.files.keys { + cache.files[path]?.forkedFromId = "missing-parent" + cache.files[path]?.forkBaselineDependencyKey = "missing-parent|legacy-raw-boundary" + cache.files[path]?.codexForkAttributionVersion = nil + } + cache.codexForkAttributionVersion = nil + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: env.cacheRoot, + producerKey: "codex:cu:p48ac20dad61e9a7f") + } + + @Test + func `public cached snapshot quarantines known model legacy fork candidate`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + try self.writeSession(env, day: day, events: [(day, 408_650_005)], model: "gpt-5.6-sol") + let options = self.options(env) + _ = CostUsageScanner.loadDailyReport(provider: .codex, since: day, until: day, now: day, options: options) + self.markLegacyForkCandidate(env) + + let legacy = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + #expect(legacy.producerKey == "codex:cu:p48ac20dad61e9a7f") + #expect(legacy.files.values.contains { CostUsageScanner.isLegacyForkAttributionCandidate($0) }) + #expect(legacy.files.values + .contains { $0.forkBaselineDependencyKey != CostUsageScanner.codexForkDependencyNotRequiredKey }) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, historyDays: 1, scannerOptions: options) + #expect(cached == nil) + + let migrated = CostUsageScanner.loadDailyReport( + provider: .codex, since: day, until: day, now: day.addingTimeInterval(1), options: options) + #expect(migrated.data.first?.modelBreakdowns?.contains { + $0.modelName == "gpt-5.6-sol" && $0.totalTokens == 408_650_005 + } == true) + let refreshed = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + #expect(refreshed.files.values.allSatisfy { + $0.codexForkAttributionVersion == CostUsageScanner.codexForkAttributionVersion + }) + } + + @Test + func `public cached snapshot preserves current legitimate unknown sentinel fork`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + try self.writeSession(env, day: day, events: [(day, 42)]) + let options = self.options(env) + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + for path in cache.files.keys { + cache.files[path]?.forkedFromId = "current-fork" + cache.files[path]?.forkBaselineDependencyKey = CostUsageScanner.codexForkDependencyNotRequiredKey + cache.files[path]?.codexForkAttributionVersion = CostUsageScanner.codexForkAttributionVersion + } + cache.codexForkAttributionVersion = CostUsageScanner.codexForkAttributionVersion + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options) + #expect(first.data.first?.totalTokens == 42) + #expect(cached?.last30DaysTokens == 42) + #expect(cached?.daily.first?.modelBreakdowns?.contains { + CostUsagePricing.isCodexUnattributedModel($0.modelName) && $0.totalTokens == 42 + } == true) + } + + @Test + func `legacy migration reparses touched file and drops unreparsed older day`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let older = try env.makeLocalNoon(year: 2026, month: 5, day: 16) + let current = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + try self.writeSession(env, day: current, events: [(older, 10), (current, 20)]) + let options = self.options(env) + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: older, + until: current, + now: current, + options: options) + self.markLegacyForkCandidate(env) + let migrated = CostUsageScanner.loadDailyReport( + provider: .codex, + since: current, + until: current, + now: current.addingTimeInterval(1), + options: options) + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let oldKey = CostUsageScanner.CostUsageDayRange.dayKey(from: older) + #expect(migrated.data.first?.totalTokens == 20) + #expect(cache.files.values.allSatisfy { $0.days[oldKey] == nil }) + #expect(cache.scanSinceKey == CostUsageScanner.CostUsageDayRange(since: current, until: current).scanSinceKey) + #expect(cache.codexForkAttributionVersion == CostUsageScanner.codexForkAttributionVersion) + #expect(cache.files.values.allSatisfy { + $0.codexForkAttributionVersion == CostUsageScanner.codexForkAttributionVersion + }) + } + + @Test + func `out of window legacy suspect is removed and later expansion reparses source`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let older = try env.makeLocalNoon(year: 2026, month: 5, day: 16) + let current = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + try self.writeSession(env, day: older, events: [(older, 33)]) + let options = self.options(env) + _ = CostUsageScanner.loadDailyReport(provider: .codex, since: older, until: older, now: older, options: options) + self.markLegacyForkCandidate(env) + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: current, + until: current, + now: current, + options: options) + let narrowed = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let oldKey = CostUsageScanner.CostUsageDayRange.dayKey(from: older) + #expect(narrowed.files.values.allSatisfy { $0.days[oldKey] == nil }) + #expect(narrowed.scanSinceKey == CostUsageScanner.CostUsageDayRange(since: current, until: current) + .scanSinceKey) + #expect(narrowed.codexForkAttributionVersion == CostUsageScanner.codexForkAttributionVersion) + + let expanded = CostUsageScanner.loadDailyReport( + provider: .codex, + since: older, + until: current, + now: current.addingTimeInterval(1), + options: options) + #expect(expanded.data.first?.totalTokens == 33) + let expandedCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + #expect(expandedCache.files.count == 1) + #expect(expandedCache.files.values.allSatisfy { + $0.codexForkAttributionVersion == CostUsageScanner.codexForkAttributionVersion + }) + } +} diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index dd40efcbf7..730502cda2 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -1428,6 +1428,147 @@ struct CostUsageScannerBreakdownTests { #expect(parsed.days[dayKey]?["gpt-5"] == nil) } + @Test + func `codex exact raw parent prefix with known model is suppressed instead of repriced`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 5, day: 17) + let childDay = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let parentID = "parent-exact-raw-prefix" + let childID = "child-exact-raw-prefix" + let copied: Usage = (input: 407_555_823, cached: 399_890_176, output: 1_094_182) + let parentURL = try env.writeCodexSessionFile( + day: parentDay, + filename: "parent-exact-raw-prefix.jsonl", + contents: env.jsonl([ + ["type": "session_meta", "timestamp": env.isoString(for: parentDay), "payload": ["id": parentID]], + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: "gpt-5.6-sol"), + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(1)), + model: "gpt-5.6-sol", + total: copied, + last: (input: 127_520, cached: 125_696, output: 57)), + // A later last-only status event must not erase the preceding raw cumulative + // snapshot that the compact child copied at its fork boundary. + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(2)), + model: "gpt-5.6-sol", + last: (input: 127_520, cached: 125_696, output: 57)), + ])) + _ = try env.writeCodexSessionFile( + day: childDay, + filename: "child-exact-raw-prefix.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": env.isoString(for: childDay), + "payload": ["id": childID, "forked_from_id": parentID, "source": "subagent"], + ], + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(1)), + model: "gpt-5.6-sol", + total: copied), + self.codexTurnContext( + timestamp: env.isoString(for: childDay.addingTimeInterval(2)), + model: "gpt-5.6-sol"), + [ + "type": "inter_agent_communication_metadata", + "timestamp": env.isoString(for: childDay.addingTimeInterval(3)), + "payload": ["trigger_turn": true], + ], + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(4)), + model: "gpt-5.6-sol", + total: copied), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + let resolver = CostUsageScanner.CodexInheritedTotalsResolver( + fileIndex: CostUsageScanner.CodexSessionFileIndex(files: [parentURL], roots: []), + checkCancellation: nil) + let cutoff = env.isoString(for: childDay) + let owned = try resolver.inheritedTotals(for: parentID, atOrBefore: cutoff) + let raw = try resolver.rawTotals(for: parentID, atOrBefore: cutoff) + if case let .resolved(ownedTotals) = owned, case let .resolved(rawTotals) = raw { + #expect(ownedTotals != rawTotals) + } else { + Issue.record("Expected parent owned and raw totals") + } + let report = CostUsageScanner.loadDailyReport( + provider: .codex, since: childDay, until: childDay, now: childDay, options: options) + + #expect(report.data.isEmpty) + + // Recreate the p48 artifact: the child recorded the copied prefix under a known model, + // even though its raw cumulative total exactly equals the parent's fork boundary. + var legacy = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let childPath = try #require(legacy.files.first { $0.value.sessionId == childID }?.key) + let childDayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: childDay) + let copiedRow = [copied.input, copied.cached, copied.output] + legacy.files[childPath]?.days[childDayKey] = ["gpt-5.6-sol": copiedRow] + legacy.files[childPath]?.forkedFromId = parentID + legacy.files[childPath]?.forkBaselineDependencyKey = "file|parent-exact-raw-prefix" + legacy.files[childPath]?.codexForkAttributionVersion = nil + legacy.days[childDayKey] = ["gpt-5.6-sol": copiedRow] + legacy.codexForkAttributionVersion = nil + CostUsageCacheIO.save( + provider: .codex, + cache: legacy, + cacheRoot: env.cacheRoot, + producerKey: "codex:cu:p48ac20dad61e9a7f") + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: childDay, historyDays: 1, scannerOptions: options) + #expect(cached == nil) + + let migrated = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay.addingTimeInterval(1), + options: options) + #expect(migrated.data.isEmpty) + let refreshed = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + #expect(refreshed.files.values.allSatisfy { + $0.codexForkAttributionVersion == CostUsageScanner.codexForkAttributionVersion + }) + } + + @Test + func `codex raw parent proof requires cumulative total usage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let parentID = "last-only-parent" + let parentURL = try env.writeCodexSessionFile( + day: day, + filename: "last-only-parent.jsonl", + contents: env.jsonl([ + ["type": "session_meta", "timestamp": env.isoString(for: day), "payload": ["id": parentID]], + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: "gpt-5.6-sol", + last: (input: 127_520, cached: 125_696, output: 57)), + ])) + let resolver = CostUsageScanner.CodexInheritedTotalsResolver( + fileIndex: CostUsageScanner.CodexSessionFileIndex(files: [parentURL], roots: []), + checkCancellation: nil) + + let raw = try resolver.rawTotals( + for: parentID, + atOrBefore: env.isoString(for: day.addingTimeInterval(2))) + if case .resolved = raw { + Issue.record("last_token_usage must not prove a raw cumulative fork boundary") + } + } + @Test func `codex turn context remains authoritative over conflicting token model`() throws { let env = try CostUsageTestEnvironment() From 766a8d836bc3fc34c6f0e4a6c2885e1693ee2374 Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Thu, 23 Jul 2026 11:22:41 +0800 Subject: [PATCH 2/2] Harden compact fork cache reuse Require complete file parses before fresh-cache reuse, centralize provenance-based presentation projection, and add growing-rollout plus mixed-breakdown regressions. --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 9 +- .../CostUsageScanner+CacheHelpers.swift | 18 +++- .../Vendored/CostUsage/CostUsageScanner.swift | 3 +- .../CodexCompactSubagentAccountingTests.swift | 96 +++++++++++++++++++ .../CodexCompactSubagentFixture.swift | 25 ++++- .../CodexForkAttributionMigrationTests.swift | 78 ++++++++++++++- 7 files changed, 210 insertions(+), 21 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 79e2d57ae4..23b398fb64 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 = "88425e27ddb94f22" + static let value = "60c110df71a146ea" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 1776d3f9d7..8b1c594a36 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -1,11 +1,10 @@ import Foundation enum CostUsageCacheIO { - /// 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. - /// The previous producer is structurally compatible. The scanner selectively reparses only - /// parent-dependent forked files via `codexForkAttributionVersion`. + /// Producer keys from older parser hashes whose caches remain structurally compatible. + /// #2037 invalidated every earlier producer because interleave containment changed cumulative + /// accounting. This immediate predecessor is safe to admit because the scanner selectively + /// reparses its parent-dependent forked files via `codexForkAttributionVersion`. private static let compatibleCodexProducerKeys: Set = ["codex:cu:p48ac20dad61e9a7f"] /// Parsing and attribution changes rotate the Codex parser producer key. diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 0a3ec8db6d..a7eb84ed9d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -16,6 +16,17 @@ extension CostUsageScanner { && usage.forkBaselineDependencyKey != codexForkDependencyNotRequiredKey } + /// Keep every cache-backed presentation surface on the same migration boundary. This is + /// intentionally provenance-based: a stale copied prefix may already carry a known model. + static func codexCacheForPresentation(_ cache: CostUsageCache) -> CostUsageCache { + var projected = cache + for (path, usage) in cache.files where Self.isLegacyForkAttributionCandidate(usage) { + Self.applyFileDays(cache: &projected, fileDays: usage.days, sign: -1) + projected.files.removeValue(forKey: path) + } + return projected + } + private final class CodexModelsDevCatalogResolver { private var catalog: ModelsDevCatalog? private let cacheRoot: URL? @@ -902,6 +913,7 @@ extension CostUsageScanner { let needsSessionId = cached.sessionId == nil guard cached.mtimeUnixMs == input.metadata.mtimeUnixMs, cached.size == input.metadata.size, + cached.parsedBytes.map({ $0 == cached.size }) == true, !needsSessionId, !context.forceFullScan else { return false } @@ -1387,13 +1399,9 @@ extension CostUsageScanner { let catalogResolver = CodexModelsDevCatalogResolver( catalog: modelsDevCatalog, cacheRoot: modelsDevCacheRoot) - var reportCache = cache // A compatible p48 cache may hydrate before migration completes. Do not present a // parent-dependent candidate; current files and sentinel-owned forks remain visible. - for (path, usage) in cache.files where Self.isLegacyForkAttributionCandidate(usage) { - Self.applyFileDays(cache: &reportCache, fileDays: usage.days, sign: -1) - reportCache.files.removeValue(forKey: path) - } + var reportCache = Self.codexCacheForPresentation(cache) for (path, usage) in reportCache.files where self.needsCodexCostCache(usage, range: range) { reportCache.files[path] = self.codexFileUsageWithCostCache( usage, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index c9fa158e0e..263b155faf 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -2806,7 +2806,8 @@ enum CostUsageScanner { let windowExpanded = Self.requestedWindowExpandsCache(range: range, cache: cache) let needsCostCacheMigration = cache.files.values.contains { Self.needsCodexCostCache($0, range: range) } let needsProjectMetadataMigration = cache.codexProjectMetadataVersion != Self.codexProjectMetadataVersion - let needsForkAttributionMigration = cache.files.values.contains { Self.isLegacyForkAttributionCandidate($0) } + let needsForkAttributionMigration = cache.codexForkAttributionVersion != Self.codexForkAttributionVersion + && cache.files.values.contains { Self.isLegacyForkAttributionCandidate($0) } let modelsDevLoad = ModelsDevCache.load(now: now, cacheRoot: options.cacheRoot) let modelsDevCatalog = modelsDevLoad.artifact?.catalog let codexPricingKey = Self.codexPricingKey(modelsDevArtifact: modelsDevLoad.artifact) diff --git a/Tests/CodexBarTests/CodexCompactSubagentAccountingTests.swift b/Tests/CodexBarTests/CodexCompactSubagentAccountingTests.swift index 3fa37a5bb9..2b2516d4ad 100644 --- a/Tests/CodexBarTests/CodexCompactSubagentAccountingTests.swift +++ b/Tests/CodexBarTests/CodexCompactSubagentAccountingTests.swift @@ -172,6 +172,102 @@ struct CodexCompactSubagentAccountingTests { #expect(afterChild.days.values.allSatisfy { $0[CostUsagePricing.codexUnattributedModel] == nil }) } + @Test + func `appended first turn marker reclassifies a cached compact child prefix`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 21) + let parentModel = "openai/gpt-5.4" + let leafModel = "openai/gpt-5.6-sol" + let prefix: Usage = (input: 407_555, cached: 399_890, output: 1094) + let parentOwned: Usage = (input: 127, cached: 125, output: 1) + let suffix: Usage = (input: 1725, cached: 1568, output: 6) + let fixture = Fixture.Child( + sessionID: "growing-compact-child", + parentID: "growing-compact-parent", + leafModel: leafModel, + prefix: prefix, + suffix: suffix, + preBoundaryLast: parentOwned) + + _ = try env.writeCodexSessionFile( + day: day, + filename: "rollout-0-growing-parent.jsonl", + contents: Fixture.parentContents( + env: env, + day: day, + sessionID: fixture.parentID, + model: parentModel, + totals: prefix, + lastTotals: parentOwned)) + let prefixContents = try Fixture.childPrefixContents(env: env, day: day, fixture: fixture) + let childURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-1-growing-child.jsonl", + contents: prefixContents) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + let provisional = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(provisional.data.first?.modelBreakdowns?.contains { + $0.modelName == CostUsagePricing.codexUnattributedModel + } == true) + + let completeContents = try prefixContents + + Fixture.childSuffixContents(env: env, day: day, fixture: fixture) + try completeContents.write(to: childURL, atomically: true, encoding: .utf8) + + // A failed full-file read can retain prefix rows while observing the complete file's + // metadata. `parsedBytes` must keep that cache from becoming permanently fresh. + var partialCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let partialEntry = try #require(partialCache.files.first { $0.value.sessionId == fixture.sessionID }) + var partialChild = partialEntry.value + let completeMetadata = CostUsageScanner.codexFileMetadata(fileURL: childURL) + let parsedPrefixBytes = try #require(partialChild.parsedBytes) + #expect(parsedPrefixBytes < completeMetadata.size) + #expect(partialChild.forkBaselineDependencyKey == CostUsageScanner.codexForkDependencyNotRequiredKey) + partialChild.mtimeUnixMs = completeMetadata.mtimeUnixMs + partialChild.size = completeMetadata.size + partialChild.parsedBytes = 0 + partialCache.files[partialEntry.key] = partialChild + CostUsageCacheIO.save(provider: .codex, cache: partialCache, cacheRoot: env.cacheRoot) + + let completed = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + let daily = try #require(completed.data.first) + #expect(daily.totalTokens == 1859) + #expect(!(daily.modelBreakdowns ?? []).contains { + $0.modelName == CostUsagePricing.codexUnattributedModel + }) + #expect(daily.modelBreakdowns?.first { + $0.modelName == CostUsagePricing.normalizeCodexModel(parentModel) + }?.totalTokens == 128) + #expect(daily.modelBreakdowns?.first { + $0.modelName == CostUsagePricing.normalizeCodexModel(leafModel) + }?.totalTokens == 1731) + let refreshedCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let refreshedChild = try #require(refreshedCache.files.values.first { + $0.sessionId == fixture.sessionID + }) + #expect(refreshedChild.codexForkAttributionVersion == CostUsageScanner.codexForkAttributionVersion) + #expect(refreshedChild.days.values.allSatisfy { + $0[CostUsagePricing.codexUnattributedModel] == nil + }) + } + @Test func `unconfirmed compact prefix stays independent and parent-dependent`() throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/CodexCompactSubagentFixture.swift b/Tests/CodexBarTests/CodexCompactSubagentFixture.swift index 85855038c3..d76be9ac5c 100644 --- a/Tests/CodexBarTests/CodexCompactSubagentFixture.swift +++ b/Tests/CodexBarTests/CodexCompactSubagentFixture.swift @@ -17,7 +17,8 @@ enum CodexCompactSubagentFixture { day: Date, sessionID: String, model: String, - totals: Usage) throws -> String + totals: Usage, + lastTotals: Usage? = nil) throws -> String { try env.jsonl([ [ @@ -32,7 +33,7 @@ enum CodexCompactSubagentFixture { timestamp: env.isoString(for: day.addingTimeInterval(-1)), model: model, total: totals, - last: totals), + last: lastTotals ?? totals), ]) } @@ -40,6 +41,15 @@ enum CodexCompactSubagentFixture { env: CostUsageTestEnvironment, day: Date, fixture: Child) throws -> String + { + try self.childPrefixContents(env: env, day: day, fixture: fixture) + + self.childSuffixContents(env: env, day: day, fixture: fixture) + } + + static func childPrefixContents( + env: CostUsageTestEnvironment, + day: Date, + fixture: Child) throws -> String { let forkTimestamp = env.isoString(for: day) var lines: [[String: Any]] = [ @@ -67,7 +77,15 @@ enum CodexCompactSubagentFixture { timestamp: env.isoString(for: day.addingTimeInterval(0.2)), last: preBoundaryLast)) } - lines.append(contentsOf: [ + return try env.jsonl(lines) + } + + static func childSuffixContents( + env: CostUsageTestEnvironment, + day: Date, + fixture: Child) throws -> String + { + try env.jsonl([ self.turnContext( timestamp: env.isoString(for: day.addingTimeInterval(1)), model: fixture.leafModel), @@ -84,7 +102,6 @@ enum CodexCompactSubagentFixture { output: fixture.prefix.output + fixture.suffix.output), last: fixture.suffix), ]) - return try env.jsonl(lines) } static func tokenCount( diff --git a/Tests/CodexBarTests/CodexForkAttributionMigrationTests.swift b/Tests/CodexBarTests/CodexForkAttributionMigrationTests.swift index e2c8287757..114895142e 100644 --- a/Tests/CodexBarTests/CodexForkAttributionMigrationTests.swift +++ b/Tests/CodexBarTests/CodexForkAttributionMigrationTests.swift @@ -17,12 +17,19 @@ struct CodexForkAttributionMigrationTests { _ env: CostUsageTestEnvironment, day: Date, events: [(Date, Int)], - model: String? = nil) throws + model: String? = nil, + sessionID: String = "migration-session", + filename: String = "migration.jsonl", + cwd: String? = nil) throws { + var metadata: [String: Any] = ["id": sessionID] + if let cwd { + metadata["cwd"] = cwd + } let lines: [[String: Any]] = [[ "type": "session_meta", "timestamp": env.isoString(for: day), - "payload": ["id": "migration-session"], + "payload": metadata, ]] + events.map { timestamp, input in var info: [String: Any] = [ "last_token_usage": ["input_tokens": input, "cached_input_tokens": 0, "output_tokens": 0], @@ -39,12 +46,18 @@ struct CodexForkAttributionMigrationTests { ], ] } - _ = try env.writeCodexSessionFile(day: day, filename: "migration.jsonl", contents: env.jsonl(lines)) + _ = try env.writeCodexSessionFile(day: day, filename: filename, contents: env.jsonl(lines)) } - private func markLegacyForkCandidate(_ env: CostUsageTestEnvironment) { + private func markLegacyForkCandidate( + _ env: CostUsageTestEnvironment, + sessionID: String? = nil) + { var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) - for path in cache.files.keys { + let candidatePaths = cache.files.compactMap { path, usage in + sessionID == nil || usage.sessionId == sessionID ? path : nil + } + for path in candidatePaths { cache.files[path]?.forkedFromId = "missing-parent" cache.files[path]?.forkBaselineDependencyKey = "missing-parent|legacy-raw-boundary" cache.files[path]?.codexForkAttributionVersion = nil @@ -88,6 +101,61 @@ struct CodexForkAttributionMigrationTests { }) } + @Test + func `cached snapshot quarantines legacy fork from daily projects and sessions`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let currentProject = env.root.appendingPathComponent("current-project", isDirectory: true) + let legacyProject = env.root.appendingPathComponent("legacy-project", isDirectory: true) + try FileManager.default.createDirectory(at: currentProject, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: legacyProject, withIntermediateDirectories: true) + try self.writeSession( + env, + day: day, + events: [(day, 42)], + model: "gpt-5.4", + sessionID: "current-session", + filename: "current.jsonl", + cwd: currentProject.path) + try self.writeSession( + env, + day: day, + events: [(day, 900)], + model: "gpt-5.6-sol", + sessionID: "legacy-session", + filename: "legacy.jsonl", + cwd: legacyProject.path) + let options = self.options(env) + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + self.markLegacyForkCandidate(env, sessionID: "legacy-session") + + let legacy = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let legacyUsage = try #require(legacy.files.values.first { $0.sessionId == "legacy-session" }) + let currentUsage = try #require(legacy.files.values.first { $0.sessionId == "current-session" }) + #expect(CostUsageScanner.isLegacyForkAttributionCandidate(legacyUsage)) + #expect(currentUsage.codexForkAttributionVersion == CostUsageScanner.codexForkAttributionVersion) + + let cached = try #require(await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options)) + #expect(cached.sessionTokens == 42) + #expect(cached.last30DaysTokens == 42) + #expect(cached.daily.count == 1) + #expect(cached.daily.first?.totalTokens == 42) + #expect(cached.sessions.map(\.sessionID) == ["current-session"]) + #expect(cached.sessions.first?.totalTokens == 42) + #expect(cached.projects.map(\.path) == [currentProject.path]) + #expect(cached.projects.first?.totalTokens == 42) + #expect(cached.projects.flatMap(\.sources).allSatisfy { $0.path != legacyProject.path }) + } + @Test func `public cached snapshot preserves current legitimate unknown sentinel fork`() async throws { let env = try CostUsageTestEnvironment()