diff --git a/CHANGELOG.md b/CHANGELOG.md index 178f4c6275d..9c212d0627d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Settings: edit saved token-account labels and credentials in place and reorder accounts without changing the active account (#4612). Thanks @lghsigma597! ### Fixed +- Pi: keep cache scope stable when the same session roots are discovered in a different order (#4618). Thanks @dhruva77! - Claude cost history: preserve the last valid cache when transcript roots or directories are unreadable (#4617). Thanks @dhruva77! - Codex: open the Usage Dashboard on the overview tab to avoid incorrect redirects (#4607). Thanks @Alex-Keyes! - Claude: try readable OAuth credentials before starting the CLI in Linux Auto mode, avoiding unnecessary interactive startup while preserving explicit source selection (#4613, #4615). Thanks @yeseoLee! diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index 2951ec36eed..ff52ba36ebc 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -579,6 +579,8 @@ enum PiSessionCostScanner { ].joined(separator: "\u{1F}") } } + // Discovery order follows working directories and process contexts; it is not part of the scope. + .sorted() .joined(separator: "\u{1E}") } @@ -1045,7 +1047,9 @@ enum PiSessionCostScanner { var seenEntries = seenEntriesBySessionID[sessionID] ?? [] for entryID in usage.entryUsages.keys.sorted() where seenEntries.insert(entryID).inserted { guard let entryUsage = usage.entryUsages[entryID] else { continue } - guard self.applyEntryUsage(daysByProvider: &days, entryUsage: entryUsage) else { return false } + guard self.applyContributions(daysByProvider: &days, contributions: [ + entryUsage.providerRawValue: [entryUsage.dayKey: [entryUsage.modelName: entryUsage.usage]], + ]) else { return false } } seenEntriesBySessionID[sessionID] = seenEntries } @@ -1053,18 +1057,6 @@ enum PiSessionCostScanner { return true } - private static func applyEntryUsage( - daysByProvider: inout [String: [String: [String: PiPackedUsage]]], - entryUsage: PiSessionEntryUsage) -> Bool - { - let contributions = [ - entryUsage.providerRawValue: [ - entryUsage.dayKey: [entryUsage.modelName: entryUsage.usage], - ], - ] - return self.applyContributions(daysByProvider: &daysByProvider, contributions: contributions) - } - private static func modelContext(from object: [String: Any]) -> PiModelContext? { guard let providerText = object["provider"] as? String else { return nil } guard let provider = self.mappedProvider(fromPiProvider: providerText) else { diff --git a/Tests/CodexBarTests/PiSessionProcessContextTests.swift b/Tests/CodexBarTests/PiSessionProcessContextTests.swift index cd33e50cc6c..3264c8700c8 100644 --- a/Tests/CodexBarTests/PiSessionProcessContextTests.swift +++ b/Tests/CodexBarTests/PiSessionProcessContextTests.swift @@ -838,3 +838,69 @@ struct PiSessionProcessContextTests { .contains(firstRoot.path) != true) } } + +extension PiSessionProcessContextTests { + @Test + func `pi cost scope ignores the discovery order of identical session roots`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let projects = try ["alpha", "beta", "gamma"].map { name in + let project = env.root.appendingPathComponent("pi-project-\(name)", isDirectory: true) + let sessionRoot = env.root.appendingPathComponent("pi-sessions-\(name)", isDirectory: true) + let settingsDirectory = project.appendingPathComponent(".pi", isDirectory: true) + try [sessionRoot, settingsDirectory].forEach { + try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true) + } + try Data(("{\"sessionDir\":\"" + sessionRoot.path + "\"}").utf8).write( + to: settingsDirectory.appendingPathComponent("settings.json"), + options: .atomic) + return project + } + func scope(_ workingDirectories: [URL]) -> String { + PiSessionCostScanner.scopeFingerprint(options: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + environment: ["HOME": env.root.path], + workingDirectories: workingDirectories)) + } + + let forward = scope([projects[0], projects[1]]) + #expect(forward.contains("pi-sessions-alpha")) + #expect(forward.contains("pi-sessions-beta")) + #expect(scope([projects[1], projects[0]]) == forward) + #expect(scope([projects[0], projects[2]]) != forward) + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 1) + for name in ["alpha", "beta"] { + let rows = try env.jsonl([[ + "type": "message", "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", "provider": "openai-codex", "model": "gpt-5.4", + "usage": ["input": 10, "output": 0], + ], + ]]) + try Data(rows.utf8).write(to: env.root.appendingPathComponent("pi-sessions-\(name)/session.jsonl")) + } + var options = PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + environment: ["HOME": env.root.path], + workingDirectories: [projects[0], projects[1]]) + let initial = PiSessionCostScanner.loadDailyReport( + provider: .pi, since: day, until: day, now: day, options: options) + #expect(initial.summary?.totalTokens == 20) + options.workingDirectories.reverse() + let cached = PiSessionCostScanner.loadCachedDailyReportResult( + provider: .pi, since: day, until: day, now: day, cacheRoot: env.cacheRoot, options: options) + #expect(cached?.report.summary?.totalTokens == 20) + #expect(cached?.isComplete == true) + + var legacy = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + legacy.sessionRootsFingerprint = legacy.sessionRootsFingerprint? + .components(separatedBy: "\u{1E}").reversed().joined(separator: "\u{1E}") + PiSessionCostCacheIO.save(cache: legacy, cacheRoot: env.cacheRoot) + let upgraded = PiSessionCostScanner.loadDailyReport( + provider: .pi, since: day, until: day, now: day, options: options) + #expect(upgraded.summary == initial.summary) + #expect(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot).sessionRootsFingerprint == forward) + } +} diff --git a/docs/pi.md b/docs/pi.md index bf55ae0b087..b46e51762d3 100644 --- a/docs/pi.md +++ b/docs/pi.md @@ -21,6 +21,8 @@ Default session roots include `~/.pi/agent/sessions` and the supported OMP agent Root canonicalization preserves an explicit directory marker even when the session directory does not exist yet. +Discovering the same roots in a different order does not change the cache scope. Older unsorted scope fingerprints are refreshed once while retaining the same source selection and totals. + Running Pi/OMP processes also contribute their environment, profile, `--session-dir`, and project settings. Relative paths resolve against that process's working directory. A missing working directory cannot turn an unresolved relative selector into a successful empty scan. Retained roots from explicit command-line or settings selectors survive process exit; settings are revalidated before reuse. Removing a setting from an accessible project drops its former root, while an inaccessible project or broken settings symlink preserves the previous scoped report and its original age. Live processes with an unreadable environment and no explicit command-line session/profile selector are skipped during cost-root discovery. A single summary diagnostic records the skipped count per discovery pass without logging arguments, paths, or environment values. Default/configured roots and other readable processes still contribute history, including on the first scan. This is best-effort discovery: a custom root visible only in an unreadable process environment cannot be discovered. Explicit selectors that cannot resolve still keep history incomplete; an absolute `--session-dir` remains usable without an environment or working directory.