From 927c8af01d40e5a2d6149cbd4a71bfccc437f830 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sun, 30 Aug 2026 15:23:55 -0700 Subject: [PATCH 1/3] Prevent Codex app resume from racing daemon --- .../Infrastructure/Ghostty/GhosttyTerminalView.swift | 8 ++++---- graphcode/Tests/AttachedSessionBriefingTests.swift | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift index cca814d5..9198e013 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift @@ -300,7 +300,7 @@ struct GhosttyTerminalView: NSViewRepresentable { return environment } - private func command(briefingPath: String?) -> [String] { + func command(briefingPath: String?) -> [String] { // A remote project's surfaces live on the remote host, local zmx or not. if let location = remoteLocation { return remoteCommand(at: location, settings: GraphcodeSettingsStore.load()) @@ -322,14 +322,14 @@ struct GhosttyTerminalView: NSViewRepresentable { // wrapper needed for a plain-shell surface. var command = [ZmxLocator.binaryURL.path, "attach", sessionName] guard launchesClaudeCode else { return command } - if let resuming = localResumeOrFreshCommand(agentLaunch: agentCommand) { - return resuming - } // Unattended Codex sessions are started by graphcoded. Attaching with the agent // command as well creates a race where zmx run types that command into Codex. if defersCodexLaunchToDaemon { return command } + if let resuming = localResumeOrFreshCommand(agentLaunch: agentCommand) { + return resuming + } command += agentCommand return command } diff --git a/graphcode/Tests/AttachedSessionBriefingTests.swift b/graphcode/Tests/AttachedSessionBriefingTests.swift index 7f57a5d8..34d95e3e 100644 --- a/graphcode/Tests/AttachedSessionBriefingTests.swift +++ b/graphcode/Tests/AttachedSessionBriefingTests.swift @@ -70,6 +70,13 @@ struct AttachedSessionBriefingTests { #expect(!surface(.claudeCode, loopType: .goalBased).defersCodexLaunchToDaemon) } + @Test + func unattendedCodexDoesNotResumeFromTheApp() { + guard ZmxLocator.isInstalled else { return } + let view = surface(.codex, loopType: .goalBased) + #expect(view.command(briefingPath: briefing) == [ZmxLocator.binaryURL.path, "attach", "s"]) + } + @Test func noBriefingMeansTheCommandAndPromptOfBefore() { let command = From 3f17f6715f477a825cf62c71047f0fb2d271e9e7 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sun, 30 Aug 2026 15:30:30 -0700 Subject: [PATCH 2/3] Harden Codex daemon handoff and remote status --- .../Sources/Sessions/ZmxSessionLauncher.swift | 46 ++++++++++++++----- .../Ghostty/GhosttyTerminalView.swift | 4 +- .../Tests/AttachedSessionBriefingTests.swift | 6 ++- graphcode/Tests/RemoteLoopSurvivalTests.swift | 2 + graphcode/Tests/ZmxSessionLauncherTests.swift | 19 ++++++++ 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 5aafc96f..874d29f8 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -487,9 +487,33 @@ public enum ZmxSessionLauncher { /// prefix of another session's name cannot pass. static func aliveCheckCommand(zmxPath: String, forNode node: LoopNode) -> String { let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName - return RemoteProjectLocation.shellQuoted(zmxPath) - + " ls 2>/dev/null | grep -v -e $'\\tended=' -e $'\\terr=' | grep -q " - + RemoteProjectLocation.shellQuoted("name=\(name)\t") + return daemonReadyCheckCommand( + zmxPath: zmxPath, sessionName: name, + executable: node.backend == .codex ? node.backend.executableName : nil) + } + + public static func daemonReadyCheckCommand( + zmxPath: String, sessionName: String, executable: String? + ) -> String { + let name = RemoteProjectLocation.shellQuoted("name=\(sessionName)\t") + var command = RemoteProjectLocation.shellQuoted(zmxPath) + + " ls 2>/dev/null | grep -v -e $'\\tended=' -e $'\\terr=' | grep -q " + name + if let executable { + command += " && " + RemoteProjectLocation.shellQuoted(zmxPath) + + " ls 2>/dev/null | grep -v -e $'\\tended=' -e $'\\terr=' | grep -q " + + RemoteProjectLocation.shellQuoted("name=\(sessionName)\t.*cmd=.*\(executable)") + } + return command + } + + public static func waitingAttachCommand( + zmxPath: String, sessionName: String, executable: String? + ) -> [String] { + let check = daemonReadyCheckCommand( + zmxPath: zmxPath, sessionName: sessionName, executable: executable) + let attach = RemoteProjectLocation.shellQuoted(zmxPath) + + " attach " + RemoteProjectLocation.shellQuoted(sessionName) + return ["/bin/zsh", "-i", "-l", "-c", "until \(check); do sleep 0.1; done; exec \(attach)"] } /// Kills the session behind an id that isn't a graph node — a quick chat. Public @@ -1317,16 +1341,16 @@ public enum ZmxSessionLauncher { forNode node: LoopNode, label: String, at location: RemoteProjectLocation ) -> [String] { let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName - let check = quotedCommand(["zmx", "get", name]) + let check = quotedCommand(["zmx", "ls"]) let read = quotedCommand(["zmx", "get", name, label]) - let history = quotedCommand(["zmx", "history", name]) let script = - "if \(check) >/dev/null 2>&1; then " - + "gc_done=$(\(history) 2>/dev/null | tail -c 4096 | " - + "sed -n 's/.*ZMX_TASK_COMPLETED:\\([0-9][0-9]*\\).*/\\1/p' | tail -1); " - + "if [ -n \"$gc_done\" ]; then echo \"\(remoteProbeMarker) exited $gc_done\"; " - + "else echo \"\(remoteProbeMarker) live $(\(read) 2>/dev/null)\"; fi; " - + "else echo '\(remoteProbeMarker) absent'; fi" + "gc_row=$(\(check) 2>/dev/null | grep -F \(quotedCommand(["name=\(name)\\t"])) | head -1); " + + "if [ -z \"$gc_row\" ] || printf '%s' \"$gc_row\" | grep -q $'\\terr='; then " + + "echo '\(remoteProbeMarker) absent'; " + + "elif printf '%s' \"$gc_row\" | grep -q $'\\tended='; then " + + "gc_done=$(printf '%s' \"$gc_row\" | sed -n 's/.*\\texit_code=\\([0-9][0-9]*\\).*/\\1/p'); " + + "echo \"\(remoteProbeMarker) exited ${gc_done:-1}\"; " + + "else echo \"\(remoteProbeMarker) live $(\(read) 2>/dev/null)\"; fi" return location.sshInvocation(remoteCommand: location.remoteLoginShellCommand(script)) } diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift index 9198e013..9ed029ae 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift @@ -325,7 +325,9 @@ struct GhosttyTerminalView: NSViewRepresentable { // Unattended Codex sessions are started by graphcoded. Attaching with the agent // command as well creates a race where zmx run types that command into Codex. if defersCodexLaunchToDaemon { - return command + return ZmxSessionLauncher.waitingAttachCommand( + zmxPath: ZmxLocator.binaryURL.path, sessionName: sessionName, + executable: backend.executableName) } if let resuming = localResumeOrFreshCommand(agentLaunch: agentCommand) { return resuming diff --git a/graphcode/Tests/AttachedSessionBriefingTests.swift b/graphcode/Tests/AttachedSessionBriefingTests.swift index 34d95e3e..fa6c417b 100644 --- a/graphcode/Tests/AttachedSessionBriefingTests.swift +++ b/graphcode/Tests/AttachedSessionBriefingTests.swift @@ -74,7 +74,11 @@ struct AttachedSessionBriefingTests { func unattendedCodexDoesNotResumeFromTheApp() { guard ZmxLocator.isInstalled else { return } let view = surface(.codex, loopType: .goalBased) - #expect(view.command(briefingPath: briefing) == [ZmxLocator.binaryURL.path, "attach", "s"]) + let command = view.command(briefingPath: briefing) + #expect(command.first == "/bin/zsh") + #expect(command.contains { $0.contains("until") && $0.contains("cmd=.*codex") }) + #expect(command.last?.contains("exec") == true) + #expect(command.last?.contains("attach 's'") == true) } @Test diff --git a/graphcode/Tests/RemoteLoopSurvivalTests.swift b/graphcode/Tests/RemoteLoopSurvivalTests.swift index 0b967647..815fd3f7 100644 --- a/graphcode/Tests/RemoteLoopSurvivalTests.swift +++ b/graphcode/Tests/RemoteLoopSurvivalTests.swift @@ -51,6 +51,8 @@ struct RemoteLoopSurvivalTests { #expect(remoteCommand.contains(ZmxSessionLauncher.remoteProbeMarker)) #expect(remoteCommand.contains("'get'")) #expect(remoteCommand.contains("presence")) + #expect(remoteCommand.contains("'ls'")) + #expect(!remoteCommand.contains("history")) #expect( remoteCommand.contains(SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName)) } diff --git a/graphcode/Tests/ZmxSessionLauncherTests.swift b/graphcode/Tests/ZmxSessionLauncherTests.swift index d3ee62ea..14a9b3f1 100644 --- a/graphcode/Tests/ZmxSessionLauncherTests.swift +++ b/graphcode/Tests/ZmxSessionLauncherTests.swift @@ -215,6 +215,25 @@ struct ZmxSessionLauncherTests { #expect(command.contains("'/usr/local/bin/zmx'")) } + @Test + func aCodexDaemonCheckRejectsABareAttachShell() { + let node = LoopNode( + title: "Codex", loopType: .goalBased, goal: GoalSpec(summary: "work"), backend: .codex) + let command = ZmxSessionLauncher.aliveCheckCommand( + zmxPath: "/usr/local/bin/zmx", forNode: node) + #expect(command.contains("cmd=.*codex")) + } + + @Test + func appWaitsForTheDaemonBeforeAttachingCodex() { + let command = ZmxSessionLauncher.waitingAttachCommand( + zmxPath: "/usr/local/bin/zmx", sessionName: "graphcode-a", executable: "codex") + #expect(command.first == "/bin/zsh") + #expect(command.last?.contains("until") == true) + #expect(command.last?.contains("cmd=.*codex") == true) + #expect(command.last?.contains("exec '/usr/local/bin/zmx' attach 'graphcode-a'") == true) + } + @Test func theRemoteSeedPreTrustsClaudeCodesFolder() { // Issue #215's fix on the remote path: a fresh unattended `claude` exits 1 on its From db669f31a37cbcb59a988fc1e756b94fe117ad86 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sun, 30 Aug 2026 16:00:17 -0700 Subject: [PATCH 3/3] Fix remote probe tab matching and bound the Codex attach wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status probe matched the session name with grep -F over a pattern holding a literal backslash-t, which matches no zmx ls row — every remote probe reported an existing session absent. The tab is a real one now (grep -F never interprets escapes). The app's waiting attach polled zmx ls at 10 Hz forever when the daemon never created the session; it now gives up after a minute with a message. Also formats the new daemon-check code to the restored swift-format gate. --- .../Sources/Sessions/ZmxSessionLauncher.swift | 24 +++++++++++++++---- graphcode/Tests/RemoteLoopSurvivalTests.swift | 5 ++++ graphcode/Tests/ZmxSessionLauncherTests.swift | 4 ++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 874d29f8..c6a5a3fa 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -496,10 +496,12 @@ public enum ZmxSessionLauncher { zmxPath: String, sessionName: String, executable: String? ) -> String { let name = RemoteProjectLocation.shellQuoted("name=\(sessionName)\t") - var command = RemoteProjectLocation.shellQuoted(zmxPath) + var command = + RemoteProjectLocation.shellQuoted(zmxPath) + " ls 2>/dev/null | grep -v -e $'\\tended=' -e $'\\terr=' | grep -q " + name if let executable { - command += " && " + RemoteProjectLocation.shellQuoted(zmxPath) + command += + " && " + RemoteProjectLocation.shellQuoted(zmxPath) + " ls 2>/dev/null | grep -v -e $'\\tended=' -e $'\\terr=' | grep -q " + RemoteProjectLocation.shellQuoted("name=\(sessionName)\t.*cmd=.*\(executable)") } @@ -511,9 +513,18 @@ public enum ZmxSessionLauncher { ) -> [String] { let check = daemonReadyCheckCommand( zmxPath: zmxPath, sessionName: sessionName, executable: executable) - let attach = RemoteProjectLocation.shellQuoted(zmxPath) + let attach = + RemoteProjectLocation.shellQuoted(zmxPath) + " attach " + RemoteProjectLocation.shellQuoted(sessionName) - return ["/bin/zsh", "-i", "-l", "-c", "until \(check); do sleep 0.1; done; exec \(attach)"] + // The cap is for a session the daemon never creates — a launch that failed, a loop + // 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)); " + + "if [ \"$tries\" -ge 600 ]; then " + + "echo \"graphcode: '\(sessionName)' never became ready to attach\"; exit 1; fi; " + + "sleep 0.1; done; exec \(attach)" + return ["/bin/zsh", "-i", "-l", "-c", script] } /// Kills the session behind an id that isn't a graph node — a quick chat. Public @@ -1343,8 +1354,11 @@ public enum ZmxSessionLauncher { let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName let check = quotedCommand(["zmx", "ls"]) let read = quotedCommand(["zmx", "get", name, label]) + // The pattern is a fixed string, so the tab must be a real one: `grep -F` never + // interprets a `\t` escape, and the two characters would match nothing — every + // probe would read an existing session as absent. let script = - "gc_row=$(\(check) 2>/dev/null | grep -F \(quotedCommand(["name=\(name)\\t"])) | head -1); " + "gc_row=$(\(check) 2>/dev/null | grep -F \(quotedCommand(["name=\(name)\t"])) | head -1); " + "if [ -z \"$gc_row\" ] || printf '%s' \"$gc_row\" | grep -q $'\\terr='; then " + "echo '\(remoteProbeMarker) absent'; " + "elif printf '%s' \"$gc_row\" | grep -q $'\\tended='; then " diff --git a/graphcode/Tests/RemoteLoopSurvivalTests.swift b/graphcode/Tests/RemoteLoopSurvivalTests.swift index 815fd3f7..fae5a79c 100644 --- a/graphcode/Tests/RemoteLoopSurvivalTests.swift +++ b/graphcode/Tests/RemoteLoopSurvivalTests.swift @@ -53,6 +53,11 @@ struct RemoteLoopSurvivalTests { #expect(remoteCommand.contains("presence")) #expect(remoteCommand.contains("'ls'")) #expect(!remoteCommand.contains("history")) + // `grep -F` never interprets escapes: the name pattern's tab has to be a real one. + // A literal `\t` would match no row and every probe would report absent. + let sessionName = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName + #expect(remoteCommand.contains("name=\(sessionName)\t'")) + #expect(!remoteCommand.contains("'name=\(sessionName)\\t'")) #expect( remoteCommand.contains(SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName)) } diff --git a/graphcode/Tests/ZmxSessionLauncherTests.swift b/graphcode/Tests/ZmxSessionLauncherTests.swift index 14a9b3f1..cd5bf439 100644 --- a/graphcode/Tests/ZmxSessionLauncherTests.swift +++ b/graphcode/Tests/ZmxSessionLauncherTests.swift @@ -232,6 +232,10 @@ struct ZmxSessionLauncherTests { #expect(command.last?.contains("until") == true) #expect(command.last?.contains("cmd=.*codex") == true) #expect(command.last?.contains("exec '/usr/local/bin/zmx' attach 'graphcode-a'") == true) + // A session the daemon never creates must not be polled at 10 Hz forever: the wait + // gives up after a minute with a message instead of spinning. + #expect(command.last?.contains("-ge 600") == true) + #expect(command.last?.contains("exit 1") == true) } @Test