diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index 3ec8476b..d0a8aa54 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -375,10 +375,10 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { } /// Loops `graphcoded` is responsible for keeping alive across its own restarts, - /// because nothing else would restart them — as opposed to a turn-based node, which a - /// human opening is what starts. + /// because nothing else would restart them — as opposed to a turn-based node or a + /// sketch, which a human opening is what starts. See `LoopType.runsUnattended`. public var runsUnattended: Bool { - loopType == .timeBased || loopType == .goalBased + loopType.runsUnattended } /// The state a surface should show, which is `state` corrected by what the session is diff --git a/GraphcodeKit/Sources/Domain/LoopType.swift b/GraphcodeKit/Sources/Domain/LoopType.swift index 7747b66a..46b7b7b1 100644 --- a/GraphcodeKit/Sources/Domain/LoopType.swift +++ b/GraphcodeKit/Sources/Domain/LoopType.swift @@ -30,4 +30,14 @@ public enum LoopType: String, Codable, CaseIterable, Sendable { /// far carries that string, and a daemon or CLI in `~/.graphcode/bin` can be a version /// behind the app that wrote it — so the on-disk word outlives the vocabulary change. case composite = "proactive" + + /// Whether `graphcoded` starts this loop's session and keeps it alive across its own + /// restarts, because nothing else would. A turn-based loop or a sketch is *attended*: + /// it only ever runs because a human opened it, so whichever pane opens it owns the + /// launch — locally and, over ssh, on the remote host. Every gate that decides who + /// launches must read this rather than name `turnBased`, which is how remote sketches + /// came to wait forever for a daemon that deliberately never starts them (#253). + public var runsUnattended: Bool { + self == .timeBased || self == .goalBased + } } diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Remote.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Remote.swift index b49fc990..3668469b 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Remote.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Remote.swift @@ -76,8 +76,9 @@ extension GhosttyTerminalView { /// A loop the daemon will never restore — resolved, killed — leaves the pane at /// that banner until the human closes it: visibly waiting, never silently /// relaunching. - /// - A **turn-based** loop has no other restorer — the daemon deliberately never - /// starts one — so the pane restores it itself: `restoreScript`, whose fresh + /// - An **attended** loop (turn-based, or a sketch — `LoopType.runsUnattended`) has + /// no other restorer — the daemon deliberately never starts one — so the pane + /// restores it itself: `restoreScript`, whose fresh /// fall-through is also the legitimate first launch of a loop nothing has banked /// an ID for. Only the connect writes the boot marker on that create /// (`freshPrefix`); the reconnect's restore runs behind a *proven* reboot and @@ -137,7 +138,7 @@ extension GhosttyTerminalView { } let restorePreparation = "if cd \(quoted(location.remotePath)); then " + hooksWrite let connectMissing: String - if loopType == .turnBased { + if !loopType.runsUnattended { connectMissing = restoreScript( preparation: restorePreparation, promptExport: promptExport, @@ -152,7 +153,7 @@ extension GhosttyTerminalView { } let connect = delivery + reattachOrRetry("connect") + connectMissing let rebootBranch: String - if loopType == .turnBased { + if !loopType.runsUnattended { rebootBranch = #"printf '\033[1;33m── Remote machine rebooted; restoring the session. ──\033[0m\r\n'; "# + restoreScript( @@ -175,7 +176,7 @@ extension GhosttyTerminalView { return (connect, reconnect) } - /// The turn-based restore a missing session sends both dials into: the preparation + /// The attended restore a missing session sends both dials into: the preparation /// the caller assembled (cd, hooks — the connect's delivery already ran), then /// resume-or-fresh. /// diff --git a/graphcode/Tests/RemoteLoopSurvivalTests.swift b/graphcode/Tests/RemoteLoopSurvivalTests.swift index fae5a79c..d68a0ab9 100644 --- a/graphcode/Tests/RemoteLoopSurvivalTests.swift +++ b/graphcode/Tests/RemoteLoopSurvivalTests.swift @@ -375,6 +375,41 @@ struct RemoteLoopSurvivalTests { #expect(connectLine[fresh.upperBound...].contains(".graphcode/boots")) } + @Test + func aSketchConnectLaunchesItsOwnSessionInsteadOfWaitingForTheDaemon() throws { + // A sketch is attended exactly like a turn-based loop — graphcoded never starts + // one — but the connect's missing branch keyed on `.turnBased` and sent every + // other type to wait for the daemon. A remote sketch therefore waited forever; + // in a Codespace, where gh flattens the retry exit to 1, the redial found no + // session and no boot marker and closed the pane as "ended while disconnected" + // (#253). Both dials now give a sketch the same self-restore a turn-based loop gets. + let nodeID = UUID() + let view = agentSurface(nodeID: nodeID, loopType: .sketch) + let script = try #require(view.remoteCommand(at: location, settings: GraphcodeSettings()).last) + #expect(!script.contains("waiting for graphcoded")) + #expect(!script.contains("waiting for the loop session")) + let connectLine = try #require(script.range(of: "while :; do").map { script[..<$0.lowerBound] }) + #expect(connectLine.contains("\(nodeID.uuidString).id")) + #expect(connectLine.contains(#"--resume "$GRAPHCODE_RESUME_ID""#)) + #expect(connectLine.contains("connect fresh")) + let loopBody = try #require(script.range(of: "while :; do").map { script[$0.upperBound...] }) + #expect(loopBody.contains("restoring the session")) + #expect(loopBody.contains("reboot fresh")) + } + + @Test + func aCopilotSketchConnectLaunchesFresh() throws { + // The backend #253 was filed against. Copilot cannot resume, so the restore has + // no banked ID to consume and goes straight to the prompt-bearing first launch — + // which is still the pane's to make, not the daemon's. + let view = agentSurface(backend: .copilotCLI, loopType: .sketch) + let script = try #require(view.remoteCommand(at: location, settings: GraphcodeSettings()).last) + #expect(!script.contains("waiting for graphcoded")) + let connectLine = try #require(script.range(of: "while :; do").map { script[..<$0.lowerBound] }) + #expect(connectLine.contains("connect fresh")) + #expect(connectLine.contains("copilot")) + } + @Test func aCopilotPaneBanksTheResumeIDOnEveryLiveJoin() throws { // A turn-based Copilot loop never gets an ensure dial, so the pane's attach-live