From d8ea1806e1ca30549834de064c35dfe0ab4a5d2c Mon Sep 17 00:00:00 2001 From: scgopi Date: Wed, 2 Sep 2026 08:46:09 -0700 Subject: [PATCH] Revert the local Copilot resume work (#248, #249) back to beta7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beta8 banked a Copilot resume ID from three places at once and killed panes it judged husks; beta9 tightened the kill to ended= rows and stopped banking the pre-launch directory. Sessions were still lost: a resumed Copilot drops --name, so name-keyed discovery can never find the directory a resume actually created, and any pointer loss (the resume-dead verdicts rm it) falls back to newest-matching-directory — a stale or near-empty conversation, banked over the real one. The findings and the revisit constraints are in docs/2026-09-02-beta8-beta9-resume-regression.md. This restores the beta7 tree (fc1d781) for SessionIDStore, ZmxSessionLauncher, Project.swift, and the resume tests. Kept from #248 is the dial logging: every local agent launch now records its branch, so an unbanked node's fresh launch logs 'open fresh' instead of being the one silent branch. No resume, banking, or kill behavior changes beyond what beta7 shipped. --- .../Sources/Sessions/SessionIDStore.swift | 20 --- .../Sources/Sessions/ZmxSessionLauncher.swift | 115 +++------------ Project.swift | 4 +- .../Ghostty/GhosttyTerminalView.swift | 96 +++++-------- graphcode/Tests/LocalSessionResumeTests.swift | 132 ++---------------- 5 files changed, 69 insertions(+), 298 deletions(-) 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