From 2b3f678a719e5abe68e350000908c7616722451f Mon Sep 17 00:00:00 2001 From: scgopi Date: Wed, 2 Sep 2026 19:33:41 -0700 Subject: [PATCH] Fix remote Main loops closing on connect: the pane, not graphcoded, launches a sketch (#253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Main (sketch) loop opened on a remote project — a Codespace in the report — never reached its agent. The connect dial's missing-session branch keyed on `loopType == .turnBased` and sent every other type to wait for graphcoded, but a sketch is attended exactly like a turn-based loop: `runsUnattended` is goal-or-time only, so the daemon deliberately never ensures one and nothing ever started it. Over gh, where every nonzero exit flattens to 1, the retry loop redialed, found no session and no boot marker, and closed the pane with "Remote session ended while disconnected" — the transcript in the issue. The ownership split (8a27eb1, 192e450) predates the sketch type (5f7e85b), which never taught the remote dial about itself. Local sketches were unaffected. - `LoopType.runsUnattended` is the one rule for who launches; `LoopNode`'s property delegates to it. - Both remote dials gate the pane-owned restore on `!runsUnattended`, so a sketch gets the same resume-or-fresh launch a turn-based loop gets, on connect and behind a proven reboot. - Regression tests for the Claude and Copilot sketch dials. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Pxwzz1AZnVE3peZ8qMejLa --- GraphcodeKit/Sources/Domain/LoopNode.swift | 6 ++-- GraphcodeKit/Sources/Domain/LoopType.swift | 10 ++++++ .../Ghostty/GhosttyTerminalView+Remote.swift | 11 +++--- graphcode/Tests/RemoteLoopSurvivalTests.swift | 35 +++++++++++++++++++ 4 files changed, 54 insertions(+), 8 deletions(-) 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