diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift index 90cec251..fbeae6aa 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift @@ -85,8 +85,34 @@ nonisolated struct TranscriptFragmentCache: Sendable { let modifiedAt: Date } + /// One normalized transcript fragment, with the two derived values scoring + /// needs. Both are precomputed because `String.count` walks graphemes and + /// `suffix` allocates โ€” costs that would otherwise be paid per fragment on + /// every match, for text that never changes. + struct Fragment: Sendable, Equatable { + let text: String + let characterCount: Int + /// The trailing 80 characters, present only when the fragment is longer + /// than that. A shorter fragment's suffix is the fragment itself, so + /// testing it would repeat the full-text test verbatim. + let suffix: String? + + init(text: String) { + self.text = text + let count = text.count + characterCount = count + suffix = count > 80 ? String(text.suffix(80)) : nil + } + + /// What this fragment costs the retention budget. `suffix` is a separate + /// allocation rather than a view into `text`, so it is counted too. + var retainedUTF8Bytes: Int { + text.utf8.count + (suffix?.utf8.count ?? 0) + } + } + private struct Entry: Sendable { - let fragments: [String] + let fragments: [Fragment] let retainedUTF8Bytes: Int var lastAccess: UInt64 } @@ -110,7 +136,7 @@ nonisolated struct TranscriptFragmentCache: Sendable { /// Returns the cached fragments for `key`, otherwise stores and returns /// `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]? { + mutating func fragments(for key: Key, load: () -> [Fragment]?) -> [Fragment]? { accessCounter &+= 1 if var cached = entries[key] { cached.lastAccess = accessCounter @@ -130,7 +156,7 @@ nonisolated struct TranscriptFragmentCache: Sendable { let byteCount = key.path.utf8.count + loaded.reduce(into: 0) { count, fragment in - count += fragment.utf8.count + count += fragment.retainedUTF8Bytes } guard maxEntryCount > 0, byteCount <= maxRetainedUTF8Bytes else { return loaded } entries[key] = Entry( @@ -232,11 +258,39 @@ actor AgentSessionResolver { return (session, nil) } + /// One directory walk, reusable by every pane that scans the same root. + /// + /// Panes resolve independently but overwhelmingly share roots: every agent in + /// one project enumerates that project's transcript directory. The walk is the + /// dominant filesystem cost and grows with the number of files on disk rather + /// than the number of panes, so repeating it per pane is pure duplication. + /// Files are stored unfiltered because callers apply their own + /// process-start threshold. + private struct RootScan { + let scannedAt: Date + /// `nil` when the walk exceeded its visit limit โ€” see `recentFiles`. + let files: [(url: URL, modifiedAt: Date)]? + } + + /// The visit limit is part of the key: a walk made under a looser limit may + /// have kept entries a stricter caller would have refused to trust, so it + /// cannot answer on that caller's behalf. + private struct RootScanKey: Hashable { + let root: URL + let visitLimit: Int + } + + /// How long one root's walk may be replayed. Short enough that a newly written + /// transcript is picked up well inside the resolver's own retry cadence, long + /// enough to collapse the burst of panes that resolve at nearly the same time. + private static let rootScanLifetime: TimeInterval = 2 + private var cache: [CacheKey: CachedResult] = [:] /// 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 var rootScans: [RootScanKey: RootScan] = [:] private let fileManager: FileManager private let homeDirectory: URL @@ -458,7 +512,8 @@ actor AgentSessionResolver { profile: profile, processStartedAt: processStartedAt, configRoot: configRoot, - visitLimit: visitLimit + visitLimit: visitLimit, + now: now ) else { // A truncated primary scan voids this whole round: the fallback tree is @@ -480,7 +535,8 @@ actor AgentSessionResolver { profile: profile, processStartedAt: processStartedAt, configRoot: configRoot, - visitLimit: visitLimit + visitLimit: visitLimit, + now: now ) return (fallback ?? [], true) } @@ -501,7 +557,8 @@ actor AgentSessionResolver { profile: AgentSessionProfile, processStartedAt: Date, configRoot: URL? = nil, - visitLimit: Int = 20_000 + visitLimit: Int = 20_000, + now: Date = Date() ) -> [AgentSessionCandidate]? { var collected: [AgentSessionCandidate] = [] for root in roots { @@ -509,7 +566,8 @@ actor AgentSessionResolver { let files = recentFiles( in: root, modifiedAfter: processStartedAt.addingTimeInterval(-2), - visitLimit: visitLimit + visitLimit: visitLimit, + now: now ) else { return nil } for item in files { @@ -556,11 +614,40 @@ actor AgentSessionResolver { /// Returns nil when the enumeration exceeded `visitLimit`: a partial view /// must void the whole scan rather than feed uniqueness checks. + /// + /// The walk itself is shared through `rootScans`; only the threshold filter is + /// per caller, so two panes with different process start times still reuse one + /// enumeration. private func recentFiles( in root: URL, modifiedAfter threshold: Date, - visitLimit: Int + visitLimit: Int, + now: Date + ) -> [(url: URL, modifiedAt: Date)]? { + guard let files = scannedFiles(in: root, visitLimit: visitLimit, now: now) else { return nil } + return files.filter { $0.modifiedAt >= threshold } + } + + /// Every regular file under `root` with its modification date, replayed from + /// `rootScans` while the previous walk is still fresh. + private func scannedFiles( + in root: URL, + visitLimit: Int, + now: Date ) -> [(url: URL, modifiedAt: Date)]? { + let key = RootScanKey(root: root, visitLimit: visitLimit) + if let cached = rootScans[key], now.timeIntervalSince(cached.scannedAt) < Self.rootScanLifetime { + return cached.files + } + let files = enumerateFiles(in: root, visitLimit: visitLimit) + rootScans[key] = RootScan(scannedAt: now, files: files) + if rootScans.count > 64 { + rootScans = rootScans.filter { now.timeIntervalSince($0.value.scannedAt) < Self.rootScanLifetime } + } + return files + } + + private func enumerateFiles(in root: URL, visitLimit: Int) -> [(url: URL, modifiedAt: Date)]? { guard let enumerator = fileManager.enumerator( at: root, @@ -579,8 +666,7 @@ actor AgentSessionResolver { } guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]), values.isRegularFile == true, - let modifiedAt = values.contentModificationDate, - modifiedAt >= threshold + let modifiedAt = values.contentModificationDate else { continue } result.append((url, modifiedAt)) } @@ -622,10 +708,12 @@ nonisolated enum AgentSessionFingerprintMatcher { let comparable = comparableFragments(at: path, modifiedAt: candidate.modifiedAt, cache: &cache) else { continue } if !comparable.isEmpty { sessionScoreable = true } - let score = comparable.reduce(0) { best, normalized in - if screen.contains(normalized) { return max(best, min(200, normalized.count + 80)) } - let suffix = String(normalized.suffix(80)) - return suffix.count >= 24 && screen.contains(suffix) ? max(best, suffix.count) : best + let score = comparable.reduce(0) { best, fragment in + if screen.contains(fragment.text) { return max(best, min(200, fragment.characterCount + 80)) } + // Only a fragment longer than the window has a suffix distinct from + // itself; for the rest the full-text test above already answered. + guard let suffix = fragment.suffix else { return best } + return screen.contains(suffix) ? max(best, 80) : best } if score > 0 { scored.append((candidate, score)) } } @@ -655,7 +743,7 @@ nonisolated enum AgentSessionFingerprintMatcher { at path: URL, modifiedAt: Date, cache: inout TranscriptFragmentCache - ) -> [String]? { + ) -> [TranscriptFragmentCache.Fragment]? { cache.fragments(for: TranscriptFragmentCache.Key(path: path.path, modifiedAt: modifiedAt)) { guard let data = tailData(at: path) else { return nil } // Lossy decoding is deliberate: the tail window can start mid-character @@ -663,8 +751,8 @@ nonisolated enum AgentSessionFingerprintMatcher { // whole tail instead of just the cut first line. // swiftlint:disable:next optional_data_string_conversion return transcriptStrings(String(decoding: data, as: UTF8.self)) - .map(normalize) - .filter { $0.count >= 12 } + .map { TranscriptFragmentCache.Fragment(text: normalize($0)) } + .filter { $0.characterCount >= 12 } } } diff --git a/supacodeTests/AgentSessionRootScanCacheTests.swift b/supacodeTests/AgentSessionRootScanCacheTests.swift new file mode 100644 index 00000000..3e3b67b5 --- /dev/null +++ b/supacodeTests/AgentSessionRootScanCacheTests.swift @@ -0,0 +1,119 @@ +import Foundation +import Testing + +@testable import supacode + +/// Every pane resolves its session independently, but panes in one project all +/// enumerate the same transcript directory. That walk grows with the number of +/// files on disk rather than the number of panes, so the resolver shares one +/// walk across panes for a short window instead of repeating it per pane. +/// +/// Reuse is asserted through observable behavior: a file created after a walk +/// is invisible while that walk is still being replayed, and visible once it +/// has expired. +struct AgentSessionRootScanCacheTests { + private struct Layout { + let home: URL + /// The directory the Claude profile enumerates for `projectDirectory`. + let root: URL + } + + private func makeLayout() throws -> Layout { + let home = FileManager.default.temporaryDirectory + .appending(path: "prowl-root-scan-\(UUID().uuidString)", directoryHint: .isDirectory) + // The profile only accepts UUID-named .jsonl files beneath /.claude/projects/. + let root = home.appending(path: ".claude/projects/-tmp-project", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return Layout(home: home, root: root) + } + + @discardableResult + private func writeTranscript(in root: URL, id: UUID = UUID()) throws -> URL { + let url = root.appending(path: "\(id.uuidString.lowercased()).jsonl") + try #"{"type":"user","message":{"content":"hello"}}"#.write(to: url, atomically: true, encoding: .utf8) + return url + } + + private func scan( + _ resolver: AgentSessionResolver, + root: URL, + startedAt: Date, + now: Date + ) async -> [AgentSessionCandidate]? { + await resolver.scanCandidates( + in: [root], + profile: AgentSessionProfile.profile(for: .claude), + processStartedAt: startedAt, + now: now + ) + } + + @Test func aWalkIsReplayedForPanesArrivingWithinTheWindow() async throws { + let layout = try makeLayout() + defer { try? FileManager.default.removeItem(at: layout.home) } + try writeTranscript(in: layout.root) + let resolver = AgentSessionResolver(fileManager: .default, homeDirectory: layout.home) + let started = Date(timeIntervalSince1970: 1_000) + let now = Date() + + let first = await scan(resolver, root: layout.root, startedAt: started, now: now) + #expect(first?.count == 1) + + // A second transcript lands, as a newly started agent would produce. + try writeTranscript(in: layout.root) + let replayed = await scan(resolver, root: layout.root, startedAt: started, now: now.addingTimeInterval(1)) + #expect(replayed?.count == 1, "The shared walk is replayed, so the new file is not yet visible") + } + + @Test func anExpiredWalkPicksUpNewTranscripts() async throws { + let layout = try makeLayout() + defer { try? FileManager.default.removeItem(at: layout.home) } + try writeTranscript(in: layout.root) + let resolver = AgentSessionResolver(fileManager: .default, homeDirectory: layout.home) + let started = Date(timeIntervalSince1970: 1_000) + let now = Date() + + _ = await scan(resolver, root: layout.root, startedAt: started, now: now) + try writeTranscript(in: layout.root) + + // Past the window the directory is walked again, so a session that started + // moments ago still becomes resolvable. + let refreshed = await scan(resolver, root: layout.root, startedAt: started, now: now.addingTimeInterval(5)) + #expect(refreshed?.count == 2) + } + + @Test func callersWithDifferentThresholdsShareOneWalk() async throws { + let layout = try makeLayout() + defer { try? FileManager.default.removeItem(at: layout.home) } + try writeTranscript(in: layout.root) + let resolver = AgentSessionResolver(fileManager: .default, homeDirectory: layout.home) + let now = Date() + + // The cached walk is stored unfiltered, so panes whose processes started at + // very different times reuse it and each apply their own threshold. + let old = await scan(resolver, root: layout.root, startedAt: Date(timeIntervalSince1970: 1_000), now: now) + let future = await scan(resolver, root: layout.root, startedAt: now.addingTimeInterval(3_600), now: now) + + #expect(old?.count == 1, "A process older than the transcript sees it") + #expect(future?.isEmpty == true, "A process started after the transcript does not") + } + + @Test func separateRootsAreCachedIndependently() async throws { + let layout = try makeLayout() + defer { try? FileManager.default.removeItem(at: layout.home) } + let other = layout.home.appending(path: ".claude/projects/-tmp-other", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: other, withIntermediateDirectories: true) + try writeTranscript(in: layout.root) + try writeTranscript(in: other) + try writeTranscript(in: other) + let resolver = AgentSessionResolver(fileManager: .default, homeDirectory: layout.home) + let started = Date(timeIntervalSince1970: 1_000) + let now = Date() + + let first = await scan(resolver, root: layout.root, startedAt: started, now: now) + let second = await scan(resolver, root: other, startedAt: started, now: now) + + #expect(first?.count == 1) + #expect(second?.count == 2, "One root's cached walk must not answer for another") + } +} diff --git a/supacodeTests/TranscriptFragmentCacheTests.swift b/supacodeTests/TranscriptFragmentCacheTests.swift index 7c95dea1..23a520e0 100644 --- a/supacodeTests/TranscriptFragmentCacheTests.swift +++ b/supacodeTests/TranscriptFragmentCacheTests.swift @@ -18,16 +18,16 @@ struct TranscriptFragmentCacheTests { let first = cache.fragments(for: target) { loads += 1 - return ["parsed fragment"] + return [.init(text: "parsed fragment")] } let second = cache.fragments(for: target) { loads += 1 - return ["should not be reached"] + return [.init(text: "should not be reached")] } #expect(loads == 1) - #expect(first == ["parsed fragment"]) - #expect(second == ["parsed fragment"]) + #expect(first?.map(\.text) == ["parsed fragment"]) + #expect(second?.map(\.text) == ["parsed fragment"]) } @Test func reloadsWhenTheFileIsAppendedTo() { @@ -36,15 +36,15 @@ struct TranscriptFragmentCacheTests { _ = cache.fragments(for: key("/tmp/a.jsonl", 100)) { loads += 1 - return ["old"] + return [.init(text: "old")] } let updated = cache.fragments(for: key("/tmp/a.jsonl", 101)) { loads += 1 - return ["new"] + return [.init(text: "new")] } #expect(loads == 2) - #expect(updated == ["new"]) + #expect(updated?.map(\.text) == ["new"]) } @Test func doesNotCacheAnUnreadableTail() { @@ -58,27 +58,27 @@ struct TranscriptFragmentCacheTests { } let recovered = cache.fragments(for: target) { loads += 1 - return ["now readable"] + return [.init(text: "now readable")] } // Caching the failure would keep a briefly unreadable transcript excluded // from every later match. #expect(loads == 2) #expect(missing == nil) - #expect(recovered == ["now readable"]) + #expect(recovered?.map(\.text) == ["now readable"]) } @Test func replacesAnOlderVersionOfTheSamePathImmediately() { var cache = TranscriptFragmentCache() - _ = cache.fragments(for: key("/tmp/busy.jsonl", 100)) { ["v1"] } - _ = cache.fragments(for: key("/tmp/busy.jsonl", 101)) { ["v2"] } + _ = cache.fragments(for: key("/tmp/busy.jsonl", 100)) { [.init(text: "v1")] } + _ = cache.fragments(for: key("/tmp/busy.jsonl", 101)) { [.init(text: "v2")] } #expect(cache.count == 1) } @Test func dropsAnOlderVersionWhenLoadingTheNewVersionFails() { var cache = TranscriptFragmentCache() - _ = cache.fragments(for: key("/tmp/busy.jsonl", 100)) { ["v1"] } + _ = cache.fragments(for: key("/tmp/busy.jsonl", 100)) { [.init(text: "v1")] } #expect(cache.fragments(for: key("/tmp/busy.jsonl", 101)) { nil } == nil) #expect(cache.count == 0) @@ -89,20 +89,20 @@ struct TranscriptFragmentCacheTests { 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"] } + _ = cache.fragments(for: first) { [.init(text: "first")] } + _ = cache.fragments(for: second) { [.init(text: "second")] } + _ = cache.fragments(for: first) { [.init(text: "not reached")] } + _ = cache.fragments(for: third) { [.init(text: "third")] } var firstReloads = 0 _ = cache.fragments(for: first) { firstReloads += 1 - return ["first reloaded"] + return [.init(text: "first reloaded")] } var secondReloads = 0 _ = cache.fragments(for: second) { secondReloads += 1 - return ["second reloaded"] + return [.init(text: "second reloaded")] } #expect(firstReloads == 0, "A cache hit must make the entry most recently used") @@ -117,7 +117,7 @@ struct TranscriptFragmentCacheTests { for _ in 0..<2 { _ = cache.fragments(for: target) { loads += 1 - return ["four"] + return [.init(text: "four")] } } @@ -130,26 +130,43 @@ struct TranscriptFragmentCacheTests { 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"] } + _ = cache.fragments(for: first) { [.init(text: "1234")] } + _ = cache.fragments(for: second) { [.init(text: "1234")] } + _ = cache.fragments(for: first) { [.init(text: "not reached")] } + _ = cache.fragments(for: third) { [.init(text: "1234")] } var firstReloads = 0 _ = cache.fragments(for: first) { firstReloads += 1 - return ["1234"] + return [.init(text: "1234")] } var secondReloads = 0 _ = cache.fragments(for: second) { secondReloads += 1 - return ["1234"] + return [.init(text: "1234")] } #expect(firstReloads == 0) #expect(secondReloads == 1) } + @Test func fragmentPrecomputesCountAndOnlyASuffixWorthTesting() { + let short = TranscriptFragmentCache.Fragment(text: String(repeating: "a", count: 80)) + #expect(short.characterCount == 80) + // At or below the window the suffix would equal the whole fragment, so the + // scoring loop must not be handed a second, identical search to run. + #expect(short.suffix == nil) + + let long = TranscriptFragmentCache.Fragment(text: String(repeating: "b", count: 81)) + #expect(long.characterCount == 81) + #expect(long.suffix?.count == 80) + + // `characterCount` must be the grapheme count `String.count` reports, not a + // byte or scalar count, because the score derives from it. + let emoji = TranscriptFragmentCache.Fragment(text: "e\u{0301}moji ๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ test") + #expect(emoji.characterCount == emoji.text.count) + } + @Test func bestMatchServesASecondCallFromTheCache() throws { let root = FileManager.default.temporaryDirectory .appending(path: "prowl-fragment-cache-\(UUID().uuidString)", directoryHint: .isDirectory)