diff --git a/GraphcodeKit/Sources/Sessions/SessionIDStore.swift b/GraphcodeKit/Sources/Sessions/SessionIDStore.swift index edcbe66a..db087eff 100644 --- a/GraphcodeKit/Sources/Sessions/SessionIDStore.swift +++ b/GraphcodeKit/Sources/Sessions/SessionIDStore.swift @@ -67,26 +67,6 @@ public enum SessionIDStore { } } - /// The daemon-side twin of the `SessionStart` hook's write, for a backend that has no - /// hook to bank its own ID (Copilot): history line first, then the pointer, and nothing - /// at all when the pointer already names this ID — an ensure tick must not grow the - /// history. - public static func bank(_ sessionID: String, forNodeID nodeID: UUID, workingDirectory: String) { - guard load(forNodeID: nodeID) != sessionID else { return } - try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let line = "\(Int(Date().timeIntervalSince1970)) \(sessionID) \(workingDirectory)\n" - // `O_APPEND`, not seek-then-write: the daemon's bank task and the app's bank-on-open - // can land on the same file in the same instant, and only an append-mode write - // keeps both lines. - let descriptor = open( - historyFile(forNodeID: nodeID).path, O_WRONLY | O_APPEND | O_CREAT, 0o644) - if descriptor >= 0 { - _ = Array(line.utf8).withUnsafeBytes { write(descriptor, $0.baseAddress, $0.count) } - close(descriptor) - } - save(sessionID, forNodeID: nodeID) - } - public static func load(forNodeID nodeID: UUID) -> String? { let url = file(forNodeID: nodeID) guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil } diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 74cd46f9..115b82e7 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -508,17 +508,6 @@ public enum ZmxSessionLauncher { return command } - /// Positive evidence that a session's task has ended: its listing row is there and - /// carries `ended=`. The complement of `daemonReadyCheckCommand` is *not* this — that - /// check also fails on a row marked `err=` (a busy daemon missing zmx's one-second - /// probe) and on an empty listing, neither of which says the agent is gone. Anything - /// destructive keys off this one. - public static func sessionEndedCheckCommand(zmxPath: String, sessionName: String) -> String { - let name = RemoteProjectLocation.shellQuoted("name=\(sessionName)\t") - return RemoteProjectLocation.shellQuoted(zmxPath) - + " ls 2>/dev/null | grep " + name + " | grep -q $'\\tended='" - } - public static func waitingAttachCommand( zmxPath: String, sessionName: String, executable: String? ) -> [String] { @@ -1561,12 +1550,19 @@ public enum ZmxSessionLauncher { // no session a keystroke can reach, so the run branch relaunches it — this is // what lets an ensure, a send, or the sweep wake a loop that died unattended // (issue #215), which a `zmx get` check could never do. - let sessionID = resumableSessionID(forNodeID: node.id, backend: node.backend) + let sessionID: String? = + SessionIDStore.load(forNodeID: node.id) + ?? { + switch node.backend { + case .copilotCLI: + let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName + return CopilotSessionLog.directory(forSessionNamed: name)?.lastPathComponent + case .claudeCode, .codex, .openCode: + return nil + } + }() guard let runArgs = arguments(forNode: node, projectPath: projectPath) else { return } let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName - let copilotSessionBefore = - node.backend == .copilotCLI - ? CopilotSessionLog.directory(forSessionNamed: name)?.lastPathComponent : nil if let sessionID, let resumeArgs = resumeArguments( forNode: node, sessionID: sessionID, projectPath: projectPath) @@ -1585,14 +1581,7 @@ public enum ZmxSessionLauncher { // only if the session really failed to survive is the ID treated as dead: it is // dropped and the fresh launch runs. A resume that took is left alone, and its // `SessionStart` hook has already rebanked the same ID. - guard await sessionDiedImmediately(node: node) else { - // A resume that took is the same conversation as before, so its ID is what a - // Copilot node banks — the only backend that cannot bank one itself. - if node.backend == .copilotCLI { - bankCopilotSessionID(sessionID, forNodeID: node.id, workingDirectory: wd ?? "") - } - return - } + guard await sessionDiedImmediately(node: node) else { return } DialLog.record(session: name, dial: "ensure", event: "resume-dead") SessionIDStore.remove(forNodeID: node.id) } @@ -1609,77 +1598,18 @@ public enum ZmxSessionLauncher { case .codex, .openCode: break } } - // `copilotSessionBefore` was noted *before* the launch: the first pass and the ID - // bank below both wait for a Copilot session directory that was not already there, - // which is how they tell a session this ensure just started from one it found - // already running. + // Noted *before* the launch: the first pass below waits for a Copilot session + // directory that was not already there, which is how it tells a session it just + // started from one this ensure found already running. + let copilotSessionBefore = + firstPassMessage(for: node) != nil + ? CopilotSessionLog.directory(forSessionNamed: name)?.lastPathComponent : nil await atomicCheckOrRun( checkCommand: aliveCheck, runArguments: runArgs, zmxPath: zmxPath, workingDirectory: wd, logFragment: DialLog.fragment(session: name, dial: "ensure", event: "fresh")) await kickOffFirstPass( of: node, sessionNamed: name, projectPath: projectPath, after: copilotSessionBefore) - await bankCopilotSessionIDWhenItAppears( - forNode: node, sessionNamed: name, workingDirectory: wd, after: copilotSessionBefore) - } - - /// The ID a node's session would resume from: the banked pointer, or for Copilot — the - /// one backend with no hook to bank its own — the session-state directory carrying - /// the `--name` graphcode launched it with. Public because the app's open path - /// (`GhosttyTerminalView.localResumeOrFreshCommand`) must make the same choice: for - /// as long as it read only the pointer, a local Copilot loop whose session was gone - /// was relaunched from its goal under the same name, and nothing logged the duplicate. - public static func resumableSessionID(forNodeID nodeID: UUID, backend: CLISessionBackendKind) - -> String? - { - if let banked = SessionIDStore.load(forNodeID: nodeID) { return banked } - guard backend == .copilotCLI else { return nil } - let name = SurfaceRef(id: nodeID, launchesClaudeCode: true).zmxSessionName - return CopilotSessionLog.directory(forSessionNamed: name)?.lastPathComponent - } - - /// Banks a Copilot session's resume ID on this machine — the local twin of - /// `CopilotSessionLog.remoteIDBankFragment`, and the same dial-log line. Before this - /// the daemon rediscovered the directory on every ensure and the app never looked, so - /// the two launchers could disagree on whether there was anything to resume. - public static func bankCopilotSessionID( - _ sessionID: String, forNodeID nodeID: UUID, workingDirectory: String - ) { - guard SessionIDStore.load(forNodeID: nodeID) != sessionID else { return } - SessionIDStore.bank(sessionID, forNodeID: nodeID, workingDirectory: workingDirectory) - let name = SurfaceRef(id: nodeID, launchesClaudeCode: true).zmxSessionName - DialLog.record(session: name, dial: "bank", event: "copilot-id") - } - - /// After a fresh ensure, waits for the directory a just-launched Copilot creates — - /// one that was not there before — and banks it. Nothing is banked when the wait runs - /// out: this cannot tell a session the ensure found already running (no new directory, - /// ever) from a cold `copilot` still at its trust dialog (a new directory, later), and - /// banking the old directory in the second case would point the next resume at the - /// dead conversation — the orphaning this whole change exists to end. An unbanked - /// Copilot node loses nothing: `resumableSessionID` falls back to the newest directory - /// with its name, and the next resume that takes banks it. - private static func bankCopilotSessionIDWhenItAppears( - forNode node: LoopNode, sessionNamed name: String, workingDirectory: String?, - after previous: String? - ) async { - guard node.backend == .copilotCLI else { return } - Task { - guard await NodeTickets.copilotBank.claim(node.id) else { return } - defer { Task { await NodeTickets.copilotBank.release(node.id) } } - let deadline = Date().addingTimeInterval(firstPassWaitSeconds) - var observed: String? - while Date() < deadline, observed == nil { - let current = CopilotSessionLog.directory(forSessionNamed: name)?.lastPathComponent - if let current, current != previous { - observed = current - } else { - try? await Task.sleep(for: .seconds(firstPassPollSeconds)) - } - } - guard let sessionID = observed else { return } - bankCopilotSessionID(sessionID, forNodeID: node.id, workingDirectory: workingDirectory ?? "") - } } /// The message that gives a Copilot time-based loop the pass its schedule will not give @@ -1725,8 +1655,8 @@ public enum ZmxSessionLauncher { ) async { guard let message = firstPassMessage(for: node), let projectPath else { return } Task { - guard await NodeTickets.firstPass.claim(node.id) else { return } - defer { Task { await NodeTickets.firstPass.release(node.id) } } + guard await FirstPassTickets.shared.claim(node.id) else { return } + defer { Task { await FirstPassTickets.shared.release(node.id) } } let deadline = Date().addingTimeInterval(firstPassWaitSeconds) var observed: String? while Date() < deadline, observed == nil { @@ -1840,9 +1770,8 @@ public enum ZmxSessionLauncher { /// One first-pass message per node at a time. Two ensure ticks that both see a fresh /// session would otherwise type the task in twice. - private actor NodeTickets { - static let firstPass = NodeTickets() - static let copilotBank = NodeTickets() + private actor FirstPassTickets { + static let shared = FirstPassTickets() private var claimed: Set = [] func claim(_ id: UUID) -> Bool { claimed.insert(id).inserted } func release(_ id: UUID) { claimed.remove(id) } diff --git a/Project.swift b/Project.swift index e1b280e1..dabb6320 100644 --- a/Project.swift +++ b/Project.swift @@ -70,8 +70,8 @@ let project = Project( // (#33). Before that the suffix lived on the tag only, so betas 48/49 // of the 0.1.15 line read "0.1.15" and are told by the build number // apart. - "CFBundleShortVersionString": "0.1.58-beta9", - "CFBundleVersion": "230", + "CFBundleShortVersionString": "0.1.58-beta7", + "CFBundleVersion": "228", ]), resources: [ "graphcode/Resources/**" diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift index ebf7e010..f6e86f0d 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift @@ -353,88 +353,56 @@ struct GhosttyTerminalView: NSViewRepresentable { /// at them. Every reboot afterwards faithfully resumed the near-empty replacements. /// /// So this path resumes too, and the two launchers now make the same choice from the - /// same banked ID. The check-then-launch is one shell script rather than an argv + /// same banked ID. The check-then-launch is one `/bin/sh` script rather than an argv /// because the decision has to be made *here*, on the machine, at the moment the pane /// opens: whether a session exists, and whether the resume survived, are both facts /// only the shell holding the terminal can see. /// - /// Every local agent launch takes this script — there is no silent branch. The one - /// there used to be, a bare `zmx attach ` for a node with - /// nothing banked, is how a Copilot loop (no hook to bank its own ID; the daemon found - /// its session directory by name, this view never looked) was relaunched from its goal - /// under the same `--name` after every reboot, a second Copilot session for one loop - /// that no dial log ever recorded. Now the ID is discovered and banked first - /// (`ZmxSessionLauncher.resumableSessionID`), and a fresh launch says so. - /// - /// The liveness check is the daemon's husk-aware one (`daemonReadyCheckCommand`), not - /// `zmx get`: a session whose agent died leaves its shell at a prompt, which answers - /// `zmx get` for as long as the machine stays up. Attaching to that showed the corpse - /// and nothing more. Such a husk is killed and the launch proceeds as if the session - /// were gone, which keeps the resume-or-fresh verdict below measurable. - /// - /// The kill needs *positive* evidence of death — a listing row carrying `ended=` - /// (`sessionEndedCheckCommand`) — not the liveness check's failure. That check also - /// fails on a row zmx marks `err=`, which is a busy daemon missing its one-second probe - /// and nothing more; zmx itself refuses to clean up on that. Killing on it would take a - /// live agent down mid-turn at exactly the moment a human opens loops after a reboot, - /// and the same evidence rule keeps this from racing the daemon: its relaunch into a - /// husk clears the `ended=` mark the instant the command is typed in. - /// /// Deliberately *not* consuming the ID up front, which is what the remote path does: /// there, one restorer owns the loop, and here the daemon's ensure may be running the /// same resume concurrently. Two consumers racing on one `rm` is how the loser falls /// through to a fresh launch — the very failure this exists to end. It is dropped only /// once a resume has been *seen* to fail, which is also the check the daemon makes. /// - /// `nil` only for a surface that is not a node's; the plain agent argv is what that - /// gets. + /// `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). func localResumeOrFreshCommand(agentLaunch: [String]) -> [String]? { guard let nodeID = SurfaceRef.nodeID(fromZmxSessionName: sessionName) else { return nil } - let settings = GraphcodeSettingsStore.load() - if SessionIDStore.load(forNodeID: nodeID) == nil, - let discovered = ZmxSessionLauncher.resumableSessionID(forNodeID: nodeID, backend: backend) - { - ZmxSessionLauncher.bankCopilotSessionID( - discovered, forNodeID: nodeID, workingDirectory: workingDirectory ?? "") + let log = { (event: String) in + DialLog.fragment(session: self.sessionName, dial: "open", event: event) + "; " } - let resumeLaunch = resumeCommand( - settings: settings, hooksFile: presenceHooksFile(), remoteSettingsPath: nil) let zmx = ZmxLocator.binaryURL.path + let fresh = ZmxSessionLauncher.quotedCommand([zmx, "attach", sessionName] + agentLaunch) + let settings = GraphcodeSettingsStore.load() + guard SessionIDStore.load(forNodeID: nodeID) != nil, + let resumeLaunch = resumeCommand( + settings: settings, hooksFile: presenceHooksFile(), remoteSettingsPath: nil) + else { + return ["/bin/sh", "-c", log("fresh") + "exec \(fresh)"] + } let quoted = RemoteProjectLocation.shellQuoted let idFile = quoted(SessionIDStore.file(forNodeID: nodeID).path) let attach = ZmxSessionLauncher.quotedCommand([zmx, "attach", sessionName]) - let fresh = ZmxSessionLauncher.quotedCommand([zmx, "attach", sessionName] + agentLaunch) - let live = ZmxSessionLauncher.daemonReadyCheckCommand( - zmxPath: zmx, sessionName: sessionName, executable: nil) - let ended = ZmxSessionLauncher.sessionEndedCheckCommand( - zmxPath: zmx, sessionName: sessionName) - let kill = ZmxSessionLauncher.quotedCommand([zmx, "kill", sessionName]) + let resume = ZmxSessionLauncher.quotedCommand( + [zmx, "attach", sessionName] + resumeLaunch) + let exists = ZmxSessionLauncher.quotedCommand([zmx, "get", sessionName]) + // A live session is joined as it always was — the resume argv would be ignored by + // `zmx attach` anyway, and building it costs a settings read nobody needs. let idVariable = ZmxSessionLauncher.remoteResumeIDVariable let settle = ZmxSessionLauncher.resumeSettleSeconds - let log = { (event: String) in - DialLog.fragment(session: self.sessionName, dial: "open", event: event) + "; " - } - // A live session is joined as it always was — the resume argv would be ignored by - // `zmx attach` anyway. - let joined = "\(live) >/dev/null 2>&1 && { \(log("attach-live"))exec \(attach); }; " - let revive = "\(ended) >/dev/null 2>&1 && { \(log("husk-killed"))\(kill) >/dev/null 2>&1; }; " - var script = joined + revive - if let resumeLaunch { - let resume = ZmxSessionLauncher.quotedCommand([zmx, "attach", sessionName] + resumeLaunch) - let read = "\(idVariable)=$(cat \(idFile) 2>/dev/null); " - let attempt = - "if [ -n \"$\(idVariable)\" ]; then export \(idVariable); \(log("resume"))" - + "gc_t0=$(date +%s); " - + resume + "; gc_rc=$?; " - let verdict = - "[ $(($(date +%s) - gc_t0)) -ge \(settle) ] && exit \"$gc_rc\"; rm -f \(idFile); " - + log("resume-dead") - + #"printf '\033[1;33m── Resume did not take; starting fresh. ──\033[0m\r\n'; fi; "# - script += read + attempt + verdict - } - script += log("fresh") + "exec \(fresh)" - // zsh, like the daemon's own check-or-run: the alive check's `$'\t'` needs a shell - // that reads ANSI-C quoting, which `/bin/sh` is only when it happens to be bash. - return ["/bin/zsh", "-c", script] + let joined = "\(exists) >/dev/null 2>&1 && { \(log("attach-live"))exec \(attach); }; " + let read = "\(idVariable)=$(cat \(idFile) 2>/dev/null); " + let attempt = + "if [ -n \"$\(idVariable)\" ]; then export \(idVariable); \(log("resume"))" + + "gc_t0=$(date +%s); " + + resume + "; gc_rc=$?; " + let verdict = + "[ $(($(date +%s) - gc_t0)) -ge \(settle) ] && exit \"$gc_rc\"; rm -f \(idFile); " + + log("resume-dead") + + #"printf '\033[1;33m── Resume did not take; starting fresh. ──\033[0m\r\n'; fi; "# + let script = joined + read + attempt + verdict + log("fresh") + "exec \(fresh)" + return ["/bin/sh", "-c", script] } } diff --git a/graphcode/Tests/LocalSessionResumeTests.swift b/graphcode/Tests/LocalSessionResumeTests.swift index 9b9027df..ddc6b5ad 100644 --- a/graphcode/Tests/LocalSessionResumeTests.swift +++ b/graphcode/Tests/LocalSessionResumeTests.swift @@ -16,7 +16,7 @@ import Testing /// days of work, leaving those transcripts on disk with nothing naming them — and every /// reboot afterwards resumed the near-empty replacements. Observed 2026-08-11 on three /// loops at once. -@Suite(.serialized) +@Suite struct LocalSessionResumeTests { private let projectPath = "/tmp/widget" @@ -55,133 +55,27 @@ struct LocalSessionResumeTests { } #expect(script.contains(#"--resume "$GRAPHCODE_RESUME_ID""#)) #expect(script.contains(SessionIDStore.file(forNodeID: nodeID).path)) - // A live session is still just joined — the same reattach the pane always did, now - // behind the daemon's husk-aware listing check rather than a bare `zmx get`. - #expect(script.contains("ls 2>/dev/null")) + // A live session is still just joined — the same reattach the pane always did. + #expect(script.contains("'get'")) #expect(script.contains("'attach'")) } @Test - func aNodeWithNothingBankedStillLaunchesFreshAndSaysSo() throws { - // The fresh launch used to be a bare argv with no log line — the one silent branch, - // and the one a user's `dials.log` could never explain. The decision is made by the - // script at run time from what is banked *then*, so the shape is the same either way. + func aNodeWithNothingBankedStillLaunchesFresh() throws { let nodeID = UUID() let view = surface(nodeID: nodeID) - let script = try withBankedID(nil, forNode: nodeID) { - try #require(view.localResumeOrFreshCommand(agentLaunch: ["claude", "the prompt"])?.last) + 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. + #expect(command?.first == "/bin/sh") + let script = try #require(command?.last) #expect(script.contains("open fresh")) + #expect(script.contains("'attach'")) #expect(script.contains("the prompt")) - #expect(script.contains(SessionIDStore.file(forNodeID: nodeID).path)) - } - - @Test - func aDeadShellLeftByTheAgentIsKilledBeforeTheRelaunch() throws { - // An agent that died inside its session leaves the shell at a prompt, which answers - // `zmx get` for as long as the machine stays up. The old check joined that corpse; - // the daemon's husk-aware check (`ended=`) falls through, and the husk is killed so - // the resume-or-fresh verdict below it stays measurable. - let nodeID = UUID() - let view = surface(nodeID: nodeID) - let script = try withBankedID("abc-123", forNode: nodeID) { - try #require(view.localResumeOrFreshCommand(agentLaunch: ["claude", "the prompt"])?.last) - } - #expect(script.contains("ended=")) - #expect(script.contains("open husk-killed")) - let kill = try #require(script.range(of: "'kill'")) - let resume = try #require(script.range(of: "open resume")) - #expect(kill.upperBound < resume.lowerBound) - // The kill keys off positive evidence — a listing row carrying `ended=` — not off - // the liveness check failing: that also fails on an `err=` row, which is a busy - // daemon missing its probe, and killing on it would take a live agent down. - let ended = try #require(script.range(of: #"grep -q $'\tended='"#)) - #expect(ended.upperBound < kill.lowerBound) - #expect(!script.contains("'get'")) - // zsh, so the alive check's `$'\t'` is read as a tab wherever `/bin/sh` points. - #expect(view.localResumeOrFreshCommand(agentLaunch: ["claude"])?.first == "/bin/zsh") - } - - // MARK: - Copilot, which cannot bank its own ID - - @Test - func aCopilotLoopWithNothingBankedResumesFromItsSessionDirectory() throws { - // Copilot has no hook; on a remote host the daemon banks its directory name, locally - // nobody did. Opening such a loop with its session gone relaunched `copilot --name - // graphcode-` from the goal: a second Copilot session for one loop, every reboot, - // unlogged. The pane now discovers and banks the same ID the daemon would. - let nodeID = UUID() - let name = SurfaceRef(id: nodeID, launchesClaudeCode: true).zmxSessionName - let root = try temporaryRoot() - defer { try? FileManager.default.removeItem(at: root) } - let directory = try copilotSession(named: name, in: root) - CopilotSessionLog.stateDirectory = root - defer { - CopilotSessionLog.stateDirectory = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".copilot/session-state", isDirectory: true) - } - let view = surface(nodeID: nodeID, backend: .copilotCLI) - let script = try withBankedID(nil, forNode: nodeID) { - let script = try #require( - view.localResumeOrFreshCommand(agentLaunch: ["copilot", "the prompt"])?.last) - #expect(SessionIDStore.load(forNodeID: nodeID) == directory.lastPathComponent) - #expect( - SessionIDStore.history(forNodeID: nodeID).last?.sessionID == directory.lastPathComponent) - return script - } - defer { try? FileManager.default.removeItem(at: SessionIDStore.historyFile(forNodeID: nodeID)) } - #expect(script.contains(#"--resume "$GRAPHCODE_RESUME_ID""#)) - } - - @Test - func theNewestDirectoryWithTheNameWinsAndTheBankIsIdempotent() throws { - // A loop that already has a duplicate must resume the one being written to now, and - // an ensure tick that finds the same ID banked must not grow the history. - let nodeID = UUID() - let name = SurfaceRef(id: nodeID, launchesClaudeCode: true).zmxSessionName - let root = try temporaryRoot() - defer { try? FileManager.default.removeItem(at: root) } - let older = try copilotSession(named: name, in: root) - try FileManager.default.setAttributes( - [.modificationDate: Date(timeIntervalSinceNow: -3600)], ofItemAtPath: older.path) - let newer = try copilotSession(named: name, in: root) - CopilotSessionLog.stateDirectory = root - defer { - CopilotSessionLog.stateDirectory = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".copilot/session-state", isDirectory: true) - } - defer { - SessionIDStore.remove(forNodeID: nodeID) - try? FileManager.default.removeItem(at: SessionIDStore.historyFile(forNodeID: nodeID)) - } - let discovered = ZmxSessionLauncher.resumableSessionID(forNodeID: nodeID, backend: .copilotCLI) - #expect(discovered == newer.lastPathComponent) - ZmxSessionLauncher.bankCopilotSessionID( - newer.lastPathComponent, forNodeID: nodeID, workingDirectory: projectPath) - ZmxSessionLauncher.bankCopilotSessionID( - newer.lastPathComponent, forNodeID: nodeID, workingDirectory: projectPath) - #expect(SessionIDStore.history(forNodeID: nodeID).count == 1) - #expect(SessionIDStore.history(forNodeID: nodeID).first?.workingDirectory == projectPath) - // Once banked, the pointer is the answer — for a Claude node there is no directory - // to fall back to at all. - #expect( - ZmxSessionLauncher.resumableSessionID(forNodeID: nodeID, backend: .claudeCode) - == newer.lastPathComponent) - } - - private func temporaryRoot() throws -> URL { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("copilot-resume-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - return root - } - - private func copilotSession(named name: String, in root: URL) throws -> URL { - let directory = root.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - try "client_name: github/cli\nname: \(name)\n".write( - to: directory.appendingPathComponent("workspace.yaml"), atomically: true, encoding: .utf8) - return directory + #expect(!script.contains("--resume")) } @Test