diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 93b9786e..7189ca86 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -89,6 +89,83 @@ public enum ZmxSessionLauncher { /// than ride through `"$@"`: positional arguments pass untouched, so a `$HOME` in one /// stays a literal dollar sign forever. The remote hooks flag needs the expansion — /// the path is on a machine whose home directory only that shell knows. + /// `zmx set agent=` — the stamp that says graphcode has launched this + /// backend into this session, and the signal `daemonReadyCheckCommand` waits on. + /// + /// It replaces a gate that could never pass. That one grepped `zmx ls` for the agent's + /// name in the session's `cmd=` field, and a session created by `zmx run` has no `cmd=` + /// field at all: zmx records a command only for `attach` (`main.zig:217`, against + /// `.command = null` for `run` at `:259`) and prints the field only when it is non-nil + /// (`util.zig:911`). Graphcode launches every loop with `zmx run -d`, so the gate was + /// unsatisfiable — every Codex pane spun out its sixty seconds and dropped the human + /// into a bare login shell while the agent underneath it ran fine (issue #272). + /// + /// The decoy, for whoever reads this next: `zmx ls` *also* truncates a long `cmd=` to a + /// literal `...` at a 256-byte cap (`ipc.zig:58`, `main.zig:1322`), which looks like the + /// bug and is not. Raising that cap or dropping the ellipsis would change nothing here — + /// a loop session has no `cmd=` to truncate in the first place. Only sessions created by + /// `attach` carry one at all. + /// + /// Written by the daemon in the same breath as the launch rather than from inside the + /// launch script, for one measured reason: what `zmx run` carries is *typed* into the + /// session's tty, and a canonical-mode tty drops everything past `MAX_CANON` + /// (issue #57). A stamp inside that argument spends a budget the Codex launch has + /// already nearly exhausted — it tipped `theLaunchStillFitsInATypedCommandLine` red the + /// first time it was tried. Out here it costs the typed line nothing. + /// The agent a node's readiness is proved with, or `nil` for a backend whose sessions + /// are judged by name alone. + /// + /// Codex only, because Codex is the only backend whose session the daemon creates while + /// the pane waits on it (`defersCodexLaunchToDaemon`) — the race #228 found. Read from + /// one place by the gate, the stamp and the repair together, so they cannot drift into + /// judging different sets of loops; and kept narrow so a bug in any of the three cannot + /// reach a backend that was never part of this. + static func readinessAgent(forNode node: LoopNode) -> String? { + node.backend == .codex ? node.backend.rawValue : nil + } + + static func agentLabelCommand(zmxPath: String, forNode node: LoopNode) -> String? { + guard readinessAgent(forNode: node) != nil else { return nil } + let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName + return RemoteProjectLocation.shellQuoted(zmxPath) + " set " + + RemoteProjectLocation.shellQuoted(name) + " " + + RemoteProjectLocation.shellQuoted("\(Self.agentLabelKey)=\(node.backend.rawValue)") + + " >/dev/null 2>&1" + } + + /// Stamps a session that is alive under this node's name and carries **no** agent + /// label — the daemon adopting a session it must have launched itself. + /// + /// Without it, one failed `zmx set` is permanent. A stamp written only in the launch + /// branch is only ever retried by another launch, and nothing re-launches a healthy + /// local loop: the liveness sweep filters to remote projects, the local ensure runs at + /// graph load, node creation and promotion, and a local send checks `sessionExists` + /// rather than this gate. The loop would sit unattachable exactly as in #272, but + /// intermittently — which is worse than the deterministic bug it came from. The repair + /// still only lands when an ensure runs, and nothing runs one periodically for a local + /// project; that residual is issue #276. + /// + /// Only when there is no label at all, never over one that disagrees: a session already + /// stamped with a different agent is a session running the wrong thing, and relaunching + /// it is the right answer, not relabelling it. And only from the daemon — the pane must + /// keep waiting for positive proof, since a bare attach shell is exactly what #228's + /// clause exists to reject. + static func adoptUnlabelledCommand(zmxPath: String, forNode node: LoopNode) -> String? { + guard let stamp = agentLabelCommand(zmxPath: zmxPath, forNode: node) else { return nil } + let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName + let listed = daemonReadyCheckCommand(zmxPath: zmxPath, sessionName: name, agent: nil) + let unlabelled = + "! " + RemoteProjectLocation.shellQuoted(zmxPath) + + " ls 2>/dev/null | grep -q " + + RemoteProjectLocation.shellQuoted("name=\(name)\t.*\t\(Self.agentLabelKey)=") + // The stamp cannot decide this branch's answer: having skipped the relaunch is the + // outcome that matters, and a stamp that failed is retried by the next ensure. + return "\(listed) >/dev/null 2>&1 && \(unlabelled) && { \(stamp) || true; }" + } + + /// The label key `agentLabelCommand` writes and `daemonReadyCheckCommand` reads. + static let agentLabelKey = "agent" + static func loginShellInvocation( of command: String, arguments: [String], environment: [String: String] = [:], scriptSuffix: String = "" @@ -489,30 +566,42 @@ public enum ZmxSessionLauncher { let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName return daemonReadyCheckCommand( zmxPath: zmxPath, sessionName: name, - executable: node.backend == .codex ? node.backend.executableName : nil) + agent: readinessAgent(forNode: node)) } + /// `agent` is a `CLISessionBackendKind.rawValue`, the same value the daemon's ensure + /// stamps (`agentLabelCommand`) — one source for the write and the read, so a backend + /// whose binary is named differently from its case (`claudeCode` → `claude`) cannot + /// make the two halves disagree. public static func daemonReadyCheckCommand( - zmxPath: String, sessionName: String, executable: String? + zmxPath: String, sessionName: String, agent: 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 { + if let agent { + // The label graphcode writes (`agentLabelCommand`), not the process's own command + // line: `zmx ls` never shows a `cmd=` for a `zmx run` session, so the grep this + // replaced could not match however long it waited (issue #272). + // Anchored on the label's own end — a tab, or the end of the line when it is the + // last label zmx printed. Unanchored, `agent=codexFoo` would satisfy a Codex gate; + // no backend's rawValue is a prefix of another today, which makes this a trap for + // whoever adds the one that is, not a live bug. command += " && " + RemoteProjectLocation.shellQuoted(zmxPath) - + " ls 2>/dev/null | grep -v -e $'\\tended=' -e $'\\terr=' | grep -q " - + RemoteProjectLocation.shellQuoted("name=\(sessionName)\t.*cmd=.*\(executable)") + + " ls 2>/dev/null | grep -v -e $'\\tended=' -e $'\\terr=' | grep -qE " + + RemoteProjectLocation.shellQuoted( + "name=\(sessionName)\t.*\t\(Self.agentLabelKey)=\(agent)(\t|$)") } return command } public static func waitingAttachCommand( - zmxPath: String, sessionName: String, executable: String? + zmxPath: String, sessionName: String, agent: String? ) -> [String] { let check = daemonReadyCheckCommand( - zmxPath: zmxPath, sessionName: sessionName, executable: executable) + zmxPath: zmxPath, sessionName: sessionName, agent: agent) let attach = RemoteProjectLocation.shellQuoted(zmxPath) + " attach " + RemoteProjectLocation.shellQuoted(sessionName) @@ -1132,11 +1221,20 @@ public enum ZmxSessionLauncher { return "" } }() + // The readiness stamp closes the create branch, after the launch it describes + // (`agentLabelCommand`, issue #272). Joined with `;` rather than `&&` because the + // create script is an if/else whose exit status belongs to whichever branch ran — + // and it is safe to run unconditionally here, since `zmx set` against a session that + // was never created fails and leaves no label for the gate to find. + let repair = adoptUnlabelledCommand(zmxPath: "zmx", forNode: node).map { "\($0) || " } ?? "" + let launch = + agentLabelCommand(zmxPath: "zmx", forNode: node) + .map { "\(create) && { \($0) || true; }" } ?? create let script = "cd \(RemoteProjectLocation.shellQuoted(location.remotePath)) && { " + deliveryFragment(delivery, ifSessionMissing: check) - + "\(check) >/dev/null 2>&1\(bank) || { " + trustSeed + hooksWrite - + "\(create); }; }" + + "\(check) >/dev/null 2>&1\(bank) || \(repair){ " + trustSeed + hooksWrite + + "\(launch); }; }" return location.sshInvocation(remoteCommand: location.remoteLoginShellCommand(script)) } @@ -1573,7 +1671,9 @@ public enum ZmxSessionLauncher { await atomicCheckOrRun( checkCommand: aliveCheck, runArguments: resumeArgs, zmxPath: zmxPath, workingDirectory: wd, - logFragment: DialLog.fragment(session: name, dial: "ensure", event: "resume")) + logFragment: DialLog.fragment(session: name, dial: "ensure", event: "resume"), + stampCommand: agentLabelCommand(zmxPath: zmxPath, forNode: node), + repairCommand: adoptUnlabelledCommand(zmxPath: zmxPath, forNode: node)) // `zmx run -d` reports that the *session* exists, not that what it launched // survived: `claude --resume` against a transcript its retention expired dies // within a second — and the wrapper shell it was typed with stays behind, a @@ -1610,7 +1710,9 @@ public enum ZmxSessionLauncher { await atomicCheckOrRun( checkCommand: aliveCheck, runArguments: runArgs, zmxPath: zmxPath, workingDirectory: wd, - logFragment: DialLog.fragment(session: name, dial: "ensure", event: "fresh")) + logFragment: DialLog.fragment(session: name, dial: "ensure", event: "fresh"), + stampCommand: agentLabelCommand(zmxPath: zmxPath, forNode: node), + repairCommand: adoptUnlabelledCommand(zmxPath: zmxPath, forNode: node)) await kickOffFirstPass( of: node, sessionNamed: name, projectPath: projectPath, after: copilotSessionBefore) } @@ -1805,12 +1907,25 @@ public enum ZmxSessionLauncher { /// woken — the husk answered every `zmx get` (#215). private static func atomicCheckOrRun( checkCommand: String, runArguments: [String], - zmxPath: String, workingDirectory: String?, logFragment: String? = nil + zmxPath: String, workingDirectory: String?, logFragment: String? = nil, + stampCommand: String? = nil, repairCommand: String? = nil ) async { let run = quotedCommand([zmxPath] + runArguments) + // The stamp rides in the run branch, after the launch it describes and only if that + // launch was made: it is what the readiness gate reads, so writing it anywhere a + // session might not exist would say the session is ready when it is not + // (`agentLabelCommand`, issue #272). `|| true` because a failed stamp must not read + // as a failed ensure — that is a retry, and a retry runs `zmx run` against a session + // that is now live, which types the whole launch command into the agent's input. + let launch = stampCommand.map { "\(run) && { \($0) || true; }" } ?? run + // Repair before relaunch: an alive, unlabelled session is one whose stamp was lost, + // and re-running it would be that same typing disaster (`adoptUnlabelledCommand`). + let repair = repairCommand.map { "\($0) || " } ?? "" let script = - logFragment.map { "\(checkCommand) >/dev/null 2>&1 || { \($0); \(run); }" } - ?? "\(checkCommand) >/dev/null 2>&1 || \(run)" + logFragment.map { + "\(checkCommand) >/dev/null 2>&1 || \(repair){ \($0); \(launch); }" + } + ?? "\(checkCommand) >/dev/null 2>&1 || \(repair)\(launch)" guard let session = try? PTYProcessSession( executable: "/bin/zsh", arguments: ["-c", script], diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift index f6e86f0d..6519a4ac 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift @@ -327,7 +327,7 @@ struct GhosttyTerminalView: NSViewRepresentable { if defersCodexLaunchToDaemon { return ZmxSessionLauncher.waitingAttachCommand( zmxPath: ZmxLocator.binaryURL.path, sessionName: sessionName, - executable: backend.executableName) + agent: backend.rawValue) } if let resuming = localResumeOrFreshCommand(agentLaunch: agentCommand) { return resuming diff --git a/graphcode/Tests/AttachedSessionBriefingTests.swift b/graphcode/Tests/AttachedSessionBriefingTests.swift index fa6c417b..ce880da9 100644 --- a/graphcode/Tests/AttachedSessionBriefingTests.swift +++ b/graphcode/Tests/AttachedSessionBriefingTests.swift @@ -76,7 +76,7 @@ struct AttachedSessionBriefingTests { let view = surface(.codex, loopType: .goalBased) let command = view.command(briefingPath: briefing) #expect(command.first == "/bin/zsh") - #expect(command.contains { $0.contains("until") && $0.contains("cmd=.*codex") }) + #expect(command.contains { $0.contains("until") && $0.contains("agent=codex") }) #expect(command.last?.contains("exec") == true) #expect(command.last?.contains("attach 's'") == true) } diff --git a/graphcode/Tests/CodexReadinessGateTests.swift b/graphcode/Tests/CodexReadinessGateTests.swift new file mode 100644 index 00000000..336a837a --- /dev/null +++ b/graphcode/Tests/CodexReadinessGateTests.swift @@ -0,0 +1,214 @@ +import Foundation +import Testing + +@testable import GraphcodeKit + +/// The gate that decides a Codex session is ready to attach, exercised against a **real** +/// `zmx` session created the way graphcode creates every loop's: `zmx run -d`. +/// +/// Issue #272 is why this suite runs a real session instead of asserting a pattern. The +/// old gate looked for the agent's name in the session's `cmd=` field, and every test of +/// it checked only that the string was *built* — so nobody noticed that a `zmx run` +/// session has no `cmd=` field at all (zmx records a command for `attach` and hardcodes +/// `.command = null` for `run`, and `zmx ls` prints the field only when it is non-nil). +/// A synthetic, attach-shaped `ls` line would pass happily while every Codex pane in the +/// product spun out its sixty seconds and gave up. +/// +/// Commands go through `PTYProcessSession` for the same reason the daemon's own ensure +/// does: `zmx` wants a terminal to create a session against. +@Suite(.serialized) +struct CodexReadinessGateTests { + private static let zmx = ZmxLocator.binaryURL.path + + private static func quoted(_ value: String) -> String { + RemoteProjectLocation.shellQuoted(value) + } + + @discardableResult + private func run(_ command: String) async -> (succeeded: Bool, output: String) { + guard + let session = try? PTYProcessSession( + executable: "/bin/zsh", arguments: ["-c", command], workingDirectory: nil) + else { return (false, "") } + return await session.waitCollectingOutput() + } + + /// A live session of the exact shape graphcode launches, named after a real node so the + /// production stamp — which derives the session name from the node — addresses it. + private func start(_ node: LoopNode) async -> String? { + let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName + await run( + "\(Self.quoted(Self.zmx)) run \(Self.quoted(name)) -d /bin/zsh -c 'sleep 30'") + for _ in 0..<40 { + if await run(listing(name)).succeeded { return name } + try? await Task.sleep(for: .milliseconds(100)) + } + return nil + } + + private func listing(_ name: String) -> String { + "\(Self.quoted(Self.zmx)) ls 2>/dev/null | grep -q $'name=\(name)\\t'" + } + + private func kill(_ name: String) async { + await run("\(Self.quoted(Self.zmx)) kill \(Self.quoted(name)) >/dev/null 2>&1") + } + + private func codexNode() -> LoopNode { + LoopNode(title: "Codex", loopType: .goalBased, goal: GoalSpec(summary: "work"), backend: .codex) + } + + @Test + func aRunCreatedSessionNeverCarriesTheCommandTheOldGateLookedFor() async throws { + guard ZmxLocator.isInstalled else { return } + let name = try #require(await start(codexNode())) + defer { Task { await kill(name) } } + + let row = await run( + "\(Self.quoted(Self.zmx)) ls 2>/dev/null | grep -F \(Self.quoted("name=\(name)\t"))") + #expect(row.succeeded) + // The whole of #272 in one assertion: the field the old gate matched on is absent + // from a session created the way graphcode creates every one of them. + #expect(!row.output.contains("cmd=")) + + // So the old gate could not pass however long the pane waited for it. + let legacy = + "\(Self.quoted(Self.zmx)) ls 2>/dev/null | grep -q $'name=\(name)\\t.*cmd=.*codex'" + #expect(await !run(legacy).succeeded) + } + + @Test + func theGateFailsUntilTheDaemonStampsTheAgentLabel() async throws { + guard ZmxLocator.isInstalled else { return } + let node = codexNode() + let name = try #require(await start(node)) + defer { Task { await kill(name) } } + + // The production gate, run against a real session. + let gate = ZmxSessionLauncher.daemonReadyCheckCommand( + zmxPath: Self.zmx, sessionName: name, agent: CLISessionBackendKind.codex.rawValue) + #expect(await !run(gate).succeeded) + + // The production stamp, run the way the daemon's ensure runs it. + await run( + try #require(ZmxSessionLauncher.agentLabelCommand(zmxPath: Self.zmx, forNode: node))) + #expect(await run(gate).succeeded) + + // A session running some *other* agent must still not satisfy a Codex gate — that is + // the whole point of the clause #228 added, and this keeps it. + let copilotGate = ZmxSessionLauncher.daemonReadyCheckCommand( + zmxPath: Self.zmx, sessionName: name, agent: CLISessionBackendKind.copilotCLI.rawValue) + #expect(await !run(copilotGate).succeeded) + } + + @Test + func aLostStampIsRepairedRatherThanRelaunched() async throws { + guard ZmxLocator.isInstalled else { return } + let node = codexNode() + let name = try #require(await start(node)) + defer { Task { await kill(name) } } + + // A session that is alive and unlabelled — a stamp that failed, or one written by a + // build that predates stamping. The ensure adopts it instead of running `zmx run` + // against a live session, which would type the launch command into the agent. + let gate = ZmxSessionLauncher.daemonReadyCheckCommand( + zmxPath: Self.zmx, sessionName: name, agent: CLISessionBackendKind.codex.rawValue) + #expect(await !run(gate).succeeded) + + let repair = try #require( + ZmxSessionLauncher.adoptUnlabelledCommand(zmxPath: Self.zmx, forNode: node)) + #expect(await run(repair).succeeded) + #expect(await run(gate).succeeded) + } + + @Test + func aSessionLabelledForAnotherAgentIsNotAdopted() async throws { + guard ZmxLocator.isInstalled else { return } + let node = codexNode() + let name = try #require(await start(node)) + defer { Task { await kill(name) } } + + // Alive, but running something else. Relabelling it would make the gate lie; the + // ensure has to fall through to its relaunch branch instead, so the adopt must fail. + await run("\(Self.quoted(Self.zmx)) set \(Self.quoted(name)) agent=copilotCLI") + let repair = try #require( + ZmxSessionLauncher.adoptUnlabelledCommand(zmxPath: Self.zmx, forNode: node)) + #expect(await !run(repair).succeeded) + + let gate = ZmxSessionLauncher.daemonReadyCheckCommand( + zmxPath: Self.zmx, sessionName: name, agent: CLISessionBackendKind.codex.rawValue) + #expect(await !run(gate).succeeded) + } + + @Test + func theGateMatchesTheWholeLabelAndNotAPrefixOfIt() async throws { + guard ZmxLocator.isInstalled else { return } + let node = codexNode() + let name = try #require(await start(node)) + defer { Task { await kill(name) } } + + // No backend's rawValue is a prefix of another today, so this is a trap for whoever + // adds the one that is rather than a live bug — which is the moment to pin it. + await run("\(Self.quoted(Self.zmx)) set \(Self.quoted(name)) agent=codexFoo") + let gate = ZmxSessionLauncher.daemonReadyCheckCommand( + zmxPath: Self.zmx, sessionName: name, agent: CLISessionBackendKind.codex.rawValue) + #expect(await !run(gate).succeeded) + + // And still matches when the label is the last one on the row, where there is no + // trailing tab to anchor against. + await run("\(Self.quoted(Self.zmx)) set \(Self.quoted(name)) agent=codex") + #expect(await run(gate).succeeded) + } + + @Test + func aStampAgainstAMissingSessionFailsAndMustNotFailTheEnsure() async { + guard ZmxLocator.isInstalled else { return } + // The hazard the `|| true` exists for: an ensure that exits non-zero is retried, and + // the retry runs `zmx run` against a session that is by then live — which types the + // entire launch command into the agent's input, the very thing the ensure's atomic + // check-or-run exists to prevent. + guard + let stamp = ZmxSessionLauncher.agentLabelCommand( + zmxPath: Self.zmx, forNode: codexNode()) + else { return } + #expect(await !run(stamp).succeeded) + #expect(await run("\(stamp) || true").succeeded) + } + + @Test + func nothingIsStampedOrRepairedForABackendTheGateNeverJudges() { + // Codex only. The gate reads a label for Codex alone (`readinessAgent`), so writing + // one anywhere else is a change to three backends that had no part in #272 — and the + // stamp is a shell fragment inside the ensure, which is not a place to spend risk. + for backend in CLISessionBackendKind.allCases where backend != .codex { + let node = LoopNode( + title: "Loop", loopType: .goalBased, goal: GoalSpec(summary: "work"), backend: backend) + #expect(ZmxSessionLauncher.readinessAgent(forNode: node) == nil) + #expect( + ZmxSessionLauncher.agentLabelCommand(zmxPath: "/usr/local/bin/zmx", forNode: node) == nil) + #expect( + ZmxSessionLauncher.adoptUnlabelledCommand(zmxPath: "/usr/local/bin/zmx", forNode: node) + == nil) + // And the gate for such a node is the name check alone, exactly as before #272. + let command = ZmxSessionLauncher.aliveCheckCommand( + zmxPath: "/usr/local/bin/zmx", forNode: node) + #expect(!command.contains("agent=")) + } + } + + @Test + func theLabelTheDaemonWritesIsTheOneTheGateReads() { + // One source for the write and the read. The gate used to take `executableName` + // while nothing wrote anything, so a backend whose binary is named differently from + // its case (`claudeCode` → `claude`) could have made the two halves disagree. + let node = codexNode() + let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName + let stamp = + ZmxSessionLauncher.agentLabelCommand(zmxPath: "/usr/local/bin/zmx", forNode: node) ?? "" + let gate = ZmxSessionLauncher.daemonReadyCheckCommand( + zmxPath: "/usr/local/bin/zmx", sessionName: name, + agent: CLISessionBackendKind.codex.rawValue) + #expect(stamp.contains("agent=codex")) + #expect(gate.contains("agent=codex")) + } +} diff --git a/graphcode/Tests/RemoteSessionResumeTests.swift b/graphcode/Tests/RemoteSessionResumeTests.swift index ab57647d..f4bc638d 100644 --- a/graphcode/Tests/RemoteSessionResumeTests.swift +++ b/graphcode/Tests/RemoteSessionResumeTests.swift @@ -166,8 +166,10 @@ struct RemoteSessionResumeTests { // assertion would pass with the write deleted entirely. let write = try #require(remoteCommand.range(of: "mkdir -p \"$HOME/.graphcode/hooks\"")) // And inside the create branch, not merely after the check — textual order alone - // would be satisfied by a fragment sitting outside the group. - let branch = try #require(remoteCommand.range(of: ">/dev/null 2>&1 || { ")) + // would be satisfied by a fragment sitting outside the group. The check and the + // group are no longer adjacent: the readiness repair sits between them (#272), so + // the group opener is what this anchors on. + let branch = try #require(remoteCommand.range(of: " || { ")) let run = try #require(remoteCommand.range(of: "'run'")) #expect(branch.lowerBound < write.lowerBound) #expect(write.lowerBound < run.lowerBound) diff --git a/graphcode/Tests/ZmxSessionLauncherTests.swift b/graphcode/Tests/ZmxSessionLauncherTests.swift index cd5bf439..caf221b5 100644 --- a/graphcode/Tests/ZmxSessionLauncherTests.swift +++ b/graphcode/Tests/ZmxSessionLauncherTests.swift @@ -221,16 +221,19 @@ struct ZmxSessionLauncherTests { 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")) + // The label graphcode writes, never the process command line: `zmx run` records no + // command, so `cmd=` is a field a loop session simply does not have (issue #272). + #expect(command.contains("agent=codex")) + #expect(!command.contains("cmd=")) } @Test func appWaitsForTheDaemonBeforeAttachingCodex() { let command = ZmxSessionLauncher.waitingAttachCommand( - zmxPath: "/usr/local/bin/zmx", sessionName: "graphcode-a", executable: "codex") + zmxPath: "/usr/local/bin/zmx", sessionName: "graphcode-a", agent: "codex") #expect(command.first == "/bin/zsh") #expect(command.last?.contains("until") == true) - #expect(command.last?.contains("cmd=.*codex") == true) + #expect(command.last?.contains("agent=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.