diff --git a/Sources/Trifola/AppServices.swift b/Sources/Trifola/AppServices.swift index bae2a8d..3e6ea9d 100644 --- a/Sources/Trifola/AppServices.swift +++ b/Sources/Trifola/AppServices.swift @@ -529,7 +529,9 @@ final class AppServices: ObservableObject { liveTerminalSessionIDs: liveTerminalSessionIDs, searchIndex: sessions.searchIndex, searchState: sessions.searchState, + sessionsRevision: sessions.revision, lineageEvidence: sessions.lineageEvidence, + lineageEvidenceRevision: sessions.lineageEvidenceRevision, showHeuristicLinks: preferences.value.showHeuristicLineageLinks, lineageIsProvisional: sessions.scanPresentation.isProvisional || sessions.scanProgress.isInProgress, diff --git a/Sources/Trifola/NavigationSnapshotStore.swift b/Sources/Trifola/NavigationSnapshotStore.swift index ee764d8..14fd95a 100644 --- a/Sources/Trifola/NavigationSnapshotStore.swift +++ b/Sources/Trifola/NavigationSnapshotStore.swift @@ -130,14 +130,28 @@ final class NavigationSnapshotStore: ObservableObject { let liveTerminalSessionIDs: Set let searchIndex: SearchIndex let searchState: SearchIndexState + let sessionsRevision: Int let lineageEvidence: SessionLineageEvidence + let lineageEvidenceRevision: Int let showHeuristicLinks: Bool let lineageIsProvisional: Bool let now: Date } + private struct LineageSource: Sendable, Equatable { + let sessionsRevision: Int + let evidenceRevision: Int + let includeHeuristics: Bool + } + private var inputs: Inputs? private var sessionGeneration = 0 + /// Resolved forest for one rebuild's inputs. Keystrokes and same-source + /// refreshes must never pay the ~10k-session resolve again. + private var lineageMemo: + (source: LineageSource, resolution: SessionLineageResolution)? + private var lineageInFlight: + (source: LineageSource, task: Task)? private var corpusGeneration = 0 private var fleetGeneration = 0 private var deadlineGeneration = 0 @@ -191,6 +205,7 @@ final class NavigationSnapshotStore: ObservableObject { deinit { sessionTask?.cancel() + lineageInFlight?.task.cancel() corpusTask?.cancel() fleetTask?.cancel() deadlineTask?.cancel() @@ -207,12 +222,25 @@ final class NavigationSnapshotStore: ObservableObject { liveTerminalSessionIDs: Set, searchIndex: SearchIndex, searchState: SearchIndexState, + sessionsRevision: Int, lineageEvidence: SessionLineageEvidence, + lineageEvidenceRevision: Int, showHeuristicLinks: Bool, lineageIsProvisional: Bool, now: Date ) { sourceGeneration += 1 + let lineageSource = LineageSource( + sessionsRevision: sessionsRevision, + evidenceRevision: lineageEvidenceRevision, + includeHeuristics: showHeuristicLinks) + if lineageMemo?.source != lineageSource { + lineageMemo = nil + } + if let pending = lineageInFlight, pending.source != lineageSource { + pending.task.cancel() + lineageInFlight = nil + } inputs = Inputs( sessions: sessions, attentionSignals: attentionSignals, @@ -223,7 +251,9 @@ final class NavigationSnapshotStore: ObservableObject { liveTerminalSessionIDs: liveTerminalSessionIDs, searchIndex: searchIndex, searchState: searchState, + sessionsRevision: sessionsRevision, lineageEvidence: lineageEvidence, + lineageEvidenceRevision: lineageEvidenceRevision, showHeuristicLinks: showHeuristicLinks, lineageIsProvisional: lineageIsProvisional, now: now) @@ -360,6 +390,28 @@ final class NavigationSnapshotStore: ObservableObject { sessionGeneration += 1 let generation = sessionGeneration let filter = sessionFilter + let lineageSource = LineageSource( + sessionsRevision: inputs.sessionsRevision, + evidenceRevision: inputs.lineageEvidenceRevision, + includeHeuristics: inputs.showHeuristicLinks) + let cachedResolution = lineageMemo.flatMap { + $0.source == lineageSource ? $0.resolution : nil + } + let resolutionTask: Task? = { + guard cachedResolution == nil else { return nil } + if let pending = lineageInFlight, + pending.source == lineageSource { + return pending.task + } + let task = Task.detached(priority: .userInitiated) { + try? SessionLineage.resolveWithIndexCancellable( + sessions: inputs.sessions, + evidence: inputs.lineageEvidence, + includeHeuristicLinks: inputs.showHeuristicLinks) + } + lineageInFlight = (source: lineageSource, task: task) + return task + }() sessionTask?.cancel() var state = sessionSearch let request = state.begin(query: filter.query) @@ -369,6 +421,27 @@ final class NavigationSnapshotStore: ObservableObject { try? await Task.sleep(for: .milliseconds(250)) guard !Task.isCancelled else { return } } + let resolution: SessionLineageResolution + if let cachedResolution { + resolution = cachedResolution + } else if let resolutionTask { + guard let completed = await resolutionTask.value else { return } + resolution = completed + guard let self else { return } + if self.inputs.map({ LineageSource( + sessionsRevision: $0.sessionsRevision, + evidenceRevision: $0.lineageEvidenceRevision, + includeHeuristics: $0.showHeuristicLinks) + }) == lineageSource { + self.lineageMemo = ( + source: lineageSource, + resolution: resolution) + self.lineageInFlight = nil + } + } else { + return + } + guard !Task.isCancelled else { return } let result = await Task.detached(priority: .userInitiated) { let metric = NavigationMetrics.beginProjection( .sessions, generation: generation) @@ -379,12 +452,8 @@ final class NavigationSnapshotStore: ObservableObject { liveTerminalSessionIDs: inputs.liveTerminalSessionIDs, filter: eligibilityFilter, now: inputs.now) - let lineage = SessionLineage.resolve( - sessions: inputs.sessions, - evidence: inputs.lineageEvidence, - includeHeuristicLinks: inputs.showHeuristicLinks) let projected = Self.projectLineage( - lineage, + resolution, summaries: inputs.sessions, liveTerminalSessionIDs: inputs.liveTerminalSessionIDs, filter: filter, @@ -594,15 +663,13 @@ final class NavigationSnapshotStore: ObservableObject { } private nonisolated static func projectLineage( - _ forest: SessionLineageForest, + _ resolution: SessionLineageResolution, summaries: [SessionSummary], liveTerminalSessionIDs: Set, filter: SessionProjectionFilter, now: Date ) -> ProjectedLineage { - let summaryByKey = Dictionary( - summaries.map { (SessionLineage.key($0), $0) }, - uniquingKeysWith: { first, _ in first }) + let forest = resolution.forest var counts = SessionLineageCounts() for node in forest.allNodes where node.spawnDepth > 0 { switch node.edgeKind { @@ -622,11 +689,14 @@ final class NavigationSnapshotStore: ObservableObject { if let machine = filter.machineID, reference.machineID != machine { return false } if filter.liveInTerminalOnly, !liveTerminalSessionIDs.contains(reference.id) { return false } - guard let summary = summaryByKey[reference.stableKey] else { + guard !reference.isMetadataOnly else { return !filter.activeOnly && !filter.heavyOnly } - if filter.activeOnly && !summary.isActive(at: now) { return false } - if filter.heavyOnly && !summary.isContextHeavy { return false } + if filter.activeOnly { + guard let last = reference.lastActivity, + now.timeIntervalSince(last) < 15 * 60 else { return false } + } + if filter.heavyOnly && reference.contextWeight <= 200_000 { return false } return true } @@ -703,7 +773,11 @@ final class NavigationSnapshotStore: ObservableObject { func displayRow(_ node: LineageNode, parent: LineageNode?, contextOnly: Bool) -> SessionLineageDisplayRow { - let summary = summaryByKey[node.session.stableKey] + let reference = node.session + let isActive = !reference.isMetadataOnly + && reference.lastActivity.map { + now.timeIntervalSince($0) < 15 * 60 + } == true return SessionLineageDisplayRow( id: node.session.stableKey, sessionID: node.session.id, @@ -718,8 +792,9 @@ final class NavigationSnapshotStore: ObservableObject { cost: node.session.cost, machineID: node.session.machineID, isRemote: node.session.machineID != Machine.localID, - isActive: summary?.isActive(at: now) ?? false, - isContextHeavy: summary?.isContextHeavy ?? false, + isActive: isActive, + isContextHeavy: !reference.isMetadataOnly + && reference.contextWeight > 200_000, isMetadataOnly: node.session.isMetadataOnly, transcriptNote: node.session.transcriptNote, edgeKind: node.edgeKind, @@ -774,8 +849,11 @@ final class NavigationSnapshotStore: ObservableObject { now: now) let matches = SessionBrowserSearch.titlePathMatches( eligible, query: filter.query) - rows = sortSessions(matches, by: filter.sort).prefix(400).map { summary in - let reference = LineageSessionReference(summary: summary) + rows = sortSessions(matches, by: filter.sort).prefix(400).compactMap { summary in + guard let key = resolution.key(for: summary) else { return nil } + let reference = LineageSessionReference( + summary: summary, + stableKey: key) let node = LineageNode( session: reference, children: [], edgeKind: nil, confidence: nil, spawnDepth: 0, displayDepth: 0, diff --git a/Sources/TrifolaKit/SessionLineage.swift b/Sources/TrifolaKit/SessionLineage.swift index 02a815a..5178317 100644 --- a/Sources/TrifolaKit/SessionLineage.swift +++ b/Sources/TrifolaKit/SessionLineage.swift @@ -219,9 +219,10 @@ public struct LineageSessionReference: Identifiable, Sendable, Hashable { return max(0, lastActivity.timeIntervalSince(startedAt)) } - public init(summary: SessionSummary, startedAt: Date? = nil) { + public init(summary: SessionSummary, startedAt: Date? = nil, + stableKey: String? = nil) { id = summary.id - stableKey = SessionLineage.key(summary) + self.stableKey = stableKey ?? SessionLineage.key(summary) provider = summary.provider project = summary.project cwd = summary.cwd @@ -317,6 +318,40 @@ public struct SessionLineageForest: Sendable, Equatable { } } +public struct SessionLineageResolution: Sendable { + public let forest: SessionLineageForest + + private struct SessionIdentity: Sendable, Hashable { + let provider: Provider + let machineID: String + let id: String + let filePath: String + + init(_ summary: SessionSummary) { + provider = summary.provider + machineID = summary.machineID + id = summary.id + filePath = summary.filePath + } + } + + private let keysBySession: [SessionIdentity: String] + + fileprivate init( + forest: SessionLineageForest, + keyedSessions: [(summary: SessionSummary, key: String)] + ) { + self.forest = forest + keysBySession = Dictionary( + keyedSessions.map { (SessionIdentity($0.summary), $0.key) }, + uniquingKeysWith: { first, _ in first }) + } + + public func key(for summary: SessionSummary) -> String? { + keysBySession[SessionIdentity(summary)] + } +} + public enum SessionLineage { private struct Candidate: Sendable { let parentKey: String? @@ -337,34 +372,91 @@ public enum SessionLineage { evidence: SessionLineageEvidence = .empty, includeHeuristicLinks: Bool = true ) -> SessionLineageForest { + resolveWithIndex( + sessions: sessions, + evidence: evidence, + includeHeuristicLinks: includeHeuristicLinks).forest + } + + public static func resolveWithIndex( + sessions: [SessionSummary], + evidence: SessionLineageEvidence = .empty, + includeHeuristicLinks: Bool = true + ) -> SessionLineageResolution { + try! buildResolution( + sessions: sessions, + evidence: evidence, + includeHeuristicLinks: includeHeuristicLinks, + checkCancellation: false) + } + + public static func resolveWithIndexCancellable( + sessions: [SessionSummary], + evidence: SessionLineageEvidence = .empty, + includeHeuristicLinks: Bool = true + ) throws -> SessionLineageResolution { + try buildResolution( + sessions: sessions, + evidence: evidence, + includeHeuristicLinks: includeHeuristicLinks, + checkCancellation: true) + } + + private static func buildResolution( + sessions: [SessionSummary], + evidence: SessionLineageEvidence, + includeHeuristicLinks: Bool, + checkCancellation: Bool + ) throws -> SessionLineageResolution { + func checkForCancellation() throws { + if checkCancellation { + try Task.checkCancellation() + } + } + + try checkForCancellation() let codexByID = Dictionary( evidence.codexThreads.map { ($0.threadID, $0) }, uniquingKeysWith: { first, _ in first }) var nodes: [String: MutableNode] = [:] var summaryByKey: [String: SessionSummary] = [:] - for summary in sessions { - let key = key(summary) + // Path standardization dominates key(): compute every session's key + // exactly once and thread the pair through every later join. + var keyedSessions: [(summary: SessionSummary, key: String)] = [] + keyedSessions.reserveCapacity(sessions.count) + for (index, summary) in sessions.enumerated() { + if index.isMultiple(of: 256) { try checkForCancellation() } + keyedSessions.append((summary, key(summary))) + } + for (summary, key) in keyedSessions { summaryByKey[key] = summary nodes[key] = MutableNode( session: LineageSessionReference( summary: summary, startedAt: evidence.sessionStartedAt[key] ?? (summary.provider == .codex - ? codexByID[summary.id]?.startedAt : nil)), + ? codexByID[summary.id]?.startedAt : nil), + stableKey: key), candidate: nil) } let lookupByProviderMachineID = Dictionary( - sessions.map { (lookupKey($0.provider, $0.machineID, $0.id), key($0)) }, + keyedSessions.map { + (lookupKey($0.summary.provider, $0.summary.machineID, + $0.summary.id), $0.key) + }, uniquingKeysWith: { first, _ in first }) let keyByProviderID = Dictionary( - sessions.map { ("\($0.provider.rawValue):\($0.id)", key($0)) }, + keyedSessions.map { + ("\($0.summary.provider.rawValue):\($0.summary.id)", $0.key) + }, uniquingKeysWith: { existing, candidate in existing.contains(":\(Machine.localID):") ? existing : candidate }) let keyByPath = Dictionary( - sessions.filter { !$0.filePath.isEmpty }.map { - (URL(fileURLWithPath: $0.filePath).standardizedFileURL.path, key($0)) + keyedSessions.filter { !$0.summary.filePath.isEmpty }.map { + (URL(fileURLWithPath: $0.summary.filePath).standardizedFileURL.path, + $0.key) }, uniquingKeysWith: { first, _ in first }) func actualKey(provider: Provider, machine: String, id: String) -> String? { lookupByProviderMachineID[lookupKey(provider, machine, id)] @@ -381,7 +473,9 @@ public enum SessionLineage { } // 1. Claude Agent/Task result joined to its directory child. - for child in sessions where child.provider == .claude && child.isSubagent { + for (child, childKey) in keyedSessions + where child.provider == .claude && child.isSubagent { + try checkForCancellation() guard let parentID = child.parentSessionID else { continue } let parentDirectory = URL(fileURLWithPath: child.filePath) .deletingLastPathComponent().deletingLastPathComponent() @@ -401,7 +495,7 @@ public enum SessionLineage { parentKey: verified ? parentKey : nil, missingParentID: parentKey == nil ? parentID : nil, kind: .subagent, confidence: .deterministic, - detail: nil, priority: 1), to: key(child)) + detail: nil, priority: 1), to: childKey) } else { // The parent transcript exists but records no matching spawn. // Never mis-attach — but never pretend this is an ordinary @@ -410,13 +504,14 @@ public enum SessionLineage { parentKey: nil, missingParentID: nil, kind: .subagent, confidence: .deterministic, detail: "subagent file — parent \(parentID) has no matching spawn record", - priority: 1), to: key(child)) + priority: 1), to: childKey) } } // 2. Remote/cloud task sidecars. A missing local transcript becomes a // metadata-only child; an exact task/session id reuses the real row. for remote in evidence.remoteTasks { + try checkForCancellation() let parentKey = anyKey(provider: .claude, id: remote.parentSessionID) let parent = parentKey.flatMap { summaryByKey[$0] } let actualKey = remote.sessionID.flatMap { @@ -455,6 +550,7 @@ public enum SessionLineage { // 3. Codex-native spawn/fork tree. for metadata in evidence.codexThreads { + try checkForCancellation() guard let childKey = anyKey( provider: .codex, id: metadata.threadID), let child = summaryByKey[childKey] else { continue } @@ -483,6 +579,7 @@ public enum SessionLineage { // 4. Claude → Codex import manifest bridge. Imported rollouts remain // source-deduplicated, so most children are intentionally metadata-only. for record in evidence.importRecords { + try checkForCancellation() let parentKey = keyByPath[ URL(fileURLWithPath: record.sourcePath).standardizedFileURL.path] let parent = parentKey.flatMap { summaryByKey[$0] } @@ -521,18 +618,28 @@ public enum SessionLineage { // 5. The only heuristic: cross-provider, non-interactive Codex child, // workspace-compatible and temporally inside the parent's observed tail. if includeHeuristicLinks { - let claudeParents = sessions.filter { $0.provider == .claude } - var claudeByExactWorkspace: [String: [SessionSummary]] = [:] - var claudeByWorkspaceName: [String: [SessionSummary]] = [:] - for parent in claudeParents where !parent.cwd.isEmpty { + struct HeuristicParent { + let key: String + let cwd: String + let lastActivity: Date? + } + var claudeByExactWorkspace: [String: [HeuristicParent]] = [:] + var claudeByWorkspaceName: [String: [HeuristicParent]] = [:] + for (parent, parentKey) in keyedSessions + where parent.provider == .claude && !parent.cwd.isEmpty { + try checkForCancellation() let path = URL(fileURLWithPath: parent.cwd).standardizedFileURL.path + let entry = HeuristicParent( + key: parentKey, cwd: parent.cwd, + lastActivity: parent.lastActivity) claudeByExactWorkspace["\(parent.machineID):\(path)", default: []] - .append(parent) + .append(entry) let name = URL(fileURLWithPath: path).lastPathComponent claudeByWorkspaceName["\(parent.machineID):\(name)", default: []] - .append(parent) + .append(entry) } for metadata in evidence.codexThreads where metadata.isNonInteractive { + try checkForCancellation() guard let childKey = anyKey( provider: .codex, id: metadata.threadID), let child = summaryByKey[childKey], @@ -553,11 +660,11 @@ public enum SessionLineage { }) } let candidates = Dictionary( - pool.map { (key($0), $0) }, uniquingKeysWith: { first, _ in first }) + pool.map { ($0.key, $0) }, uniquingKeysWith: { first, _ in first }) .values.filter { activeWindowContains( started, - parentStart: evidence.sessionStartedAt[key($0)], + parentStart: evidence.sessionStartedAt[$0.key], parentLastActivity: $0.lastActivity) }.sorted { abs(($0.lastActivity ?? .distantPast).timeIntervalSince(started)) @@ -565,7 +672,7 @@ public enum SessionLineage { } guard let parent = candidates.first else { continue } offer(Candidate( - parentKey: key(parent), missingParentID: nil, + parentKey: parent.key, missingParentID: nil, kind: .orchestrated, confidence: .heuristic, detail: "linked by workspace + timing", priority: 5), to: childKey) @@ -582,6 +689,7 @@ public enum SessionLineage { ($0.1.priority, $0.0) < ($1.1.priority, $1.0) } for (child, candidate) in candidateOrder { + try checkForCancellation() guard let parent = candidate.parentKey, nodes[parent] != nil, parent != child else { continue } var cursor: String? = parent @@ -599,16 +707,22 @@ public enum SessionLineage { } let roots = nodes.keys.filter { acceptedParents[$0] == nil } - func materialize(_ nodeKey: String, depth: Int) -> LineageNode { + func materialize(_ nodeKey: String, depth: Int) throws -> LineageNode { + try checkForCancellation() let value = nodes[nodeKey]! let candidate = value.candidate - let children = (childrenByParent[nodeKey] ?? []).sorted { + let childKeys = (childrenByParent[nodeKey] ?? []).sorted { let lhs = nodes[$0]!.session let rhs = nodes[$1]!.session let ld = lhs.startedAt ?? lhs.lastActivity ?? .distantPast let rd = rhs.startedAt ?? rhs.lastActivity ?? .distantPast return ld == rd ? lhs.stableKey < rhs.stableKey : ld < rd - }.map { materialize($0, depth: depth + 1) } + } + var children: [LineageNode] = [] + children.reserveCapacity(childKeys.count) + for childKey in childKeys { + children.append(try materialize(childKey, depth: depth + 1)) + } let missing: String? = { guard acceptedParents[nodeKey] == nil, let candidate else { return nil } if let id = candidate.missingParentID { @@ -630,15 +744,39 @@ public enum SessionLineage { edgeDetail: acceptedParents[nodeKey] == nil ? nil : candidate?.detail) } - let materializedRoots = roots.map { materialize($0, depth: 0) }.sorted { - let ld = $0.session.lastActivity ?? .distantPast - let rd = $1.session.lastActivity ?? .distantPast - return ld == rd ? $0.id < $1.id : ld > rd + // Sort root KEYS before materializing (sorting materialized nodes + // deep-copies whole subtrees), and look each root up exactly once — + // dictionary probes inside the comparator hash long keys per compare. + struct RootOrder { + let key: String + let last: Date + let stable: String + } + var rootOrder: [RootOrder] = [] + rootOrder.reserveCapacity(roots.count) + for rootKey in roots { + try checkForCancellation() + let session = nodes[rootKey]!.session + rootOrder.append(RootOrder( + key: rootKey, + last: session.lastActivity ?? .distantPast, + stable: session.stableKey)) + } + rootOrder.sort { lhs, rhs in + lhs.last == rhs.last ? lhs.stable < rhs.stable : lhs.last > rhs.last + } + var materializedRoots: [LineageNode] = [] + materializedRoots.reserveCapacity(rootOrder.count) + for root in rootOrder { + materializedRoots.append(try materialize(root.key, depth: 0)) } - return SessionLineageForest( + let forest = SessionLineageForest( roots: materializedRoots, transcriptSessionCount: sessions.count, metadataOnlyCount: nodes.values.filter(\.session.isMetadataOnly).count) + return SessionLineageResolution( + forest: forest, + keyedSessions: keyedSessions) } public static func key(_ summary: SessionSummary) -> String { diff --git a/Sources/TrifolaKit/Stores.swift b/Sources/TrifolaKit/Stores.swift index 93788e3..7f1c91e 100644 --- a/Sources/TrifolaKit/Stores.swift +++ b/Sources/TrifolaKit/Stores.swift @@ -1384,6 +1384,7 @@ public struct SessionIndex: Sendable { public final class SessionStore: ObservableObject { @Published public private(set) var sessions: [SessionSummary] = [] @Published public private(set) var lineageEvidence = SessionLineageEvidence.empty + public private(set) var lineageEvidenceRevision = 0 /// Name sources outside the transcripts (live PID registry + /rename history). private let nameResolver: SessionNameResolver /// Codex's separate bounded MRU thread-name index. Kept provider-scoped when @@ -1557,6 +1558,7 @@ public final class SessionStore: ObservableObject { refreshedLineage.sessionStartedAt = result.sessionStartedAt if lineageEvidence != refreshedLineage { lineageEvidence = refreshedLineage + lineageEvidenceRevision += 1 } appliedCount = result.entries.count // Merge in any read-only remote mirrors (Cross-Machine Fleet). The remote diff --git a/Tests/TrifolaKitTests/NavigationSnapshotShapeTests.swift b/Tests/TrifolaKitTests/NavigationSnapshotShapeTests.swift index ad84e59..5eff31d 100644 --- a/Tests/TrifolaKitTests/NavigationSnapshotShapeTests.swift +++ b/Tests/TrifolaKitTests/NavigationSnapshotShapeTests.swift @@ -28,4 +28,15 @@ struct NavigationSnapshotShapeTests { #expect(source.contains("rows = Array(rows.prefix(400))")) #expect(source.contains("Task.detached(priority: .userInitiated)")) } + + @Test("Lineage memo follows corpus revisions and shares in-flight work") + func lineageMemoHasStableOwnership() throws { + let source = try snapshotSource + + #expect(source.contains("sessionsRevision: inputs.sessionsRevision")) + #expect(source.contains("evidenceRevision: inputs.lineageEvidenceRevision")) + #expect(source.contains("pending.source == lineageSource")) + #expect(source.contains("SessionLineage.resolveWithIndex")) + #expect(!source.contains("summaries.map { (SessionLineage.key($0), $0) }")) + } } diff --git a/Tests/TrifolaKitTests/SessionLineageTests.swift b/Tests/TrifolaKitTests/SessionLineageTests.swift index 1c9436b..ad6ea58 100644 --- a/Tests/TrifolaKitTests/SessionLineageTests.swift +++ b/Tests/TrifolaKitTests/SessionLineageTests.swift @@ -157,6 +157,39 @@ struct SessionLineageTests { #expect(Set(forest.roots.map(\.id)).count == 2) } + @Test("indexed resolution reuses each session's stable key") + func indexedResolutionCarriesStableKeys() throws { + let first = session( + "resumed-id", filePath: "/Users/dev/.sessions/first.jsonl") + let second = session( + "resumed-id", filePath: "/Users/dev/.sessions/second.jsonl") + let resolution = SessionLineage.resolveWithIndex( + sessions: [first, second]) + + #expect(resolution.key(for: first) == SessionLineage.key(first)) + #expect(resolution.key(for: second) == SessionLineage.key(second)) + #expect(resolution.forest.allNodes.count == 2) + #expect(resolution.forest == SessionLineage.resolve( + sessions: [first, second])) + } + + @Test("cancellable indexed resolution stops canceled work") + func indexedResolutionCooperatesWithCancellation() async { + let task = Task { + try SessionLineage.resolveWithIndexCancellable( + sessions: (0..<1_000).map { session("session-\($0)") }) + } + task.cancel() + + do { + _ = try await task.value + Issue.record("Expected lineage resolution cancellation") + } catch is CancellationError { + } catch { + Issue.record("Unexpected error: \(error)") + } + } + @Test("a heuristic link is refused when the would-be parent started after the child") func heuristicRespectsParentStart() throws { let parent = session("late-driver", lastOffset: 2_400)