From a02d8d2f4c897b4f50fbb1579ee626b2111c207b Mon Sep 17 00:00:00 2001 From: scgopi Date: Sun, 6 Sep 2026 18:07:11 -0700 Subject: [PATCH] Let the daemon own an unattended relaunch; the pane waits for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restarting a loop from the app started a fresh session instead of resuming: the agent came up on its opening prompt, so a goal-based loop re-issued `/goal` and the conversation the restart existed to keep was orphaned. The restart machinery was not at fault. `graphcoded` kills and then starts with `--resume`, and the dial log says so — but the app remounts the pane on the same beat, and `localResumeOrFreshCommand`'s unbanked fallback had no `zmx get` guard and no resume branch. It unconditionally created the session fresh, and beat the daemon's detached start often enough to be the bug. Harmless while a session is alive, because `zmx attach` ignores its command argument then; fatal in the window right after a restart's kill. Local Copilot took that branch every time. Nothing banks a Copilot session id on this machine — `CopilotSessionLog`'s banker is the remote ensure's, and Copilot has no `SessionStart` hook the way Claude Code does — so `SessionIDStore.load` is permanently nil for it locally and the resume branch was unreachable. Claude Code, OpenCode and Codex all bank, so they resumed. So an unattended loop's pane now waits for the session the daemon is starting and attaches to it, rather than racing it with a launch that carries the prompt. That is the same ownership split the remote pane already makes for a missing unattended session, and the same one the Codex pane makes for every launch. An attended loop keeps the fresh launch: no daemon starts a turn-based loop, so a pane that waited for one would wait out its whole minute. `waitingAttachCommand` gains a dial line, since waiting was otherwise the one launch decision the log could not tell apart from a pane that never opened. Measured on the reported case — a Copilot goal loop open in the app logged `restart killed`, `ensure resume` and `open fresh` in the same second, twice over two presses, and the surviving process was the pane's fresh launch. The same backend and loop type with no pane open resumed on both restarts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KdnA1YedzfZVxRsNVGuKAN --- .../Sources/Sessions/ZmxSessionLauncher.swift | 8 +++-- .../Ghostty/GhosttyTerminalView.swift | 27 ++++++++++++++- graphcode/Tests/LocalSessionResumeTests.swift | 34 ++++++++++++++++--- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index e96b9663..bf7e7885 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -705,8 +705,11 @@ public enum ZmxSessionLauncher { return command } + /// `logFragment` names the branch in the dial log the way every other launch decision + /// does — a pane that waited and one that launched have to be told apart afterwards, + /// and waiting used to be the silent one. public static func waitingAttachCommand( - zmxPath: String, sessionName: String, agent: String? + zmxPath: String, sessionName: String, agent: String?, logFragment: String? = nil ) -> [String] { let check = daemonReadyCheckCommand( zmxPath: zmxPath, sessionName: sessionName, agent: agent) @@ -717,7 +720,8 @@ public enum ZmxSessionLauncher { // deleted mid-wait. Unbounded, the pane polls `zmx ls` twenty times a second // forever; bounded, it says so and gives up after a minute. let script = - "tries=0; until \(check); do tries=$((tries+1)); " + (logFragment.map { "\($0); " } ?? "") + + "tries=0; until \(check); do tries=$((tries+1)); " + "if [ \"$tries\" -ge 600 ]; then " + "echo \"graphcode: '\(sessionName)' never became ready to attach\"; exit 1; fi; " + "sleep 0.1; done; exec \(attach)" diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift index 6519a4ac..3e496b10 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift @@ -367,7 +367,9 @@ struct GhosttyTerminalView: NSViewRepresentable { /// `nil` only for a surface that is not a node's. A backend that cannot resume, or a /// node with nothing banked, takes the ordinary fresh launch — logged, where it used /// to be the one silent branch (the duplicate-session investigation of 2026-09-02 - /// started from exactly that silence; the revert of #248/#249 keeps the dial). + /// started from exactly that silence; the revert of #248/#249 keeps the dial) — unless + /// the loop is unattended, in which case that launch belongs to the daemon and this + /// waits for it instead. func localResumeOrFreshCommand(agentLaunch: [String]) -> [String]? { guard let nodeID = SurfaceRef.nodeID(fromZmxSessionName: sessionName) else { return nil } let log = { (event: String) in @@ -380,6 +382,29 @@ struct GhosttyTerminalView: NSViewRepresentable { let resumeLaunch = resumeCommand( settings: settings, hooksFile: presenceHooksFile(), remoteSettingsPath: nil) else { + // Nothing banked means there is no resume for this pane to make. It does *not* mean + // a fresh launch is this pane's to make: `graphcoded` owns an unattended loop's + // relaunch and, on a restart, is already making it (`ZmxSessionLauncher.restart` + // kills and then starts). Creating the session here races that, and wins often + // enough to have been the bug — the agent comes up on `initialPrompt` instead of the + // conversation, so the goal is re-issued and the transcript the restart existed to + // keep is orphaned, with the daemon's `ensure resume` in the dial log one second + // away saying it did the right thing. + // + // Local Copilot is where it bit, because this is the only branch it can ever take: + // nothing banks a Copilot session id on this machine — `CopilotSessionLog`'s banker + // is the remote ensure's, and Copilot has no `SessionStart` hook the way Claude Code + // does — so `SessionIDStore.load` is permanently nil for it here. + // + // Waiting is the same ownership split the remote pane already makes for a missing + // unattended session, and the same one the Codex pane makes for every launch. An + // attended loop keeps the fresh launch: no daemon will ever start one, so a pane + // that waited for it would wait out its whole minute. + if loopType.runsUnattended { + return ZmxSessionLauncher.waitingAttachCommand( + zmxPath: zmx, sessionName: sessionName, agent: nil, + logFragment: DialLog.fragment(session: sessionName, dial: "open", event: "await-daemon")) + } return ["/bin/sh", "-c", log("fresh") + "exec \(fresh)"] } let quoted = RemoteProjectLocation.shellQuoted diff --git a/graphcode/Tests/LocalSessionResumeTests.swift b/graphcode/Tests/LocalSessionResumeTests.swift index ddc6b5ad..83053673 100644 --- a/graphcode/Tests/LocalSessionResumeTests.swift +++ b/graphcode/Tests/LocalSessionResumeTests.swift @@ -21,12 +21,13 @@ struct LocalSessionResumeTests { private let projectPath = "/tmp/widget" private func surface( - nodeID: UUID = UUID(), backend: CLISessionBackendKind = .claudeCode + nodeID: UUID = UUID(), backend: CLISessionBackendKind = .claudeCode, + loopType: LoopType = .goalBased ) -> GhosttyTerminalView { GhosttyTerminalView( surfaceID: nodeID, sessionName: SurfaceRef(id: nodeID, launchesClaudeCode: true).zmxSessionName, - launchesClaudeCode: true, backend: backend, loopType: .goalBased, + launchesClaudeCode: true, backend: backend, loopType: loopType, initialPrompt: "Work toward this goal until it is met: ship it", workingDirectory: projectPath, projectPath: projectPath, onProcessExited: { _ in }) } @@ -61,15 +62,17 @@ struct LocalSessionResumeTests { } @Test - func aNodeWithNothingBankedStillLaunchesFresh() throws { + func anAttendedNodeWithNothingBankedStillLaunchesFresh() throws { let nodeID = UUID() - let view = surface(nodeID: nodeID) + let view = surface(nodeID: nodeID, loopType: .turnBased) let command = withBankedID(nil, forNode: nodeID) { view.localResumeOrFreshCommand(agentLaunch: ["claude", "the prompt"]) } // Nothing to resume is the first launch, and the fresh argv is what it gets — // behind its own dial line now. The launch used to be the one silent branch, // which is why the duplicate-session investigation of 2026-09-02 started blind. + // Attended, so this pane really is the only launcher: no daemon starts a turn-based + // loop, and a pane that waited for one would wait out its whole minute. #expect(command?.first == "/bin/sh") let script = try #require(command?.last) #expect(script.contains("open fresh")) @@ -78,6 +81,29 @@ struct LocalSessionResumeTests { #expect(!script.contains("--resume")) } + /// The restart bug: `graphcoded` kills the session and starts it again with `--resume`, + /// and the app remounts the pane on the same beat. With nothing banked the pane used to + /// launch *fresh* — creating the session the daemon was about to resume, winning often + /// enough that the agent came up on its goal instead of its conversation. Local Copilot + /// took this branch every time, because nothing banks its session id on this machine. + @Test + func anUnattendedNodeWithNothingBankedWaitsForTheDaemonInsteadOfRacingIt() throws { + for loopType in [LoopType.goalBased, .timeBased] { + let nodeID = UUID() + let view = surface(nodeID: nodeID, backend: .copilotCLI, loopType: loopType) + let command = withBankedID(nil, forNode: nodeID) { + view.localResumeOrFreshCommand(agentLaunch: ["copilot", "the prompt"]) + } + let script = try #require(command?.last) + // It waits on the session the daemon creates, and attaches bare when it appears. + #expect(script.contains("until")) + #expect(script.contains("open await-daemon")) + #expect(script.contains("attach")) + // The whole point: the opening prompt is never typed, so the goal is not re-issued. + #expect(!script.contains("the prompt")) + } + } + @Test func codexResumesFromItsSessionID() throws { let nodeID = UUID()