Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions GraphcodeKit/Sources/Domain/LoopNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions GraphcodeKit/Sources/Domain/LoopType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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.
///
Expand Down
35 changes: 35 additions & 0 deletions graphcode/Tests/RemoteLoopSurvivalTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading