Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Sources/CodexBar/UsageStore+CodexCostCatchUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ extension UsageStore {
context: context,
phase: status.pending ? .indexing : .complete)
var didAdvance = false
var previousActiveDuration: TimeInterval?
var (previousActiveDuration, seenProgressKeys): (TimeInterval?, Set<String>) = (nil, [status.progressKey])
while status.pending {
do {
guard self.codexCostCatchUpContextIsCurrent(context) else { return }
Expand Down Expand Up @@ -183,7 +183,7 @@ extension UsageStore {
pauseReason: .user)
return
}
if nextStatus.pending, nextStatus.progressKey == status.progressKey {
if nextStatus.pending, !seenProgressKeys.insert(nextStatus.progressKey).inserted {
self.publishCodexCostCatchUpActivity(
status: nextStatus,
context: context,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ extension UsageStore {

var didChangeCache = false
var previousActiveDuration: TimeInterval?
var stalledCacheIdentities: Set<String> = []
var (stalledCacheIdentities, seenKeysByCache) = (Set<String>(), statuses.mapValues { Set([$0.progressKey]) })
while Self.spendDashboardCodexCatchUpIsPending(statuses) {
do {
guard self.spendDashboardCodexCostCatchUpContextIsCurrent(context) else { return }
Expand Down Expand Up @@ -197,7 +197,7 @@ extension UsageStore {
didChangeCache = didChangeCache || nextStatus.progressKey != previousStatus?.progressKey
statuses[account.cacheIdentity] = nextStatus
if nextStatus.pending,
nextStatus.progressKey == previousStatus?.progressKey
!seenKeysByCache[account.cacheIdentity, default: []].insert(nextStatus.progressKey).inserted
{
stalledCacheIdentities.insert(account.cacheIdentity)
} else {
Expand Down
85 changes: 76 additions & 9 deletions Sources/CodexBarCore/CostUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -312,19 +312,19 @@ public struct CostUsageFetcher: Sendable {
}

let scoped = CostUsageScanner.codexCache(cache, scopedTo: roots)
var progressHasher = Hasher()
for (path, usage) in scoped.files.sorted(by: { $0.key < $1.key }) {
progressHasher.combine(path)
progressHasher.combine(usage.codexScanFileId)
progressHasher.combine(usage.parsedBytes)
progressHasher.combine(usage.size)
progressHasher.combine(usage.codexScanComplete)
}
// Bounded passes can advance work without changing parsed file bytes.
// Directory validation can move between partitions,
// active lookback can discover older sessions,
// and buffered fork retries can resolve a parent dependency.
// Include each semantic cursor so the stall detector sees real progress.
let progressKey = self.codexScanProgressKey(
cache: cache,
scopedFiles: scoped.files)
let hasIncompleteFile = scoped.files.values.contains { $0.codexScanComplete == false }
let pending = cache.codexScanCatchUpPending == true || hasIncompleteFile
return CodexScanCatchUpStatus(
pending: pending,
progressKey: "\(scoped.files.count):\(progressHasher.finalize())",
progressKey: progressKey,
processedBytes: cache.codexScanProcessedBytes ?? 0,
totalBytes: cache.codexScanTotalBytes ?? 0,
completedFiles: cache.codexScanCompletedFiles ?? 0,
Expand Down Expand Up @@ -1372,4 +1372,71 @@ extension CostUsageFetcher {
#endif
return nil
}

static func codexScanProgressKey(
cache: CostUsageCache,
scopedFiles: [String: CostUsageFileUsage]) -> String
{
var progressHasher = Hasher()
// The aggregate closes the remaining bounded-work gap for already indexed files that grew:
// each completed stale append advances the count, while a fully parsed active append leaves
// it unchanged.
progressHasher.combine(cache.codexScanCompletedFiles)
// Stable file membership tracks newly completed bounded work. Mutable byte counts belong
// only to unfinished files: a completed live session can append between every pass without
// advancing the finite backlog. Buffered retries track dependency state rather than counts,
// so appends cannot mask a parent dependency that remains unresolved.
for (path, usage) in scopedFiles.sorted(by: { $0.key < $1.key }) {
progressHasher.combine(path)
progressHasher.combine(usage.codexScanFileId)
progressHasher.combine(usage.codexScanComplete)
if usage.codexScanComplete == false {
progressHasher.combine(usage.parsedBytes)
progressHasher.combine(usage.size)
progressHasher.combine(usage.codexJSONLResumeState?.offset)
}
let hasBufferedRetry = usage.hasBufferedCodexForkRetryLines
progressHasher.combine(hasBufferedRetry)
if hasBufferedRetry {
progressHasher.combine(usage.forkedFromId)
progressHasher.combine(usage.forkBaselineDependencyKey)
progressHasher.combine(usage.codexBufferedSubagentLines?.isEmpty == false)
progressHasher.combine(usage.codexBufferedUnresolvedForkLines?.isEmpty == false)
}
}

if let discovery = cache.codexSessionDiscovery {
progressHasher.combine(discovery.generation)
progressHasher.combine(discovery.directoryPaths.count)
progressHasher.combine(discovery.nextDirectoryIndex)
progressHasher.combine(discovery.filePaths.count)
progressHasher.combine(discovery.nextFileIndex)
progressHasher.combine(discovery.headScan?.path)
progressHasher.combine(discovery.headScan?.offset)
progressHasher.combine(discovery.headScan?.resumeState?.offset)
progressHasher.combine(discovery.filePathBySessionId.count)
progressHasher.combine(discovery.missingSessionIds.sorted())
progressHasher.combine(discovery.pendingSessionIds.sorted())
progressHasher.combine(discovery.validationDirectoryIndex)
Comment thread
pavbar marked this conversation as resolved.
progressHasher.combine(discovery.isComplete)
} else {
progressHasher.combine("no-discovery")
}

if let lookback = cache.codexActiveLookbackState {
progressHasher.combine(lookback.scanSinceKey)
progressHasher.combine(lookback.rootPaths.sorted())
for (root, dayKey) in lookback.nextDayKeyByRoot.sorted(by: { $0.key < $1.key }) {
progressHasher.combine(root)
progressHasher.combine(dayKey)
}
progressHasher.combine(lookback.completedRootPaths.sorted())
progressHasher.combine(lookback.pendingFilePaths.sorted())
progressHasher.combine(lookback.legacyRecursivePendingRootPaths.sorted())
} else {
progressHasher.combine("no-lookback")
}

return "v2:\(scopedFiles.count):\(progressHasher.finalize())"
}
}
233 changes: 233 additions & 0 deletions Tests/CodexBarTests/CostUsageCatchUpProgressTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import Foundation
import Testing
@testable import CodexBarCore

struct CostUsageCatchUpProgressTests {
@Test
func `progress key includes semantic discovery cursor progress`() {
var cache = CostUsageCache()
cache.codexSessionDiscovery = CostUsageCodexSessionDiscovery(
roots: ["/sessions"],
generation: "generation-1",
directoryStamps: [:],
directoryPaths: ["/sessions/2026", "/sessions/2026/08"],
nextDirectoryIndex: 2,
filePaths: [],
nextFileIndex: 0,
fileStamps: [:],
headScan: nil,
filePathBySessionId: [:],
missingSessionIds: ["missing-parent"],
pendingSessionIds: [],
validationDirectoryIndex: 0,
isComplete: true)

let initial = CostUsageFetcher.codexScanProgressKey(cache: cache, scopedFiles: [:])
cache.codexSessionDiscovery?.validationDirectoryIndex = 1
let advanced = CostUsageFetcher.codexScanProgressKey(cache: cache, scopedFiles: [:])

#expect(advanced != initial)
}

@Test
func `progress tracks stable membership but ignores complete live appends`() {
let path = "/sessions/live.jsonl"
let complete = CostUsageScanner.makeFileUsage(
mtimeUnixMs: 1,
size: 100,
days: [:],
parsedBytes: 100,
codexScanFileId: "1:1",
codexScanComplete: true)
let empty = CostUsageFetcher.codexScanProgressKey(
cache: CostUsageCache(),
scopedFiles: [:])
let initial = CostUsageFetcher.codexScanProgressKey(
cache: CostUsageCache(),
scopedFiles: [path: complete])
var appended = complete
appended.size = 125
appended.parsedBytes = 125
let afterLiveAppend = CostUsageFetcher.codexScanProgressKey(
cache: CostUsageCache(),
scopedFiles: [path: appended])

#expect(initial != empty)
#expect(afterLiveAppend == initial)
}

@Test
func `progress tracks unfinished file bytes`() {
let path = "/sessions/unfinished.jsonl"
var unfinished = CostUsageScanner.makeFileUsage(
mtimeUnixMs: 1,
size: 125,
days: [:],
parsedBytes: 110,
codexScanFileId: "1:1",
codexScanComplete: false)
let unfinishedInitial = CostUsageFetcher.codexScanProgressKey(
cache: CostUsageCache(),
scopedFiles: [path: unfinished])
unfinished.parsedBytes = 120
let unfinishedAdvanced = CostUsageFetcher.codexScanProgressKey(
cache: CostUsageCache(),
scopedFiles: [path: unfinished])

#expect(unfinishedAdvanced != unfinishedInitial)
}

@Test
func `progress tracks completed aggregate for existing file backlog`() {
let path = "/sessions/existing.jsonl"
let usage = CostUsageScanner.makeFileUsage(
mtimeUnixMs: 1,
size: 125,
days: [:],
parsedBytes: 125,
codexScanFileId: "1:1",
codexScanComplete: true)
var before = CostUsageCache()
before.codexScanCompletedFiles = 0
var after = before
after.codexScanCompletedFiles = 1

#expect(CostUsageFetcher.codexScanProgressKey(cache: after, scopedFiles: [path: usage])
!= CostUsageFetcher.codexScanProgressKey(cache: before, scopedFiles: [path: usage]))
}

@Test
func `completed buffered appends do not hide a stalled dependency`() {
let path = "/sessions/fork.jsonl"
let line = CostUsageScanner.CodexBufferedFastLine(
lineIndex: 1,
ordinal: nil,
line: .interAgentCommunication(triggerTurn: false))
let buffered = CostUsageScanner.makeFileUsage(
mtimeUnixMs: 1,
size: 100,
days: [:],
parsedBytes: 100,
forkedFromId: "missing-parent",
codexScanFileId: "1:1",
codexScanComplete: true,
codexBufferedUnresolvedForkLines: [line])
let initial = CostUsageFetcher.codexScanProgressKey(
cache: CostUsageCache(),
scopedFiles: [path: buffered])

var appended = buffered
appended.size = 125
appended.parsedBytes = 125
appended.codexBufferedUnresolvedForkLines = [line, line]
let afterAppend = CostUsageFetcher.codexScanProgressKey(
cache: CostUsageCache(),
scopedFiles: [path: appended])

var dependencyResolved = appended
dependencyResolved.forkBaselineDependencyKey = "parent:resolved"
let afterDependencyChange = CostUsageFetcher.codexScanProgressKey(
cache: CostUsageCache(),
scopedFiles: [path: dependencyResolved])

var replayed = dependencyResolved
replayed.codexBufferedUnresolvedForkLines = nil
let afterReplay = CostUsageFetcher.codexScanProgressKey(
cache: CostUsageCache(),
scopedFiles: [path: replayed])

#expect(afterAppend == initial)
#expect(afterDependencyChange != afterAppend)
#expect(afterReplay != afterDependencyChange)
}

@Test
func `progress key includes resumable discovery head offset`() throws {
let env = try CostUsageTestEnvironment()
defer { env.cleanup() }
let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10)
let iso = env.isoString(for: day)
let body = #"{"type":"session_meta","timestamp":"\#(iso)","payload":{"session_id":"known-session","cwd":""#
+ String(repeating: "x", count: 512)
+ #""}}"#
+ "\n"
let fileURL = try env.writeCodexSessionFile(
day: day,
filename: "budgeted-head.jsonl",
contents: body)

let firstBudget = CostUsageScanner.CodexScanBudget(maxFileBytes: 32, maxBytesPerRefresh: 32)
let firstIndex = CostUsageScanner.CodexSessionFileIndex(
files: [fileURL],
roots: [env.codexSessionsRoot],
cachedDiscovery: nil,
scanBudget: firstBudget)
_ = try firstIndex.lookup(sessionId: "absent-session")
let firstDiscovery = firstIndex.persistedState

let secondBudget = CostUsageScanner.CodexScanBudget(maxFileBytes: 32, maxBytesPerRefresh: 32)
let secondIndex = CostUsageScanner.CodexSessionFileIndex(
files: [fileURL],
roots: [env.codexSessionsRoot],
cachedDiscovery: firstDiscovery,
scanBudget: secondBudget)
_ = try secondIndex.lookup(sessionId: "absent-session")
let secondDiscovery = secondIndex.persistedState

let firstCommittedOffset = try #require(firstDiscovery.headScan?.offset)
let secondCommittedOffset = try #require(secondDiscovery.headScan?.offset)
let firstResumeOffset = try #require(firstDiscovery.headScan?.resumeState?.offset)
let secondResumeOffset = try #require(secondDiscovery.headScan?.resumeState?.offset)
var firstCache = CostUsageCache()
firstCache.codexSessionDiscovery = firstDiscovery
var secondCache = CostUsageCache()
secondCache.codexSessionDiscovery = secondDiscovery

#expect(secondCommittedOffset == firstCommittedOffset)
#expect(secondResumeOffset > firstResumeOffset)
#expect(CostUsageFetcher.codexScanProgressKey(cache: secondCache, scopedFiles: [:])
!= CostUsageFetcher.codexScanProgressKey(cache: firstCache, scopedFiles: [:]))
}

@Test
func `progress key includes active lookback cursor and ignores dictionary insertion order`() {
var initialCache = CostUsageCache()
initialCache.codexActiveLookbackState = CostUsageCodexActiveLookbackState(
scanSinceKey: "2026-07-01",
rootPaths: ["/sessions", "/archived_sessions"],
nextDayKeyByRoot: [
"/sessions": "2026-07-02",
"/archived_sessions": "2026-07-03",
])
var advancedCache = initialCache
advancedCache.codexActiveLookbackState?.nextDayKeyByRoot["/sessions"] = "2026-07-01"

let first = CostUsageScanner.makeFileUsage(
mtimeUnixMs: 1,
size: 10,
days: [:],
parsedBytes: 10,
codexScanFileId: "1:1",
codexScanComplete: true)
let second = CostUsageScanner.makeFileUsage(
mtimeUnixMs: 1,
size: 20,
days: [:],
parsedBytes: 10,
codexScanFileId: "2:2",
codexScanComplete: false)
var forward: [String: CostUsageFileUsage] = [:]
forward["/sessions/a.jsonl"] = first
forward["/sessions/b.jsonl"] = second
var reverse: [String: CostUsageFileUsage] = [:]
reverse["/sessions/b.jsonl"] = second
reverse["/sessions/a.jsonl"] = first

let initial = CostUsageFetcher.codexScanProgressKey(cache: initialCache, scopedFiles: forward)
let advanced = CostUsageFetcher.codexScanProgressKey(cache: advancedCache, scopedFiles: forward)
let reordered = CostUsageFetcher.codexScanProgressKey(cache: initialCache, scopedFiles: reverse)

#expect(advanced != initial)
#expect(reordered == initial)
}
}
Loading
Loading