From 42f335b9400afadc27f077c3e532bf0da02a4e56 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 11 Jul 2026 18:28:50 +0900 Subject: [PATCH] Close correctness gaps in scan caps and retention pacing Second adversarial review round found the two performance caps punching holes through the "never return a wrong session" invariant, plus two consistency gaps; all four are fixed with regression tests: - A directory enumeration that exceeds the 20k visit limit now voids the entire scan instead of feeding a partial candidate set into uniqueness checks: an unvisited file could hold the real session. - The fingerprint read budget is allocated per session (freshest two files each, at most twelve distinct sessions, refusing uniqueness beyond that) so one chatty multi-file session can no longer evict a competing session from the margin comparison. - resolve() reports whether the result is a fresh computation or a cache replay from backoff; sticky-session aging only counts fresh ambiguous resolutions, restoring the documented "two consecutive resolutions" semantics instead of decaying per UI tick. - Gemini candidates require a successful header read (new requiresHeaderSessionID profile flag, honored by both the storage scan and the open-descriptor path): a corrupt or partially written header drops the candidate rather than surfacing the un-resumable 8-hex filename prefix. - The unresolved backoff streak starts at zero so the first retry keeps the documented 1 s (narrow) / 8 s (wide) pacing. --- doc-onevcat/agent-session-detection.md | 20 +-- .../AgentDetection/PaneAgentState.swift | 10 +- ...WorktreeTerminalState+AgentDetection.swift | 31 +++-- .../AgentDetection/AgentSessionProfile.swift | 5 + .../AgentDetection/AgentSessionResolver.swift | 107 +++++++++++----- supacodeTests/AgentSessionProfileTests.swift | 114 ++++++++++++++++++ supacodeTests/AgentSessionResolverTests.swift | 23 +++- 7 files changed, 253 insertions(+), 57 deletions(-) diff --git a/doc-onevcat/agent-session-detection.md b/doc-onevcat/agent-session-detection.md index 5fad4594..92a6bd06 100644 --- a/doc-onevcat/agent-session-detection.md +++ b/doc-onevcat/agent-session-detection.md @@ -15,10 +15,12 @@ Resolution is anchored to the exact process selected by Active Agents. Evidence 2. **Pid-keyed artifacts** (`exact`, `process_log`): files that name the agent pid directly — Copilot's `logs/process--.log` (containing "Registering foreground session: ") and Qwen's `.runtime.json` sidecar (`{"pid": ..., "session_id": ...}`). -3. **Transcript/screen correlation** (`high`, `transcript_match`): bounded tails of candidate transcripts (the 12 - most recently modified) are compared with the pane's live text. Only a unique match with sufficient score and - margin wins; the margin rule applies between *distinct sessions* — several files of one session (Kimi, Cline, - Copilot) reinforce it instead of competing. +3. **Transcript/screen correlation** (`high`, `transcript_match`): bounded tails of candidate transcripts are + compared with the pane's live text. The read budget is per session (freshest 2 files each, at most 12 distinct + sessions — beyond that uniqueness cannot be proven and no match is declared), so one chatty session can never + evict a competing one from the comparison. Only a unique match with sufficient score and margin wins; the margin + rule applies between *distinct sessions* — several files of one session (Kimi, Cline, Copilot) reinforce it + instead of competing. 4. **Sole process-lifetime candidate** (`medium`, `recent_file` / `store_record`): storage roots (or OpenCode's sqlite `session` table) are filtered to entries modified during the process lifetime; a single distinct session id wins. @@ -123,14 +125,18 @@ Implications: - Darwin inspection uses `proc_pidinfo` / `proc_pidfdinfo`; Prowl never shells out to `lsof`. - Results are cached per process lifetime. Unresolved lookups back off exponentially (1 s doubling to a 15 s cap; wide-root fallback scans start at 8 s), so a permanently ambiguous pane costs almost nothing. Directory enumeration - is additionally capped at 20 000 entries per scan (truncation logs a warning and degrades to "unresolved"). + is capped at 20 000 entries per scan; a truncated enumeration voids the whole scan (unresolved) because a partial + view could otherwise declare a false unique candidate. - Open-descriptor evidence only counts descriptors opened for WRITING: agents transiently open other sessions read-only (resume pickers, history browsing) and a read FD must not claim a session. - A sole process-lifetime candidate (`medium`) is only adopted after two consecutive resolutions agree on it, and never when another live process already claimed the same session id — this closes the startup race where a new pane in a shared directory briefly sees only its sibling's session file. -- A previously resolved session is retained through probe gaps, but at most two consecutive ambiguous resolutions on - the same process; after that it is dropped so a rotated-away id (`/clear`) cannot survive indefinitely. +- A previously resolved session is retained through probe gaps and cache replays, but at most two consecutive FRESH + ambiguous resolutions on the same process; after that it is dropped so a rotated-away id (`/clear`) cannot survive + indefinitely. Resolver backoff replays are not new evidence and never age the retained session. +- Gemini candidates additionally require a successful header read: the filename only carries an 8-hex prefix that + cannot be resumed, so a corrupt or partially written header drops the candidate instead of surfacing the prefix. - Header enrichment (replacing a path-derived id with a JSONL first-line field) is opt-in per profile and only Gemini uses it — its filenames carry a truncated id. Generic header sniffing is forbidden: event-stream layouts like Copilot's `events.jsonl` may expose unrelated ids at the top level. diff --git a/supacode/Domain/AgentDetection/PaneAgentState.swift b/supacode/Domain/AgentDetection/PaneAgentState.swift index 922a3329..faad62ec 100644 --- a/supacode/Domain/AgentDetection/PaneAgentState.swift +++ b/supacode/Domain/AgentDetection/PaneAgentState.swift @@ -33,19 +33,23 @@ struct PaneAgentState: Equatable, Sendable { self.lastChangedAt = lastChangedAt } - /// Sticky-session policy: a fresh resolution always wins; a probe gap + /// Sticky-session policy: a resolution always wins; a probe gap /// (`identifiedPID == nil`, presence hold) keeps the last session without - /// aging it; an ambiguous resolver result on the same process keeps it for + /// aging it; a FRESH ambiguous resolution on the same process keeps it for /// at most two misses so a rotated-away session id cannot survive - /// indefinitely. A different pid discards it immediately. + /// indefinitely. Cache replays during resolver backoff (`isFresh == false`) + /// are not new evidence and never age the session. A different pid discards + /// it immediately. static func retainedSession( resolved: AgentSession?, + isFresh: Bool, previous: PaneAgentState, identifiedPID: pid_t? ) -> (session: AgentSession?, missStreak: Int) { if let resolved { return (resolved, 0) } guard let identifiedPID else { return (previous.session, previous.sessionMissStreak) } guard identifiedPID == previous.agentProcessID else { return (nil, 0) } + guard isFresh else { return (previous.session, previous.sessionMissStreak) } let streak = previous.sessionMissStreak + 1 return (streak >= 3 ? nil : previous.session, streak) } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 75fa173f..9b8580be 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -122,16 +122,12 @@ extension WorktreeTerminalState { } let iconLookupToken = identified?.name ?? previous.iconLookupToken ?? agent.iconLookupToken let workingDirectory = activeAgentWorkingDirectory(surfaceID: surfaceID) - let resolved = await resolveAgentSession( + let (session, sessionMissStreak) = await resolveRetainedSession( identified: identified, + previous: previous, workingDirectory: workingDirectory, activeText: activeText ) - let (session, sessionMissStreak) = PaneAgentState.retainedSession( - resolved: resolved, - previous: previous, - identifiedPID: identified?.process.pid - ) // Re-check after the suspension: the pane may have been closed and its // agent state cleaned up while the resolver was doing file inspection; // writing below would resurrect a ghost Active Agents entry. @@ -175,16 +171,25 @@ extension WorktreeTerminalState { return true } - private func resolveAgentSession( + private func resolveRetainedSession( identified: IdentifiedAgentProcess?, + previous: PaneAgentState, workingDirectory: URL?, activeText: String - ) async -> AgentSession? { - guard let identified else { return nil } - return await AgentSessionResolver.shared.resolve( - identified: identified, - workingDirectory: workingDirectory, - activeText: activeText + ) async -> (session: AgentSession?, missStreak: Int) { + var resolution = AgentSessionResolution(session: nil, isFresh: false) + if let identified { + resolution = await AgentSessionResolver.shared.resolve( + identified: identified, + workingDirectory: workingDirectory, + activeText: activeText + ) + } + return PaneAgentState.retainedSession( + resolved: resolution.session, + isFresh: resolution.isFresh, + previous: previous, + identifiedPID: identified?.process.pid ) } diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift index a1502c19..e03666fc 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift @@ -16,6 +16,10 @@ nonisolated struct AgentSessionProfile: Sendable { /// already holds the full id — generic sniffing can grab an unrelated field /// from event-stream layouts like Copilot's events.jsonl. var headerSessionIDKeys: [String] = [] + /// When true, a candidate whose header lookup fails is DROPPED instead of + /// falling back to the path-derived id (Gemini: the filename only holds an + /// 8-hex prefix that cannot be resumed). + var requiresHeaderSessionID: Bool = false /// Storage roots scanned for session files modified during the process /// lifetime. Narrow these as much as the layout allows. var candidateRoots: @Sendable (_ home: URL, _ cwd: URL?, _ processStartedAt: Date, _ now: Date) -> [URL] = { @@ -108,6 +112,7 @@ nonisolated extension AgentSessionProfile { return AgentSession(id: id, transcriptPath: url, source: .recentFile) }, headerSessionIDKeys: ["sessionId"], + requiresHeaderSessionID: true, candidateRoots: { home, cwd, _, _ in guard let cwd else { return [home.appending(path: ".gemini/tmp")] } let tmp = home.appending(path: ".gemini/tmp") diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift index 19757451..c3d8465f 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift @@ -63,6 +63,13 @@ nonisolated extension [AgentSessionCandidate] { } } +/// Outcome of one resolver call: `isFresh` distinguishes a newly computed +/// resolution from a cache replay during backoff. +nonisolated struct AgentSessionResolution: Sendable { + let session: AgentSession? + let isFresh: Bool +} + /// Compatibility shim over the per-agent profiles; the actual rules live in /// `AgentSessionProfile`. nonisolated enum AgentSessionPathParser { @@ -129,9 +136,11 @@ actor AgentSessionResolver { workingDirectory: URL?, activeText: String, now: Date = Date() - ) -> AgentSession? { + ) -> AgentSessionResolution { let process = identified.process - guard let startedAt = ProcessDetection.processStartDate(pid: process.pid) else { return nil } + guard let startedAt = ProcessDetection.processStartDate(pid: process.pid) else { + return AgentSessionResolution(session: nil, isFresh: true) + } let key = CacheKey(pid: process.pid, startedAt: startedAt) let cached = cache[key] if let cached { @@ -140,7 +149,11 @@ actor AgentSessionResolver { usedWideScan: cached.usedWideScan, unresolvedStreak: cached.unresolvedStreak ) - if now.timeIntervalSince(cached.resolvedAt) < lifetime { return cached.session } + if now.timeIntervalSince(cached.resolvedAt) < lifetime { + // Replayed cache hits are not new evidence; consumers must not age + // their sticky sessions on them. + return AgentSessionResolution(session: cached.session, isFresh: false) + } } let (resolved, usedWideScan) = resolveUncached( @@ -163,8 +176,10 @@ actor AgentSessionResolver { resolvedAt: now, session: session, usedWideScan: usedWideScan, - // A pending sole confirmation retries fast instead of backing off. - unresolvedStreak: session == nil && provisionalID == nil ? (cached?.unresolvedStreak ?? 0) + 1 : 0, + // 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, provisionalSoleID: provisionalID ) if cache.count > 128 { @@ -172,7 +187,7 @@ actor AgentSessionResolver { ProcessDetection.processStartDate(pid: entry.key.pid) == entry.key.startedAt } } - return session + return AgentSessionResolution(session: session, isFresh: true) } /// A session id already resolved for a different live process cannot also @@ -195,10 +210,12 @@ actor AgentSessionResolver { let openSessions = ProcessDetection.openFilePaths(pid: identified.process.pid) .compactMap { profile.parsePath($0) } - .map { session -> AgentSession in + .compactMap { session -> AgentSession? in guard let path = session.transcriptPath, let fullID = Self.sessionIDFromHeader(at: path, keys: profile.headerSessionIDKeys) - else { return session } + else { + return profile.requiresHeaderSessionID ? nil : session + } return AgentSession(id: fullID, transcriptPath: path, source: session.source) } if let session = uniqueSession(openSessions) { @@ -275,12 +292,13 @@ actor AgentSessionResolver { primaryRoots.append(root) } } - let primary = candidates(in: primaryRoots, profile: profile, processStartedAt: processStartedAt) + let primary = scanCandidates(in: primaryRoots, profile: profile, processStartedAt: processStartedAt) ?? [] let combined = primary + stored.uniquedBySessionID() guard combined.isEmpty else { return (combined, false) } let fallbackRoots = profile.fallbackRoots(homeDirectory, workingDirectory) guard !fallbackRoots.isEmpty else { return ([], false) } - return (candidates(in: fallbackRoots, profile: profile, processStartedAt: processStartedAt), true) + let fallback = scanCandidates(in: fallbackRoots, profile: profile, processStartedAt: processStartedAt) ?? [] + return (fallback, true) } /// The pane reports the shell's logical `$PWD` while agents usually record @@ -291,21 +309,47 @@ actor AgentSessionResolver { return resolved.path == cwd.path ? [cwd] : [cwd, resolved] } - private func candidates( + /// Scans `roots` for session files modified during the process lifetime. + /// Returns nil when any enumeration was truncated: an incomplete view could + /// declare a false unique candidate, and unresolved is the safe outcome. + func scanCandidates( in roots: [URL], profile: AgentSessionProfile, - processStartedAt: Date - ) -> [AgentSessionCandidate] { - roots.flatMap { root in - recentFiles(in: root, modifiedAfter: processStartedAt.addingTimeInterval(-2)).compactMap { item in - guard let session = profile.parsePath(item.url.path) else { return nil } - let enriched = - Self.sessionIDFromHeader(at: item.url, keys: profile.headerSessionIDKeys).map { - AgentSession(id: $0, transcriptPath: item.url, source: .recentFile) - } ?? session - return AgentSessionCandidate(session: enriched, modifiedAt: item.modifiedAt) + processStartedAt: Date, + visitLimit: Int = 20_000 + ) -> [AgentSessionCandidate]? { + var collected: [AgentSessionCandidate] = [] + for root in roots { + guard + let files = recentFiles( + in: root, + modifiedAfter: processStartedAt.addingTimeInterval(-2), + visitLimit: visitLimit + ) + else { return nil } + for item in files { + guard let candidate = enrichedCandidate(for: item, profile: profile) else { continue } + collected.append(candidate) } } + return collected + } + + private func enrichedCandidate( + for item: (url: URL, modifiedAt: Date), + profile: AgentSessionProfile + ) -> AgentSessionCandidate? { + guard let session = profile.parsePath(item.url.path) else { return nil } + if let fullID = Self.sessionIDFromHeader(at: item.url, keys: profile.headerSessionIDKeys) { + return AgentSessionCandidate( + session: AgentSession(id: fullID, transcriptPath: item.url, source: .recentFile), + modifiedAt: item.modifiedAt + ) + } + // A profile that depends on the header (Gemini's filenames only carry a + // truncated id) must not surface the unusable path-derived id. + guard !profile.requiresHeaderSessionID else { return nil } + return AgentSessionCandidate(session: session, modifiedAt: item.modifiedAt) } nonisolated static func sessionIDFromHeader(at url: URL, keys: [String]) -> String? { @@ -323,11 +367,13 @@ actor AgentSessionResolver { return nil } + /// Returns nil when the enumeration exceeded `visitLimit`: a partial view + /// must void the whole scan rather than feed uniqueness checks. private func recentFiles( in root: URL, modifiedAfter threshold: Date, - visitLimit: Int = 20_000 - ) -> [(url: URL, modifiedAt: Date)] { + visitLimit: Int + ) -> [(url: URL, modifiedAt: Date)]? { guard let enumerator = fileManager.enumerator( at: root, @@ -341,10 +387,8 @@ actor AgentSessionResolver { for case let url as URL in enumerator { visited += 1 if visited > visitLimit { - // A pathological tree; missing candidates degrades to "unresolved", - // never to a wrong id. agentSessionLogger.warning("Agent session scan truncated at \(visitLimit) entries under \(root.path)") - break + return nil } guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]), values.isRegularFile == true, @@ -364,8 +408,15 @@ nonisolated enum AgentSessionFingerprintMatcher { ) -> AgentSessionCandidate? { let screen = normalize(activeText) guard screen.count >= 12 else { return nil } - // Cap tail reads: the freshest files carry the on-screen conversation. - let recent = candidates.sorted { $0.modifiedAt > $1.modifiedAt }.prefix(12) + // Bound tail reads WITHOUT evicting whole sessions: cap files per session + // (extra files of one session only reinforce it), and refuse to declare + // uniqueness when there are more sessions than the read budget covers — + // an unexamined session could hold the same text. + let bySession = Dictionary(grouping: candidates) { $0.session.id } + guard bySession.count <= 12 else { return nil } + let recent = bySession.values.flatMap { group in + group.sorted { $0.modifiedAt > $1.modifiedAt }.prefix(2) + } let scored = recent.compactMap { candidate -> (AgentSessionCandidate, Int)? in guard let path = candidate.session.transcriptPath, let data = tailData(at: path), diff --git a/supacodeTests/AgentSessionProfileTests.swift b/supacodeTests/AgentSessionProfileTests.swift index 8c13452f..f1803431 100644 --- a/supacodeTests/AgentSessionProfileTests.swift +++ b/supacodeTests/AgentSessionProfileTests.swift @@ -133,6 +133,76 @@ struct AgentSessionProfileTests { #expect(match?.session.id == "same-session") } + @Test func fingerprintCapCannotEvictACompetingSession() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-cap-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let content = #"{"message":{"content":"Reply exactly READY and wait for further instructions."}}"# + + // Session A floods the recency window with 20 files; session B has one + // older file with the same on-screen text. B must still veto uniqueness. + var candidates: [AgentSessionCandidate] = [] + for index in 0..<20 { + let url = root.appending(path: "a-\(index).jsonl") + try content.write(to: url, atomically: true, encoding: .utf8) + candidates.append( + AgentSessionCandidate( + session: AgentSession(id: "session-a", transcriptPath: url, source: .recentFile), + modifiedAt: Date(timeIntervalSince1970: 2_000 + TimeInterval(index)) + ) + ) + } + let bURL = root.appending(path: "b.jsonl") + try content.write(to: bURL, atomically: true, encoding: .utf8) + candidates.append( + AgentSessionCandidate( + session: AgentSession(id: "session-b", transcriptPath: bURL, source: .recentFile), + modifiedAt: Date(timeIntervalSince1970: 1_000) + ) + ) + + let match = AgentSessionFingerprintMatcher.bestMatch( + activeText: "Reply exactly READY and wait for further instructions.", + candidates: candidates + ) + #expect(match == nil) + } + + @Test func fingerprintRefusesUniquenessBeyondSessionBudget() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-many-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let unique = root.appending(path: "match.jsonl") + try #"{"message":{"content":"An unmistakably distinctive fingerprint phrase."}}"# + .write(to: unique, atomically: true, encoding: .utf8) + var candidates = [ + AgentSessionCandidate( + session: AgentSession(id: "target", transcriptPath: unique, source: .recentFile), + modifiedAt: Date(timeIntervalSince1970: 5_000) + ) + ] + for index in 0..<13 { + let url = root.appending(path: "other-\(index).jsonl") + try #"{"message":{"content":"irrelevant"}}"#.write(to: url, atomically: true, encoding: .utf8) + candidates.append( + AgentSessionCandidate( + session: AgentSession(id: "other-\(index)", transcriptPath: url, source: .recentFile), + modifiedAt: Date(timeIntervalSince1970: 4_000 - TimeInterval(index)) + ) + ) + } + + // 14 distinct sessions exceed the read budget; uniqueness cannot be + // proven, so no match may be declared. + let match = AgentSessionFingerprintMatcher.bestMatch( + activeText: "An unmistakably distinctive fingerprint phrase.", + candidates: candidates + ) + #expect(match == nil) + } + // MARK: - Header enrichment stays per-profile @Test func headerEnrichmentNeverOverridesDirectoryDerivedIDs() throws { @@ -290,6 +360,50 @@ struct AgentSessionProfileTests { #expect(AgentSessionResolver.sessionIDFromHeader(at: huge, keys: ["sessionId"]) == nil) } + @Test func truncatedScansYieldNoCandidates() async throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-truncate-\(UUID().uuidString)/.claude/projects/-tmp-x", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + for index in 0..<5 { + try "{}".write( + to: root.appending(path: "0000000\(index)-1111-2222-3333-444444444444.jsonl"), + atomically: true, + encoding: .utf8 + ) + } + let resolver = AgentSessionResolver() + let profile = AgentSessionProfile.profile(for: .claude) + let full = await resolver.scanCandidates(in: [root], profile: profile, processStartedAt: .distantPast) + #expect(full?.count == 5) + // A truncated enumeration cannot prove uniqueness; the whole scan is void. + let truncated = await resolver.scanCandidates( + in: [root], + profile: profile, + processStartedAt: .distantPast, + visitLimit: 3 + ) + #expect(truncated == nil) + } + + @Test func geminiCandidatesRequireSuccessfulHeaderEnrichment() async throws { + let chats = FileManager.default.temporaryDirectory + .appending(path: "prowl-gemini-req-\(UUID().uuidString)/.gemini/tmp/proj/chats", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: chats, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: chats) } + try #"{"sessionId":"23ce3e98-af90-4d5c-8b83-ffcc258dff2b"}"# + .write(to: chats.appending(path: "session-2026-07-11T05-41-23ce3e98.jsonl"), atomically: true, encoding: .utf8) + try "not json at all" + .write(to: chats.appending(path: "session-2026-07-11T05-42-6827d721.jsonl"), atomically: true, encoding: .utf8) + + let resolver = AgentSessionResolver() + let profile = AgentSessionProfile.profile(for: .gemini) + let candidates = await resolver.scanCandidates(in: [chats], profile: profile, processStartedAt: .distantPast) + // The corrupt header must drop its file rather than surface a truncated + // 8-hex id that cannot be resumed. + #expect(candidates?.map(\.session.id) == ["23ce3e98-af90-4d5c-8b83-ffcc258dff2b"]) + } + // MARK: - Cache pacing @Test func unresolvedLookupsBackOffExponentially() { diff --git a/supacodeTests/AgentSessionResolverTests.swift b/supacodeTests/AgentSessionResolverTests.swift index 41818c33..4cfd69d9 100644 --- a/supacodeTests/AgentSessionResolverTests.swift +++ b/supacodeTests/AgentSessionResolverTests.swift @@ -97,24 +97,35 @@ struct AgentSessionResolverTests { var previous = PaneAgentState(agentProcessID: 42, session: session) previous.sessionMissStreak = 0 - // Same process, resolver ambiguous: retained for two misses, dropped on the third. - let miss1 = PaneAgentState.retainedSession(resolved: nil, previous: previous, identifiedPID: 42) + // Same process, FRESH ambiguous resolution: retained for two misses, + // dropped on the third. + let miss1 = PaneAgentState.retainedSession(resolved: nil, isFresh: true, previous: previous, identifiedPID: 42) #expect(miss1.session?.id == "old") #expect(miss1.missStreak == 1) previous.sessionMissStreak = 2 - let miss3 = PaneAgentState.retainedSession(resolved: nil, previous: previous, identifiedPID: 42) + let miss3 = PaneAgentState.retainedSession(resolved: nil, isFresh: true, previous: previous, identifiedPID: 42) #expect(miss3.session == nil) + // Cached nil replayed during resolver backoff must NOT age the session: + // only fresh resolutions count as misses. + previous.sessionMissStreak = 2 + let replay = PaneAgentState.retainedSession(resolved: nil, isFresh: false, previous: previous, identifiedPID: 42) + #expect(replay.session?.id == "old") + #expect(replay.missStreak == 2) + // Presence hold (probe returned no process): keep without aging. previous.sessionMissStreak = 2 - let held = PaneAgentState.retainedSession(resolved: nil, previous: previous, identifiedPID: nil) + let held = PaneAgentState.retainedSession(resolved: nil, isFresh: false, previous: previous, identifiedPID: nil) #expect(held.session?.id == "old") #expect(held.missStreak == 2) // Fresh resolution resets the streak; new pid drops the session. - let fresh = PaneAgentState.retainedSession(resolved: session, previous: previous, identifiedPID: 42) + let fresh = PaneAgentState.retainedSession(resolved: session, isFresh: true, previous: previous, identifiedPID: 42) #expect(fresh.missStreak == 0) - #expect(PaneAgentState.retainedSession(resolved: nil, previous: previous, identifiedPID: 43).session == nil) + #expect( + PaneAgentState.retainedSession(resolved: nil, isFresh: true, previous: previous, identifiedPID: 43).session + == nil + ) } @Test func openFilePathsExcludeReadOnlyDescriptors() throws { -- 2.51.2