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
8 changes: 6 additions & 2 deletions GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
34 changes: 30 additions & 4 deletions graphcode/Tests/LocalSessionResumeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
Expand Down Expand Up @@ -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"))
Expand All @@ -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()
Expand Down
Loading