diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift index a4caf94e..90cec251 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift @@ -70,22 +70,40 @@ nonisolated struct AgentSessionResolution: Sendable { let isFresh: Bool } -/// Parsed, normalized transcript fragments reused across resolver polls. +/// Parsed, normalized transcript fragments reused across resolver polls and panes. /// /// Fingerprint matching re-reads the same transcript tails every time a pane's /// session cache expires (5 s while a session stays resolved). The bytes are /// almost always identical and the tails sit in the page cache, so the cost is /// re-parsing JSON and re-normalizing text rather than I/O. Keying the parsed /// result on the file's modification time replays it for unchanged transcripts -/// without altering which candidate wins. +/// without altering which candidate wins. Entry-count and retained-payload limits +/// bound process churn and candidate-set churn independently of process cleanup. nonisolated struct TranscriptFragmentCache: Sendable { struct Key: Hashable, Sendable { let path: String let modifiedAt: Date } - private var entries: [Key: [String]] = [:] - private var consulted: Set = [] + private struct Entry: Sendable { + let fragments: [String] + let retainedUTF8Bytes: Int + var lastAccess: UInt64 + } + + private var entries: [Key: Entry] = [:] + private var retainedUTF8Bytes = 0 + private var accessCounter: UInt64 = 0 + private let maxEntryCount: Int + private let maxRetainedUTF8Bytes: Int + + init( + maxEntryCount: Int = 128, + maxRetainedUTF8Bytes: Int = 8 * 1_024 * 1_024 + ) { + self.maxEntryCount = max(0, maxEntryCount) + self.maxRetainedUTF8Bytes = max(0, maxRetainedUTF8Bytes) + } var count: Int { entries.count } @@ -93,20 +111,49 @@ nonisolated struct TranscriptFragmentCache: Sendable { /// `load()`. A nil `load()` is deliberately not cached: an unreadable tail is /// transient, and caching the failure would keep a recovered file excluded. mutating func fragments(for key: Key, load: () -> [String]?) -> [String]? { - consulted.insert(key) - if let cached = entries[key] { return cached } + accessCounter &+= 1 + if var cached = entries[key] { + cached.lastAccess = accessCounter + entries[key] = cached + return cached.fragments + } + // Only the newest observed version of a path can be useful. Removing older + // modification-time keys immediately avoids spending the shared budget on + // append history until LRU pressure happens to arrive. Do this even when + // the new version is temporarily unreadable: an old key cannot answer a + // request for the new file contents. + for superseded in entries.keys.filter({ $0.path == key.path }) { + remove(superseded) + } guard let loaded = load() else { return nil } - entries[key] = loaded + + let byteCount = + key.path.utf8.count + + loaded.reduce(into: 0) { count, fragment in + count += fragment.utf8.count + } + guard maxEntryCount > 0, byteCount <= maxRetainedUTF8Bytes else { return loaded } + entries[key] = Entry( + fragments: loaded, + retainedUTF8Bytes: byteCount, + lastAccess: accessCounter + ) + retainedUTF8Bytes += byteCount + evictIfNeeded() return loaded } - /// Drops every entry not consulted since the previous prune: superseded - /// versions of an appended transcript, and files that left the candidate set. - /// Because the key carries a modification date, an actively written transcript - /// mints a new entry per poll; without this the cache would grow without bound. - mutating func pruneUnconsulted() { - entries = entries.filter { consulted.contains($0.key) } - consulted.removeAll(keepingCapacity: true) + private mutating func evictIfNeeded() { + while entries.count > maxEntryCount || retainedUTF8Bytes > maxRetainedUTF8Bytes { + guard let leastRecentlyUsed = entries.min(by: { $0.value.lastAccess < $1.value.lastAccess })?.key + else { return } + remove(leastRecentlyUsed) + } + } + + private mutating func remove(_ key: Key) { + guard let removed = entries.removeValue(forKey: key) else { return } + retainedUTF8Bytes -= removed.retainedUTF8Bytes } } @@ -186,9 +233,10 @@ actor AgentSessionResolver { } private var cache: [CacheKey: CachedResult] = [:] - /// Per-pane transcript fragment reuse, kept beside `cache` so both are evicted - /// on the same liveness check. - private var fragmentCaches: [CacheKey: TranscriptFragmentCache] = [:] + /// Transcript parsing depends only on file identity, not on the process doing + /// the match. Sharing one bounded cache avoids retaining duplicate 128 KiB + /// tails for every pane that consults the same candidate set. + private var fragmentCache = TranscriptFragmentCache() private let fileManager: FileManager private let homeDirectory: URL @@ -226,7 +274,6 @@ actor AgentSessionResolver { } } - var fragments = fragmentCaches[key] ?? TranscriptFragmentCache() let (resolved, usedWideScan) = resolveUncached( ResolveRequest( identified: identified, @@ -236,10 +283,8 @@ actor AgentSessionResolver { configRoot: configRoot, now: now ), - fragments: &fragments + fragments: &fragmentCache ) - fragments.pruneUnconsulted() - fragmentCaches[key] = fragments var session = resolved var provisionalID: String? if let candidate = session, candidate.confidence == .medium { @@ -265,7 +310,6 @@ actor AgentSessionResolver { cache = cache.filter { entry in ProcessDetection.processStartDate(pid: entry.key.pid) == entry.key.startedAt } - fragmentCaches = fragmentCaches.filter { cache[$0.key] != nil } } return AgentSessionResolution(session: session, isFresh: true) } diff --git a/supacodeTests/AgentSessionFingerprintNormalizeTests.swift b/supacodeTests/AgentSessionFingerprintNormalizeTests.swift index c8ee1462..4406389f 100644 --- a/supacodeTests/AgentSessionFingerprintNormalizeTests.swift +++ b/supacodeTests/AgentSessionFingerprintNormalizeTests.swift @@ -91,6 +91,20 @@ struct AgentSessionFingerprintNormalizeTests { } } + @Test func fastPathMatchesReferenceForEveryASCIIByteAndPair() { + let scalars = (UInt8.min...UInt8.max).prefix(128).map { String(Unicode.Scalar($0)) } + for first in scalars { + #expect(AgentSessionFingerprintMatcher.normalize(first) == Self.pristine(first)) + for second in scalars { + let input = first + second + #expect( + AgentSessionFingerprintMatcher.normalize(input) == Self.pristine(input), + "normalize diverged for \(String(reflecting: input))" + ) + } + } + } + @Test func normalizesToExpectedText() { #expect(AgentSessionFingerprintMatcher.normalize(" \u{001B}[1;31mHello\t\tWORLD ") == "hello world") #expect(AgentSessionFingerprintMatcher.normalize("") == "") diff --git a/supacodeTests/TranscriptFragmentCacheTests.swift b/supacodeTests/TranscriptFragmentCacheTests.swift index 28dc3f73..7c95dea1 100644 --- a/supacodeTests/TranscriptFragmentCacheTests.swift +++ b/supacodeTests/TranscriptFragmentCacheTests.swift @@ -68,30 +68,86 @@ struct TranscriptFragmentCacheTests { #expect(recovered == ["now readable"]) } - @Test func pruneDropsOnlyEntriesNotConsultedSinceTheLastPrune() { + @Test func replacesAnOlderVersionOfTheSamePathImmediately() { var cache = TranscriptFragmentCache() - let stable = key("/tmp/stable.jsonl", 100) - let superseded = key("/tmp/busy.jsonl", 100) - _ = cache.fragments(for: stable) { ["stable"] } - _ = cache.fragments(for: superseded) { ["v1"] } - #expect(cache.count == 2) - - cache.pruneUnconsulted() - #expect(cache.count == 2, "Both were consulted in this round, so both survive") - - // Next round: the busy transcript is appended to, so its old key is never - // consulted again and must not accumulate. - _ = cache.fragments(for: stable) { ["stable"] } + _ = cache.fragments(for: key("/tmp/busy.jsonl", 100)) { ["v1"] } _ = cache.fragments(for: key("/tmp/busy.jsonl", 101)) { ["v2"] } - cache.pruneUnconsulted() - #expect(cache.count == 2) + #expect(cache.count == 1) + } + + @Test func dropsAnOlderVersionWhenLoadingTheNewVersionFails() { + var cache = TranscriptFragmentCache() + _ = cache.fragments(for: key("/tmp/busy.jsonl", 100)) { ["v1"] } + + #expect(cache.fragments(for: key("/tmp/busy.jsonl", 101)) { nil } == nil) + #expect(cache.count == 0) + } + + @Test func evictsTheLeastRecentlyUsedEntryAtCapacity() { + var cache = TranscriptFragmentCache(maxEntryCount: 2, maxRetainedUTF8Bytes: .max) + let first = key("/tmp/first.jsonl", 100) + let second = key("/tmp/second.jsonl", 100) + let third = key("/tmp/third.jsonl", 100) + _ = cache.fragments(for: first) { ["first"] } + _ = cache.fragments(for: second) { ["second"] } + _ = cache.fragments(for: first) { ["not reached"] } + _ = cache.fragments(for: third) { ["third"] } + + var firstReloads = 0 + _ = cache.fragments(for: first) { + firstReloads += 1 + return ["first reloaded"] + } + var secondReloads = 0 + _ = cache.fragments(for: second) { + secondReloads += 1 + return ["second reloaded"] + } + + #expect(firstReloads == 0, "A cache hit must make the entry most recently used") + #expect(secondReloads == 1, "The least recently used entry must be evicted first") + } + + @Test func doesNotRetainAnEntryLargerThanTheByteBudget() { + var cache = TranscriptFragmentCache(maxEntryCount: 10, maxRetainedUTF8Bytes: 3) + let target = key("/tmp/large.jsonl", 100) var loads = 0 - _ = cache.fragments(for: superseded) { - loads += 1 - return ["v1 again"] + + for _ in 0..<2 { + _ = cache.fragments(for: target) { + loads += 1 + return ["four"] + } + } + + #expect(loads == 2) + #expect(cache.count == 0) + } + + @Test func byteBudgetEvictsTheLeastRecentlyUsedEntry() { + var cache = TranscriptFragmentCache(maxEntryCount: 10, maxRetainedUTF8Bytes: 10) + let first = key("a", 100) + let second = key("b", 100) + let third = key("c", 100) + _ = cache.fragments(for: first) { ["1234"] } + _ = cache.fragments(for: second) { ["1234"] } + _ = cache.fragments(for: first) { ["not reached"] } + _ = cache.fragments(for: third) { ["1234"] } + + var firstReloads = 0 + _ = cache.fragments(for: first) { + firstReloads += 1 + return ["1234"] } - #expect(loads == 1, "The superseded entry was evicted, so it must reload") + var secondReloads = 0 + _ = cache.fragments(for: second) { + secondReloads += 1 + return ["1234"] + } + + #expect(firstReloads == 0) + #expect(secondReloads == 1) } @Test func bestMatchServesASecondCallFromTheCache() throws {