diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift index c3d8465f..46a8a5be 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift @@ -105,6 +105,18 @@ actor AgentSessionResolver { return min(15, base * TimeInterval(1 << min(unresolvedStreak, 4))) } + /// resolved → ambiguous starts a new unresolved episode at streak 0 (the + /// first retry keeps the fast pacing, e.g. right after `/clear`); only + /// consecutive unresolved results escalate the backoff. + nonisolated static func nextUnresolvedStreak( + resolvedNow: Bool, + previousWasUnresolved: Bool, + previousStreak: Int + ) -> Int { + guard !resolvedNow, previousWasUnresolved else { return 0 } + return previousStreak + 1 + } + /// A sole process-lifetime candidate is only trusted after two consecutive /// resolutions agree on it. A pane that starts in a directory where another /// agent is actively writing can otherwise adopt that agent's session during @@ -176,10 +188,12 @@ actor AgentSessionResolver { resolvedAt: now, session: session, usedWideScan: usedWideScan, - // A pending sole confirmation retries fast instead of backing off; the - // first unresolved result starts at streak 0 so the initial retry keeps - // the documented 1 s (narrow) / 8 s (wide) pacing. - unresolvedStreak: session == nil && provisionalID == nil ? cached.map { $0.unresolvedStreak + 1 } ?? 0 : 0, + // A pending sole confirmation retries fast instead of backing off. + unresolvedStreak: Self.nextUnresolvedStreak( + resolvedNow: session != nil || provisionalID != nil, + previousWasUnresolved: cached.map { $0.session == nil && $0.provisionalSoleID == nil } ?? false, + previousStreak: cached?.unresolvedStreak ?? 0 + ), provisionalSoleID: provisionalID ) if cache.count > 128 { @@ -276,11 +290,12 @@ actor AgentSessionResolver { return unique.count == 1 ? unique.values.first : nil } - private func recentCandidates( + func recentCandidates( profile: AgentSessionProfile, processStartedAt: Date, workingDirectory: URL?, - now: Date + now: Date, + visitLimit: Int = 20_000 ) -> (candidates: [AgentSessionCandidate], usedWideScan: Bool) { let cwdVariants = workingDirectoryVariants(workingDirectory) let threshold = processStartedAt.addingTimeInterval(-2) @@ -292,13 +307,30 @@ actor AgentSessionResolver { primaryRoots.append(root) } } - let primary = scanCandidates(in: primaryRoots, profile: profile, processStartedAt: processStartedAt) ?? [] + guard + let primary = scanCandidates( + in: primaryRoots, + profile: profile, + processStartedAt: processStartedAt, + visitLimit: visitLimit + ) + else { + // A truncated primary scan voids this whole round: the fallback tree is + // a superset and would only repeat the oversized enumeration. Report it + // as a wide scan so the retry backs off at the slow tier. + return ([], true) + } let combined = primary + stored.uniquedBySessionID() guard combined.isEmpty else { return (combined, false) } let fallbackRoots = profile.fallbackRoots(homeDirectory, workingDirectory) guard !fallbackRoots.isEmpty else { return ([], false) } - let fallback = scanCandidates(in: fallbackRoots, profile: profile, processStartedAt: processStartedAt) ?? [] - return (fallback, true) + let fallback = scanCandidates( + in: fallbackRoots, + profile: profile, + processStartedAt: processStartedAt, + visitLimit: visitLimit + ) + return (fallback ?? [], true) } /// The pane reports the shell's logical `$PWD` while agents usually record diff --git a/supacodeTests/AgentSessionProfileTests.swift b/supacodeTests/AgentSessionProfileTests.swift index f1803431..0e467b6c 100644 --- a/supacodeTests/AgentSessionProfileTests.swift +++ b/supacodeTests/AgentSessionProfileTests.swift @@ -404,6 +404,74 @@ struct AgentSessionProfileTests { #expect(candidates?.map(\.session.id) == ["23ce3e98-af90-4d5c-8b83-ffcc258dff2b"]) } + @Test func truncatedPrimaryScanSkipsFallbackEntirely() async throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-trunc-fb-\(UUID().uuidString)", directoryHint: .isDirectory) + let primaryDir = root.appending(path: "primary") + let fallbackDir = root.appending(path: "fallback") + try FileManager.default.createDirectory(at: primaryDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: fallbackDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + // Primary trips the visit limit; the fallback holds ONE cleanly parsable + // session below the limit. If a truncated primary scan wrongly proceeded + // to the fallback, this candidate would surface — the assertion below + // would catch it. + for index in 0..<4 { + try "{}".write( + to: primaryDir.appending(path: "0000000\(index)-1111-2222-3333-444444444444.jsonl"), + atomically: true, + encoding: .utf8 + ) + } + try "{}".write( + to: fallbackDir.appending(path: "99999999-1111-2222-3333-444444444444.jsonl"), + atomically: true, + encoding: .utf8 + ) + let profile = AgentSessionProfile( + parsePath: { path in + let url = URL(fileURLWithPath: path) + guard url.pathExtension == "jsonl" else { return nil } + return AgentSession( + id: url.deletingPathExtension().lastPathComponent, + transcriptPath: url, + source: .recentFile + ) + }, + candidateRoots: { _, _, _, _ in [primaryDir] }, + fallbackRoots: { _, _ in [fallbackDir] } + ) + + let resolver = AgentSessionResolver() + let result = await resolver.recentCandidates( + profile: profile, + processStartedAt: .distantPast, + workingDirectory: nil, + now: .now, + visitLimit: 3 + ) + #expect(result.candidates.isEmpty) + #expect(result.usedWideScan) + } + + @Test func unresolvedStreakResetsWhenAResolvedSessionTurnsAmbiguous() { + // resolved → ambiguous starts a NEW unresolved episode at streak 0 so the + // first retry keeps the documented 1 s / 8 s pacing (e.g. right after + // /clear); only consecutive unresolved results escalate. + #expect( + AgentSessionResolver.nextUnresolvedStreak(resolvedNow: true, previousWasUnresolved: false, previousStreak: 5) == 0 + ) + #expect( + AgentSessionResolver.nextUnresolvedStreak(resolvedNow: false, previousWasUnresolved: false, previousStreak: 0) + == 0) + #expect( + AgentSessionResolver.nextUnresolvedStreak(resolvedNow: false, previousWasUnresolved: true, previousStreak: 0) == 1 + ) + #expect( + AgentSessionResolver.nextUnresolvedStreak(resolvedNow: false, previousWasUnresolved: true, previousStreak: 3) == 4 + ) + } + // MARK: - Cache pacing @Test func unresolvedLookupsBackOffExponentially() {