diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index d0a8aa5..c9eeaa0 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -126,6 +126,17 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { /// `nil` until the summary producer is switched on in Settings, which is where it stays /// for anyone who never turns the experiment on. public var summary: LoopSummary? + /// What this node's session last said it was called — `/rename` inside Claude Code. + /// + /// Recorded so the rename fires **once per change** rather than once per poll. Without + /// it, the 15s tick would re-apply the session's name forever, and renaming a loop from + /// the sidebar would silently undo itself a few seconds later. Persisted for the same + /// reason: a daemon restart must not make an already-applied rename look new again. + /// + /// It is the last *observed* title, not the node's title — the two differ the moment a + /// human renames the card afterwards, and that difference is what keeps the card's name + /// theirs. + public var sessionTitle: String? /// The same run, drawn — a Mermaid flowchart or a table, composed once per finished pass /// and rendered natively by the rail (`SummaryBoard`). /// @@ -213,6 +224,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { usage: UsageSample? = nil, activity: String? = nil, summary: LoopSummary? = nil, + sessionTitle: String? = nil, board: SummaryBoard? = nil, presence: PresenceReading? = nil, metricHistory: [MetricSample] = [], @@ -242,6 +254,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { self.usage = usage self.activity = activity self.summary = summary + self.sessionTitle = sessionTitle self.board = board self.presence = presence self.metricHistory = metricHistory @@ -490,7 +503,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { case worktreeBinding, subGraph, pilotState, usage, metricHistory, createdBy case lastArtifactoryRead, artifactoryWatch case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly - case summary, board, heartbeatIntervalSeconds, stallReason + case summary, sessionTitle, board, heartbeatIntervalSeconds, stallReason case createdFromTemplateID, templateFollow, sessionRestarts } @@ -525,6 +538,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { // account of a run, and a resolved loop's is the thing worth reading after the fact. // It is bounded by construction, so a graph file cannot grow on it. summary = try container.decodeIfPresent(LoopSummary.self, forKey: .summary) + sessionTitle = try container.decodeIfPresent(String.self, forKey: .sessionTitle) // Survives a reload for the reason above, and one more: it cost a model call, and a // picture that has to be paid for again on every relaunch is a picture nobody keeps. board = try container.decodeIfPresent(SummaryBoard.self, forKey: .board) diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index e7c6d08..f9c855c 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -52,6 +52,7 @@ public actor GraphStore { /// What a working session has narrated, folded into `LoopNode.summary`. `nil` when /// nothing produces beats — no reader wired, or the human has left the producer off. private let onReadSummary: (@Sendable (LoopNode, String?) async -> SummaryReading?)? + private let onReadSessionTitle: (@Sendable (LoopNode, String?) async -> String?)? private let onReadPresence: (@Sendable (LoopNode, String?) async -> PresenceReading)? /// Whether a local loop's session is alive and not a husk — what decides if a pane /// closing may resolve the loop (`sessionPermitsResolution`). @@ -217,6 +218,7 @@ public actor GraphStore { onReadUsage: (@Sendable (LoopNode, String?) async -> UsageSample?)? = nil, onReadActivity: (@Sendable (LoopNode, String?) async -> String?)? = nil, onReadSummary: (@Sendable (LoopNode, String?) async -> SummaryReading?)? = nil, + onReadSessionTitle: (@Sendable (LoopNode, String?) async -> String?)? = nil, onReadPresence: (@Sendable (LoopNode, String?) async -> PresenceReading)? = nil, onSessionAlive: (@Sendable (LoopNode, String?) async -> Bool)? = nil, onSpawnIntoProject: (@Sendable (String, NodeDraft) -> Void)? = nil, @@ -249,6 +251,7 @@ public actor GraphStore { self.onReadUsage = onReadUsage self.onReadActivity = onReadActivity self.onReadSummary = onReadSummary + self.onReadSessionTitle = onReadSessionTitle self.onReadPresence = onReadPresence self.onSessionAlive = onSessionAlive self.onSpawnIntoProject = onSpawnIntoProject @@ -1004,6 +1007,34 @@ public actor GraphStore { return changed } + /// Applies a rename a human typed inside a session to the loop it belongs to (#261). + /// + /// A title is the one thing about a loop that is expected to be wrong: it is written + /// before the work exists, and what the loop turns out to be is known only once the + /// session is running. `/rename` is a human saying exactly that, and until this existed + /// the card went on showing the name they had just replaced. + /// + /// **Fires on change, not on sight.** `node.sessionTitle` records what the session was + /// last *observed* to call itself, which is not the same as what the node is called: the + /// two part company the moment someone renames the card afterwards, and that gap is what + /// keeps the card's name theirs instead of having it overwritten 15 seconds later. A + /// session nobody has renamed reports nothing and nothing here runs. + private func refreshSessionTitles() async -> Bool { + guard let onReadSessionTitle else { return false } + let path = graph.project.path + var renamed = false + for node in graph.nodes where !node.isResolved { + guard let observed = await onReadSessionTitle(node, path), observed != node.sessionTitle + else { continue } + graph.nodes[id: node.id]?.sessionTitle = observed + // Through the same door a human's rename comes through, so a blank title is refused + // and everything a rename deliberately leaves alone stays left alone. + renameNode(node.id, to: observed) + renamed = true + } + return renamed + } + /// Draws the passes that have ended since the last tick — the only reading here that /// costs money, and the only one that is allowed to skip work it could do. /// @@ -1165,6 +1196,13 @@ public actor GraphStore { var changed = await refreshPresence() if await refreshActivity() { changed = true } if await refreshSummary() { changed = true } + // Kept apart from `changed` because it alone has to reach disk. Every other reading on + // this tick is a field restored from the session next time anyone asks; a rename is + // the node's own title, and `sessionTitle` is the record that stops the following tick + // applying it a second time. Told to clients only, both would be gone at the next + // daemon restart — and the rename would then land again as if it were new. + let renamed = await refreshSessionTitles() + if renamed { changed = true } // After the summary and never beside it: a board is drawn *from* the merged summary, // so a pass that ended on this tick has to be counted before it can be drawn. if await refreshBoards() { changed = true } @@ -1172,7 +1210,7 @@ public actor GraphStore { // what was waiting on exactly that. await drainPendingFollowUps() guard changed else { return } - notifyClients() + if renamed { broadcast() } else { notifyClients() } } // MARK: - Renaming diff --git a/GraphcodeKit/Sources/ProjectRegistry.swift b/GraphcodeKit/Sources/ProjectRegistry.swift index d4916ee..5b3ef20 100644 --- a/GraphcodeKit/Sources/ProjectRegistry.swift +++ b/GraphcodeKit/Sources/ProjectRegistry.swift @@ -40,6 +40,7 @@ public actor ProjectRegistry { private let readUsage: (@Sendable (LoopNode, String?) async -> UsageSample?)? private let readActivity: (@Sendable (LoopNode, String?) async -> String?)? private let readSummary: (@Sendable (LoopNode, String?) async -> SummaryReading?)? + private let readSessionTitle: (@Sendable (LoopNode, String?) async -> String?)? private let readPresence: (@Sendable (LoopNode, String?) async -> PresenceReading)? private let sessionAlive: (@Sendable (LoopNode, String?) async -> Bool)? private let composeBoard: @@ -74,6 +75,8 @@ public actor ProjectRegistry { CLISessionBackend.readActivity, readSummary: (@Sendable (LoopNode, String?) async -> SummaryReading?)? = CLISessionBackend.readSummary, + readSessionTitle: (@Sendable (LoopNode, String?) async -> String?)? = + CLISessionBackend.readSessionTitle, readPresence: (@Sendable (LoopNode, String?) async -> PresenceReading)? = CLISessionBackend.readPresence, sessionAlive: (@Sendable (LoopNode, String?) async -> Bool)? = CLISessionBackend.sessionAlive, @@ -92,6 +95,7 @@ public actor ProjectRegistry { self.readUsage = readUsage self.readActivity = readActivity self.readSummary = readSummary + self.readSessionTitle = readSessionTitle self.readPresence = readPresence self.sessionAlive = sessionAlive self.composeBoard = composeBoard @@ -582,6 +586,7 @@ public actor ProjectRegistry { onReadUsage: readUsage, onReadActivity: readActivity, onReadSummary: readSummary, + onReadSessionTitle: readSessionTitle, onReadPresence: readPresence, onSessionAlive: sessionAlive, onSpawnIntoProject: spawnIntoProject, diff --git a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift index 227e9df..a2c94b8 100644 --- a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift +++ b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift @@ -56,6 +56,11 @@ public struct CLISessionBackend: Sendable { /// `LoopNode.summary` by `GraphStore`, never written straight onto the node — see /// `LoopSummary.merge` for why the store has the last word. public var summary: @Sendable (LoopNode, String?) async -> SummaryReading? + /// What the session says it is *called*, or `nil` when the backend has no such notion. + /// A human renaming a session from inside it is the one thing that can tell graphcode a + /// loop's title is wrong, and only Claude Code writes it down — see + /// `ClaudeSessionLog.sessionTitle`. + public var sessionTitle: @Sendable (LoopNode, String?) async -> String? public init( kind: CLISessionBackendKind, @@ -66,7 +71,8 @@ public struct CLISessionBackend: Sendable { presence: @escaping @Sendable (LoopNode, String?) async -> PresenceReading, usage: @escaping @Sendable (LoopNode, String?) async -> UsageSample?, activity: @escaping @Sendable (LoopNode, String?) async -> String? = { _, _ in nil }, - summary: @escaping @Sendable (LoopNode, String?) async -> SummaryReading? = { _, _ in nil } + summary: @escaping @Sendable (LoopNode, String?) async -> SummaryReading? = { _, _ in nil }, + sessionTitle: @escaping @Sendable (LoopNode, String?) async -> String? = { _, _ in nil } ) { self.kind = kind self.launch = launch @@ -77,6 +83,7 @@ public struct CLISessionBackend: Sendable { self.usage = usage self.activity = activity self.summary = summary + self.sessionTitle = sessionTitle } } @@ -179,6 +186,19 @@ extension CLISessionBackend { guard let reading else { return nil } return await SummaryModelWriter.applied( to: reading, node: node, projectPath: projectPath, settings: settings) + }, + // Ungated by `summarisesLoops`, unlike the rail above: that setting is about how + // closely graphcode watches a session, and this is a human having already said what + // the loop is called. Suppressing it would be reading the answer and discarding it. + sessionTitle: { node, projectPath in + switch kind { + case .claudeCode: + return await ClaudeSessionLog.sessionTitle(of: node, projectPath: projectPath) + case .copilotCLI, .codex, .openCode: + // None of the three has a rename that leaves a record behind. `nil` is the + // honest answer; a guess here would rename a loop from a string the CLI chose. + return nil + } } ) } @@ -271,6 +291,12 @@ extension CLISessionBackend { await backend(for: node).summary(node, path) } + /// The session-title hook `GraphStore` is wired with. + public static let readSessionTitle: @Sendable (LoopNode, String?) async -> String? = { + node, path in + await backend(for: node).sessionTitle(node, path) + } + /// The presence-reading hook `GraphStore` is wired with. The last missing link in a /// chain that was otherwise complete: `presence` was implemented on every adapter and /// called by nothing, so every surface had only `LoopState` to go on and a loop that diff --git a/GraphcodeKit/Sources/Sessions/ClaudeSessionLog.swift b/GraphcodeKit/Sources/Sessions/ClaudeSessionLog.swift index 18e6096..8ea6154 100644 --- a/GraphcodeKit/Sources/Sessions/ClaudeSessionLog.swift +++ b/GraphcodeKit/Sources/Sessions/ClaudeSessionLog.swift @@ -234,6 +234,53 @@ public enum ClaudeSessionLog { let reading = reading(inTranscriptAt: transcript, metricSamples: node.metricHistory) return reading.isEmpty ? nil : reading } + + /// The name a human gave this session with `/rename`, or `nil` when nobody has. + /// + /// Claude Code writes it as a standalone record — + /// `{"type":"custom-title","customTitle":"…"}` — and re-emits it at every checkpoint + /// rather than once at the rename, so a tail read always carries the current one and the + /// last one wins. + /// + /// **Only `custom-title`.** Every session also carries an `agent-name` record, and it is + /// not the same thing: it holds a name the CLI assigned itself (`angleReuse2`) and is + /// present in sessions nobody has renamed. Reading it would rename half a graph to + /// strings no human ever typed. + static func customTitle(inLines lines: [Data]) -> String? { + var latest: String? + for line in lines { + guard let object = try? JSONSerialization.jsonObject(with: line), + let record = object as? [String: Any], + record["type"] as? String == "custom-title", + let title = record["customTitle"] as? String + else { continue } + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + latest = trimmed + } + return latest + } + + /// What this node's session calls itself, or `nil` when nothing does. + /// + /// **Local only, deliberately.** A remote loop's transcript is on the other machine, and + /// every reading of it costs an ssh probe per node per poll. The summary rail pays that + /// because a rail changes every few seconds; a title changes when a human types six + /// characters, perhaps once in a loop's life. Reported as "nothing said" rather than + /// bought at that price — the same answer this gives a backend that writes no transcript. + /// Gated on the transcript having moved, like the rail is, and for a sharper reason: the + /// summary rail is off by default, so nothing was reading transcripts on a default + /// install at all. Ungated this would have added a 512KB tail read per loop every + /// fifteen seconds to every install there is, to re-read a name that changes when a + /// human types `/rename`. A quiet loop now costs one `stat`. + public static func sessionTitle(of node: LoopNode, projectPath: String? = nil) async -> String? { + guard projectPath.flatMap(RemoteProjectLocation.parse(projectPath:)) == nil, + let sessionID = SessionIDStore.load(forNodeID: node.id), + let transcript = transcript(forSessionID: sessionID), + await TranscriptFreshness.titles.hasChanged(transcript, forNode: node.id) + else { return nil } + return customTitle(inLines: SummaryBeatBuilder.tailLines(of: transcript)) + } } /// How the one number the rail carries is written. diff --git a/GraphcodeKit/Sources/Sessions/TranscriptFreshness.swift b/GraphcodeKit/Sources/Sessions/TranscriptFreshness.swift index 4962cc9..4ebe1cc 100644 --- a/GraphcodeKit/Sources/Sessions/TranscriptFreshness.swift +++ b/GraphcodeKit/Sources/Sessions/TranscriptFreshness.swift @@ -23,6 +23,14 @@ import Foundation actor TranscriptFreshness { static let shared = TranscriptFreshness() + /// The session-title reader's own instance (`ClaudeSessionLog.sessionTitle`). + /// + /// A second instance rather than a second caller of `shared`, because `hasChanged` is + /// consuming by design: it records the date it just saw, so of two readers asking about + /// the same file on the same tick the second is always told "unchanged". Sharing it + /// would have left the summary rail and the title reader taking turns to work. + static let titles = TranscriptFreshness() + private var seen: [UUID: Date] = [:] /// True when `url` has been modified since this node last read it — and records the new diff --git a/graphcode/Tests/SessionRenameTests.swift b/graphcode/Tests/SessionRenameTests.swift new file mode 100644 index 0000000..2c958f4 --- /dev/null +++ b/graphcode/Tests/SessionRenameTests.swift @@ -0,0 +1,255 @@ +import Foundation +import Testing + +@testable import GraphcodeKit + +/// `/rename` inside a session, and the loop it renames (#261). +/// +/// Two halves, tested apart. Reading a session's chosen name out of the transcript is a +/// question about one record type among a dozen, and applying it is a question about how +/// often — the poll runs every fifteen seconds and a title is a human's to keep, so a rule +/// that applied what it saw would undo the human on the next tick. +@Suite +struct SessionRenameTests { + + // MARK: - Reading the name out of a transcript + + private func lines(_ objects: [String]) -> [Data] { + objects.compactMap { $0.data(using: .utf8) } + } + + @Test + func theLastCustomTitleWins() { + // Claude Code re-emits the record at every checkpoint rather than once at the rename, + // so a tail carries the whole history of one and only the newest is the answer. + let found = ClaudeSessionLog.customTitle( + inLines: lines([ + #"{"type":"custom-title","customTitle":"first","sessionId":"s"}"#, + #"{"type":"assistant","message":{"content":[]}}"#, + #"{"type":"custom-title","customTitle":"second","sessionId":"s"}"#, + ])) + #expect(found == "second") + } + + /// The distinction the whole feature rests on. + /// + /// Every session carries an `agent-name` record holding a name the CLI assigned itself — + /// `angleReuse2` and the like — and it is present in sessions nobody has ever renamed. + /// Reading it as a rename would retitle most of a graph to strings no human typed, which + /// is a far worse bug than the one being fixed. + @Test + func anAgentNameIsNotARename() { + let found = ClaudeSessionLog.customTitle( + inLines: lines([ + #"{"type":"agent-name","agentName":"angleReuse2","sessionId":"s"}"# + ])) + #expect(found == nil) + } + + @Test + func aBlankTitleIsNotARename() { + let found = ClaudeSessionLog.customTitle( + inLines: lines([ + #"{"type":"custom-title","customTitle":"named","sessionId":"s"}"#, + #"{"type":"custom-title","customTitle":" ","sessionId":"s"}"#, + ])) + #expect(found == "named") + } + + @Test + func aTranscriptNobodyRenamedSaysNothing() { + let found = ClaudeSessionLog.customTitle( + inLines: lines([ + #"{"type":"user","message":{"role":"user","content":"hi"}}"#, + #"not json at all"#, + ])) + #expect(found == nil) + } + + /// The two halves `sessionTitle` composes, over a file shaped like a real transcript: + /// checkpoint records repeated as the session runs, each `custom-title` paired with the + /// `agent-name` that must not be mistaken for it, and the rename arriving partway + /// through rather than at the top. + @Test + func theTailReadFindsTheNameInARealisticTranscript() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-rename-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let url = root.appendingPathComponent("transcript.jsonl") + + var records = [#"{"type":"agent-name","agentName":"angleReuse2","sessionId":"s"}"#] + for _ in 0..<3 { + records.append(#"{"type":"assistant","message":{"content":[],"stop_reason":"end_turn"}}"#) + records.append(#"{"type":"agent-name","agentName":"angleReuse2","sessionId":"s"}"#) + } + for _ in 0..<3 { + records.append(#"{"type":"custom-title","customTitle":"hello","sessionId":"s"}"#) + records.append(#"{"type":"agent-name","agentName":"hello","sessionId":"s"}"#) + } + try records.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8) + + #expect(ClaudeSessionLog.customTitle(inLines: SummaryBeatBuilder.tailLines(of: url)) == "hello") + } + + /// A remote loop's transcript is on the other machine, and this must not go looking for + /// it: every reading of one costs an ssh probe per node per poll, which is a price worth + /// paying for a rail that changes every few seconds and not for a name that changes when + /// somebody types six characters. + @Test + func aRemoteLoopIsNotProbed() async { + let node = LoopNode(title: "Remote", loopType: .goalBased, goal: GoalSpec(summary: "done")) + #expect( + await ClaudeSessionLog.sessionTitle(of: node, projectPath: "ssh://host/srv/repo") == nil) + #expect( + await ClaudeSessionLog.sessionTitle( + of: node, projectPath: "codespace://humble-dollop/repo") == nil) + } + + // MARK: - Applying it to the loop + + private func node(_ title: String, _ state: LoopState = .running) -> LoopNode { + LoopNode(title: title, loopType: .goalBased, goal: GoalSpec(summary: "done"), state: state) + } + + private func graph(_ nodes: [LoopNode]) -> LoopGraph { + var graph = LoopGraph(scope: LoopGraphScope(projectPath: "/tmp/p", name: "p")) + for node in nodes { graph.nodes.append(node) } + return graph + } + + /// `addConnection` broadcasts immediately and drops a connection it cannot write to, so + /// a placeholder descriptor would leave the store clientless and `pollPresence` would + /// return before doing anything — every assertion below passing for the wrong reason. + private func attach(to store: GraphStore) async -> Int32 { + let descriptor = open("/dev/null", O_WRONLY) + await store.addConnection(id: UUID(), fileDescriptor: descriptor) + return descriptor + } + + private func store( + _ nodes: [LoopNode], titles: TitleProbe, onGraphChanged: (@Sendable (LoopGraph) -> Void)? = nil + ) -> GraphStore { + GraphStore( + graph: graph(nodes), + onGraphChanged: onGraphChanged, + onReadSessionTitle: { node, _ in await titles.read(node) }, + onReadPresence: { _, _ in PresenceReading(presence: .busy, confidence: .reported) }) + } + + @Test + func aRenamedSessionRenamesItsLoop() async { + let titles = TitleProbe() + await titles.answer("New Loop", with: "hello") + let store = store([node("New Loop")], titles: titles) + let descriptor = await attach(to: store) + defer { close(descriptor) } + + await store.pollPresence() + + #expect(await store.graph.nodes[0].title == "hello") + #expect(await store.graph.nodes[0].sessionTitle == "hello") + } + + /// The guard that makes this safe to run every fifteen seconds. + /// + /// Once a name has been applied, the card is the human's again. Renaming it from the + /// sidebar has to stick — and it only can because the node remembers what the *session* + /// was called, which is no longer what the node is called. + @Test + func aRenameFromTheSidebarSurvivesTheNextPoll() async { + let titles = TitleProbe() + await titles.answer("New Loop", with: "hello") + let store = store([node("New Loop")], titles: titles) + let descriptor = await attach(to: store) + defer { close(descriptor) } + await store.pollPresence() + + let nodeID = await store.graph.nodes[0].id + await store.handle(.renameNode(nodeID, title: "Chosen By Hand")) + // The session still says the same thing it said before; nothing has changed but time. + await titles.answer("Chosen By Hand", with: "hello") + await store.pollPresence() + + #expect(await store.graph.nodes[0].title == "Chosen By Hand") + } + + @Test + func aSecondSessionRenameStillWins() async { + let titles = TitleProbe() + await titles.answer("New Loop", with: "hello") + let store = store([node("New Loop")], titles: titles) + let descriptor = await attach(to: store) + defer { close(descriptor) } + await store.pollPresence() + + await titles.answer("hello", with: "hello again") + await store.pollPresence() + + #expect(await store.graph.nodes[0].title == "hello again") + } + + /// Unlike every other reading on the poll, this one has to reach disk. + /// + /// The title is the node's own, and `sessionTitle` is what stops the following tick + /// applying the same rename a second time. Broadcast to clients but never saved, both + /// would be gone at the next daemon restart — and the rename would land again as new, + /// overwriting whatever the human had renamed the card to in between. + @Test + func aSessionRenameIsPersistedThoughThePollUsuallyIsNot() async { + let titles = TitleProbe() + await titles.answer("New Loop", with: "hello") + let saved = GraphBox() + let store = store( + [node("New Loop")], titles: titles, + onGraphChanged: { graph in Task { await saved.record(graph) } }) + let descriptor = await attach(to: store) + defer { close(descriptor) } + + await store.pollPresence() + + #expect(await saved.awaitTitle("hello")) + } + + /// A resolved loop's session is over, so it cannot be renamed — and asking anyway costs + /// a walk of every project directory on the machine, per node, every fifteen seconds. + @Test + func aResolvedLoopIsNotAsked() async { + let titles = TitleProbe() + let store = store([node("Done", .succeeded)], titles: titles) + let descriptor = await attach(to: store) + defer { close(descriptor) } + + await store.pollPresence() + + #expect(await titles.asked.isEmpty) + } + + private actor TitleProbe { + private var answers: [String: String] = [:] + private(set) var asked: [String] = [] + + func answer(_ title: String, with sessionTitle: String) { answers[title] = sessionTitle } + + func read(_ node: LoopNode) -> String? { + asked.append(node.title) + return answers[node.title] + } + } + + private actor GraphBox { + private var graphs: [LoopGraph] = [] + + func record(_ graph: LoopGraph) { graphs.append(graph) } + + func awaitTitle(_ title: String, attempts: Int = 50) async -> Bool { + for _ in 0..