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()