From 00b7bc4143e912555f907a1c63106ce022b6fcf3 Mon Sep 17 00:00:00 2001 From: scgopi Date: Wed, 9 Sep 2026 08:11:34 -0700 Subject: [PATCH 01/11] Start remote session export tests (#333) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb --- graphcode/Tests/RemoteSessionExportTests.swift | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 graphcode/Tests/RemoteSessionExportTests.swift diff --git a/graphcode/Tests/RemoteSessionExportTests.swift b/graphcode/Tests/RemoteSessionExportTests.swift new file mode 100644 index 0000000..afb0994 --- /dev/null +++ b/graphcode/Tests/RemoteSessionExportTests.swift @@ -0,0 +1,8 @@ +import Foundation +import Testing + +@testable import GraphcodeKit + +@Suite +struct RemoteSessionExportTests { +} From 61cfe28b462295bcbb8e66f639b1a7a9bd9e13f2 Mon Sep 17 00:00:00 2001 From: scgopi Date: Wed, 9 Sep 2026 10:27:51 -0700 Subject: [PATCH 02/11] Carry sessions when exporting a remote project (#333) A remote loop banks its session id and writes its transcript on the host it runs on, so the local-only export shipped every ssh:// and codespace:// bundle without sessions. SessionTransplant gains the export twin of restoreRemote: a host-side script per backend locates the session by the banked id (Claude), the banked id or the zmx-named session-state directory (Copilot), or the newest rollout for the loop's working directory (Codex), and streams it as tar on the dial's stdout; the local runner unpacks that into the same artifact keys the local export produces, so restore needs no change. ProjectPersistence's builders gain async remote-aware twins, which the app and the CLI now use; the CLI prints the session count. A fetch that fails leaves the loop without a session, never a failed export. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb --- .../Sources/ProjectPersistence+Export.swift | 185 ++++++++++----- .../Sources/Sessions/SessionTransplant.swift | 155 +++++++++++++ graphcode-cli/Sources/main.swift | 51 ++++- .../Features/Project/ProjectFeature.swift | 38 +-- .../Tests/RemoteSessionExportTests.swift | 216 ++++++++++++++++++ 5 files changed, 559 insertions(+), 86 deletions(-) diff --git a/GraphcodeKit/Sources/ProjectPersistence+Export.swift b/GraphcodeKit/Sources/ProjectPersistence+Export.swift index 5c360e4..8f5dbd7 100644 --- a/GraphcodeKit/Sources/ProjectPersistence+Export.swift +++ b/GraphcodeKit/Sources/ProjectPersistence+Export.swift @@ -14,6 +14,10 @@ extension ProjectPersistence { /// Import is *not* the mirror of this call: the daemon owns the live graph, so a /// bundle goes back in through `GraphCommand.importNodes`, never by writing the /// graph file from a client. + /// + /// Sessions are read off this Mac's disk — right for a local project. A remote + /// project's sessions are on its host, and this call would leave them behind; the + /// `async` twin below fetches them over the ssh dial. public func createExportBundle( for nodeIDs: [UUID], from graph: LoopGraph, @@ -22,95 +26,160 @@ extension ProjectPersistence { includeMemory: Bool = true, createdBy: String? = nil ) -> GraphExportBundle? { - var nodeIDsToExport = Set(nodeIDs) + guard let slice = slice(of: graph, for: nodeIDs, includeChildren: includeChildren) + else { return nil } + return bundle( + slice, projectPath: projectPath, includeMemory: includeMemory, createdBy: createdBy, + sessions: Self.sessionArtifacts(for: slice.nodes, projectPath: projectPath)) + } + + /// The same bundle, with sessions collected wherever the project actually lives: a + /// remote project's are fetched from its host (`SessionTransplant.exportRemoteArtifact`), + /// a local project's are read off this disk exactly as the synchronous call does. The + /// app and the CLI export through this one so an ssh:// or codespace:// export carries + /// its conversations (issue #333). + public func createExportBundle( + for nodeIDs: [UUID], + from graph: LoopGraph, + projectPath: String, + includeChildren: Bool = true, + includeMemory: Bool = true, + createdBy: String? = nil + ) async -> GraphExportBundle? { + guard let slice = slice(of: graph, for: nodeIDs, includeChildren: includeChildren) + else { return nil } + let sessions = await Self.remoteAwareSessionArtifacts( + for: slice.nodes, projectPath: projectPath) + return bundle( + slice, projectPath: projectPath, includeMemory: includeMemory, createdBy: createdBy, + sessions: sessions) + } + + /// Exports an entire graph as a shareable bundle. Sessions come off this disk, as in + /// `createExportBundle`; the `async` twin reaches a remote project's host. + public func createFullGraphExportBundle( + for graph: LoopGraph, + projectPath: String, + createdBy: String? = nil + ) -> GraphExportBundle { + bundle( + Slice(graph: graph, nodes: Array(graph.nodes), isFullGraph: true, includesChildren: true), + projectPath: projectPath, includeMemory: true, createdBy: createdBy, + sessions: Self.sessionArtifacts(for: graph.nodes, projectPath: projectPath)) + } + + /// The whole graph with sessions collected from wherever the project lives — see the + /// `async` `createExportBundle`. + public func createFullGraphExportBundle( + for graph: LoopGraph, + projectPath: String, + createdBy: String? = nil + ) async -> GraphExportBundle { + let sessions = await Self.remoteAwareSessionArtifacts( + for: graph.nodes, projectPath: projectPath) + return bundle( + Slice(graph: graph, nodes: Array(graph.nodes), isFullGraph: true, includesChildren: true), + projectPath: projectPath, includeMemory: true, createdBy: createdBy, sessions: sessions) + } + + /// Each exported loop's backend conversation, where one exists and the backend can + /// carry it — see `SessionTransplant`. Local disk only. + static func sessionArtifacts( + for nodes: some Sequence, projectPath: String + ) -> [String: SessionTransplant.Artifact] { + var artifacts: [String: SessionTransplant.Artifact] = [:] + for node in nodes { + if let artifact = SessionTransplant.exportArtifact(forNode: node, projectPath: projectPath) { + artifacts[node.id.uuidString] = artifact + } + } + return artifacts + } + + /// `sessionArtifacts` for a project on any host. A remote project's loops are fetched + /// concurrently — each is its own dial, multiplexed over one connection for a plain + /// host and a fresh tunnel per loop for a Codespace — and a loop whose fetch comes + /// back empty is simply exported without a session: the export never fails on one. + static func remoteAwareSessionArtifacts( + for nodes: some Sequence, projectPath: String + ) async -> [String: SessionTransplant.Artifact] { + guard let remote = RemoteProjectLocation.parse(projectPath: projectPath) else { + return sessionArtifacts(for: nodes, projectPath: projectPath) + } + return await withTaskGroup(of: (String, SessionTransplant.Artifact?).self) { group in + for node in nodes { + group.addTask { + ( + node.id.uuidString, + await SessionTransplant.exportRemoteArtifact(forNode: node, at: remote) + ) + } + } + var artifacts: [String: SessionTransplant.Artifact] = [:] + for await (nodeID, artifact) in group { + if let artifact { artifacts[nodeID] = artifact } + } + return artifacts + } + } + + /// The part of a graph an export names: the snapshot to write and the loops it holds. + private struct Slice { + var graph: LoopGraph + var nodes: [LoopNode] + var isFullGraph: Bool + var includesChildren: Bool + } + private func slice(of graph: LoopGraph, for nodeIDs: [UUID], includeChildren: Bool) -> Slice? { + var nodeIDsToExport = Set(nodeIDs) if includeChildren { for nodeID in nodeIDs { nodeIDsToExport.formUnion(descendants(of: nodeID, in: graph)) } } - let exportedNodes = graph.nodes.filter { nodeIDsToExport.contains($0.id) } guard !exportedNodes.isEmpty else { return nil } - let exportedEdges = graph.edges.filter { nodeIDsToExport.contains($0.from) && nodeIDsToExport.contains($0.to) } - let exportGraph = LoopGraph( id: graph.id, scope: graph.scope, nodes: IdentifiedArray(uniqueElements: exportedNodes), edges: IdentifiedArray(uniqueElements: exportedEdges) ) + return Slice( + graph: exportGraph, nodes: exportedNodes, + isFullGraph: Set(graph.nodes.map(\.id)) == nodeIDsToExport, + includesChildren: includeChildren) + } + private func bundle( + _ slice: Slice, projectPath: String, includeMemory: Bool, createdBy: String?, + sessions: [String: SessionTransplant.Artifact] + ) -> GraphExportBundle { var memoryByNodeID: [String: [String]] = [:] if includeMemory { - for nodeID in nodeIDsToExport { - let entries = NodeMemory.entries(forProjectPath: projectPath, nodeID: nodeID) + for node in slice.nodes { + let entries = NodeMemory.entries(forProjectPath: projectPath, nodeID: node.id) if !entries.isEmpty { - memoryByNodeID[nodeID.uuidString] = entries + memoryByNodeID[node.id.uuidString] = entries } } } - let contents = ExportContents( - nodeIDs: nodeIDsToExport.map(\.uuidString), - includesChildren: includeChildren, - isFullGraph: Set(graph.nodes.map(\.id)) == nodeIDsToExport, + nodeIDs: slice.nodes.map(\.id.uuidString), + includesChildren: slice.includesChildren, + isFullGraph: slice.isFullGraph, sourceProject: projectPath, includesMemory: includeMemory ) - - return GraphExportBundle( - manifest: ExportManifest(createdBy: createdBy, contents: contents), - graphSnapshot: exportGraph, - memoryByNodeID: memoryByNodeID, - sessionsByNodeID: Self.sessionArtifacts(for: exportedNodes, projectPath: projectPath) - ) - } - - /// Each exported loop's backend conversation, where one exists and the backend can - /// carry it — see `SessionTransplant`. - static func sessionArtifacts( - for nodes: some Sequence, projectPath: String - ) -> [String: SessionTransplant.Artifact] { - var artifacts: [String: SessionTransplant.Artifact] = [:] - for node in nodes { - if let artifact = SessionTransplant.exportArtifact(forNode: node, projectPath: projectPath) { - artifacts[node.id.uuidString] = artifact - } - } - return artifacts - } - - /// Exports an entire graph as a shareable bundle. - public func createFullGraphExportBundle( - for graph: LoopGraph, - projectPath: String, - createdBy: String? = nil - ) -> GraphExportBundle { - var memoryByNodeID: [String: [String]] = [:] - for node in graph.nodes { - let entries = NodeMemory.entries(forProjectPath: projectPath, nodeID: node.id) - if !entries.isEmpty { - memoryByNodeID[node.id.uuidString] = entries - } - } - - let contents = ExportContents( - nodeIDs: graph.nodes.map { $0.id.uuidString }, - includesChildren: true, - isFullGraph: true, - sourceProject: projectPath, - includesMemory: true - ) - return GraphExportBundle( manifest: ExportManifest(createdBy: createdBy, contents: contents), - graphSnapshot: graph, + graphSnapshot: slice.graph, memoryByNodeID: memoryByNodeID, - sessionsByNodeID: Self.sessionArtifacts(for: graph.nodes, projectPath: projectPath) + sessionsByNodeID: sessions ) } diff --git a/GraphcodeKit/Sources/Sessions/SessionTransplant.swift b/GraphcodeKit/Sources/Sessions/SessionTransplant.swift index 132ec76..47fc560 100644 --- a/GraphcodeKit/Sources/Sessions/SessionTransplant.swift +++ b/GraphcodeKit/Sources/Sessions/SessionTransplant.swift @@ -92,6 +92,161 @@ public enum SessionTransplant { } } + // MARK: - Remote export + + /// The remote twin of `exportArtifact`. A remote loop's session lives on the host it + /// runs on — its id banked at the file `PresenceHooks.remoteSessionIDExpression` + /// names, its transcript beside it — so reading this Mac's home directory found + /// nothing and every remote export shipped without sessions (issue #333). The host + /// locates the session and streams it back as `tar` on the ssh dial's stdout, the + /// mirror of `restoreRemote`'s tar-in; the stream is unpacked here and re-keyed to + /// exactly the artifact the local export produces, so `restore` and `restoreRemote` + /// need no remote-aware branch of their own. + /// + /// Nil for anything short of a whole session: nothing banked, the files gone, the + /// link dying mid-stream. A loop whose fetch fails is exported without a session — + /// the shape a local loop with nothing banked already has — never as a failed export. + /// No deadline of its own: a stopped Codespace can take minutes to deliver its first + /// byte while `gh` starts it, and the dial's keepalives already bound a dead link. + public static func exportRemoteArtifact( + forNode node: LoopNode, at location: RemoteProjectLocation + ) async -> Artifact? { + guard let script = remoteExportScript(forNode: node, at: location) else { return nil } + let staging = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-export-\(UUID().uuidString)", isDirectory: true) + guard + (try? FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)) + != nil + else { return nil } + defer { try? FileManager.default.removeItem(at: staging) } + RemoteProjectLocation.prepareControlSocketDirectory() + guard + await runShell(remoteExportPipeline(remoteScript: script, staging: staging, at: location)) + else { return nil } + return artifact( + fromFetched: filesUnder(staging), backend: node.backend, + workingDirectory: remoteWorkingDirectory(forNode: node, at: location)) + } + + /// The local half of the transfer: the dial's stdout straight into `tar -x`, so the + /// bytes never pass through a PTY or a `String` — a transcript is arbitrary bytes at + /// megabytes, the reason `deliver` is a pipeline too. The pipeline's status is the + /// untar's, deliberately not ssh's: `gh codespace ssh` flattens every remote exit to 1, + /// so the archive itself is the verdict. A link that dies mid-stream leaves a truncated + /// archive `tar` rejects; a host that found nothing sends an empty stream, which `tar` + /// accepts and extracts nothing from — and an empty staging directory is "nothing to + /// carry", the answer the local export gives for a loop with nothing banked. + static func remoteExportPipeline( + remoteScript: String, staging: URL, at location: RemoteProjectLocation + ) -> String { + location.sshCommandLine(remoteCommand: remoteScript) + + " | tar -xf - -C \(RemoteProjectLocation.shellQuoted(staging.path))" + } + + /// What the host runs to find the loop's session and stream it out — the mirror of + /// `remoteInstallScript`, and like it a pure function so its shape is testable. Each + /// backend's lookup is the one graphcode already trusts elsewhere: + /// + /// - Claude Code: the banked id — the file the ensure's resume branch consumes — then + /// the transcript by id across every project directory, because a worktree-bound + /// loop's transcript lives under the worktree's slug (`findClaudeTranscript`). + /// - Copilot: the banked id, else the directory whose `workspace.yaml` names the zmx + /// session graphcode launched it as — the walk `remoteIDBankFragment` does. + /// - Codex: the newest rollout whose header opened in the loop's working directory, + /// the match `CodexSessionLog.remoteSummaryInvocation` makes. + /// - OpenCode: nothing, for the reason the local export carries nothing. + /// + /// The archive's first path component is the session's identity — `.jsonl`, + /// `/…`, `rollout-….jsonl` — which is how the id reaches this side without a + /// second channel; `artifact(fromFetched:)` reads it back. A session that is not there + /// exits 0 having written nothing. Not wrapped in the login shell the probes use: an + /// interactive `zsh -i` may print from its rc files, and stdout here *is* the archive. + static func remoteExportScript( + forNode node: LoopNode, at location: RemoteProjectLocation + ) -> String? { + let idFile = PresenceHooks.remoteSessionIDExpression(forNodeID: node.id) + switch node.backend { + case .claudeCode: + return "S=$(cat \(idFile) 2>/dev/null); [ -n \"$S\" ] || exit 0; " + + "F=$(ls -t \"$HOME\"/.claude/projects/*/\"$S\".jsonl 2>/dev/null | head -1); " + + "[ -n \"$F\" ] || exit 0; exec tar -cf - -C \"$(dirname \"$F\")\" \"$S.jsonl\"" + case .copilotCLI: + let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName + return "S=$(cat \(idFile) 2>/dev/null); if [ -z \"$S\" ]; then " + + "for d in $(ls -t \"$HOME/.copilot/session-state/\" 2>/dev/null); do " + + "if grep -qx 'name: \(name)' " + + "\"$HOME/.copilot/session-state/$d/workspace.yaml\" 2>/dev/null; " + + "then S=\"$d\"; break; fi; done; fi; " + + "[ -n \"$S\" ] && [ -d \"$HOME/.copilot/session-state/$S\" ] || exit 0; " + + "exec tar -cf - -C \"$HOME/.copilot/session-state\" \"$S\"" + case .codex: + let directory = RemoteProjectLocation.shellQuoted( + remoteWorkingDirectory(forNode: node, at: location)) + return "W=\(directory); F=''; " + + "for f in $(ls -t \"$HOME\"/.codex/sessions/*/*/*/rollout-*.jsonl 2>/dev/null" + + " | head -40); do " + + "if head -c 65536 \"$f\" 2>/dev/null | grep -q \"\\\"cwd\\\":\\\"$W\\\"\"; " + + "then F=\"$f\"; break; fi; done; " + + "[ -n \"$F\" ] || exit 0; exec tar -cf - -C \"$(dirname \"$F\")\" \"$(basename \"$F\")\"" + case .openCode: + return nil + } + } + + /// A remote loop's working directory: its worktree if bound, else the project folder + /// on that host — `CodexSessionLog.summary`'s answer, because the local one checks the + /// path exists on this Mac and a remote one never does. + static func remoteWorkingDirectory( + forNode node: LoopNode, at location: RemoteProjectLocation + ) -> String { + node.worktreeBinding?.worktreePath ?? location.remotePath + } + + /// The fetched archive as the artifact the *local* export would have produced for the + /// same session — same keys, same id, so nothing downstream can tell where a session + /// came from. The first path component carries the identity: for Claude and Codex a + /// single file named by it, for Copilot the session directory, stripped from every key. + /// Anything else — two transcripts, a stray file beside the directory — is not a + /// session this side knows how to restore, and is refused rather than guessed at. + static func artifact( + fromFetched files: [String: Data], backend: CLISessionBackendKind, workingDirectory: String + ) -> Artifact? { + switch backend { + case .claudeCode: + guard let only = singleFile(in: files) else { return nil } + return Artifact( + backend: .claudeCode, sessionID: String(only.name.dropLast(".jsonl".count)), + sourceWorkingDirectory: workingDirectory, files: ["transcript.jsonl": only.data]) + case .copilotCLI: + guard let first = files.keys.sorted().first, let slash = first.firstIndex(of: "/") + else { return nil } + let sessionID = String(first[.. (name: String, data: Data)? { + guard files.count == 1, let entry = files.first, !entry.key.contains("/"), + entry.key.hasSuffix(".jsonl") + else { return nil } + return (entry.key, entry.value) + } + // MARK: - Restore /// Installs an exported session for a freshly imported node, under a fresh identity, diff --git a/graphcode-cli/Sources/main.swift b/graphcode-cli/Sources/main.swift index c9df34c..5315e26 100644 --- a/graphcode-cli/Sources/main.swift +++ b/graphcode-cli/Sources/main.swift @@ -131,6 +131,22 @@ func openProject(_ projectPath: String) throws -> LoopGraph? { /// it prints: one loop's unread slice, one post, or the whole room. A refusal (the /// project not open, a path the daemon does not know) arrives as an error and stops /// here, the way `openProject`'s does. +/// Runs an export's async bundle build to completion from this synchronous top level — +/// the `reap`/`importNodes` semaphore pattern — because a remote project's sessions +/// arrive over ssh. +func awaitBundle(_ build: @escaping @Sendable () async -> GraphExportBundle?) -> GraphExportBundle? +{ + final class Box: @unchecked Sendable { var bundle: GraphExportBundle? } + let box = Box() + let built = DispatchSemaphore(value: 0) + Task { + box.bundle = await build() + built.signal() + } + built.wait() + return box.bundle +} + func fetchMailbox(_ projectPath: String, _ query: MailboxQuery) throws -> Mailbox { try sendCommand(.mailbox(projectPath: projectPath, query: query)) phase = "waiting for the mailbox answer" @@ -551,15 +567,20 @@ do { case .exportNode(let projectPath, let nodeID, let output, let includeChildren): guard let graph = try openProject(projectPath) else { fail("Could not load graph") } + // Async behind a semaphore, the `importNodes` pattern in reverse: a remote + // project's sessions are fetched from its host over ssh, and a loop whose fetch + // fails is exported without one — the count printed last is what actually rode. let persistence = ProjectPersistence(baseDirectory: SupportDirectory.url) guard - let bundle = persistence.createExportBundle( - for: [nodeID], - from: graph, - projectPath: projectPath, - includeChildren: includeChildren, - createdBy: ProcessInfo.processInfo.environment["USER"] - ) + let bundle = awaitBundle({ + await persistence.createExportBundle( + for: [nodeID], + from: graph, + projectPath: projectPath, + includeChildren: includeChildren, + createdBy: ProcessInfo.processInfo.environment["USER"] + ) + }) else { fail("Could not create export bundle") } guard let zipPath = bundle.writeToZip(at: output) else { @@ -569,16 +590,21 @@ do { print("Exported to \(zipPath)") print("Nodes: \(bundle.manifest.contents.nodeIDs.count)") print("Memory logs: \(bundle.memoryByNodeID.count)") + print("Sessions: \(bundle.sessionsByNodeID.count)") case .exportGraph(let projectPath, let output): guard let graph = try openProject(projectPath) else { fail("Could not load graph") } let persistence = ProjectPersistence(baseDirectory: SupportDirectory.url) - let bundle = persistence.createFullGraphExportBundle( - for: graph, - projectPath: projectPath, - createdBy: ProcessInfo.processInfo.environment["USER"] - ) + guard + let bundle = awaitBundle({ + await persistence.createFullGraphExportBundle( + for: graph, + projectPath: projectPath, + createdBy: ProcessInfo.processInfo.environment["USER"] + ) + }) + else { fail("Could not create export bundle") } guard let zipPath = bundle.writeToZip(at: output) else { fail("Could not write ZIP file to \(output)") @@ -588,6 +614,7 @@ do { print("Nodes: \(bundle.manifest.contents.nodeIDs.count)") print("Edges: \(graph.edges.count)") print("Memory logs: \(bundle.memoryByNodeID.count)") + print("Sessions: \(bundle.sessionsByNodeID.count)") case .importNodes(let projectPath, let fromZip, let asChildOf): guard let bundle = GraphExportBundle.readFromZip(at: fromZip) else { diff --git a/graphcode/Sources/Features/Project/ProjectFeature.swift b/graphcode/Sources/Features/Project/ProjectFeature.swift index 0dae5ed..8b28d3d 100644 --- a/graphcode/Sources/Features/Project/ProjectFeature.swift +++ b/graphcode/Sources/Features/Project/ProjectFeature.swift @@ -964,32 +964,38 @@ extension ProjectFeature { /// /// Export is read-only, so unlike import it never goes near the daemon: the graph in /// hand is the daemon's own latest broadcast, and memory logs are read straight off - /// disk. The finished zip is revealed in Finder — that reveal *is* the success - /// feedback, pointing at the file the user is about to go share. + /// disk. Sessions are read off disk too for a local project, and fetched from the + /// host over ssh for a remote one — off the main actor, since that is round-trips + /// and the panel is long dismissed. The finished zip is revealed in Finder — that + /// reveal *is* the success feedback, pointing at the file the user is about to go + /// share. private func exportBundle( from graph: LoopGraph, projectPath: String, nodeIDs: [UUID]?, suggestedName: String ) -> Effect { .run { _ in - await MainActor.run { + let destination = await MainActor.run { () -> URL? in let panel = NSSavePanel() panel.allowedContentTypes = [.zip] panel.nameFieldStringValue = suggestedName.replacingOccurrences(of: "/", with: "-") + ".zip" panel.message = "Export loops as a shareable bundle" - guard panel.runModal() == .OK, let url = panel.url else { return } - - let persistence = ProjectPersistence(baseDirectory: SupportDirectory.url) - let bundle: GraphExportBundle? = - if let nodeIDs { - persistence.createExportBundle( - for: nodeIDs, from: graph, projectPath: projectPath, createdBy: NSUserName()) - } else { - persistence.createFullGraphExportBundle( - for: graph, projectPath: projectPath, createdBy: NSUserName()) - } - guard let bundle, bundle.writeToZip(at: url.path) != nil else { return } - NSWorkspace.shared.activateFileViewerSelecting([url]) + guard panel.runModal() == .OK else { return nil } + return panel.url } + guard let url = destination else { return } + + let persistence = ProjectPersistence(baseDirectory: SupportDirectory.url) + let createdBy = NSUserName() + let bundle: GraphExportBundle? = + if let nodeIDs { + await persistence.createExportBundle( + for: nodeIDs, from: graph, projectPath: projectPath, createdBy: createdBy) + } else { + await persistence.createFullGraphExportBundle( + for: graph, projectPath: projectPath, createdBy: createdBy) + } + guard let bundle, bundle.writeToZip(at: url.path) != nil else { return } + await MainActor.run { NSWorkspace.shared.activateFileViewerSelecting([url]) } } } diff --git a/graphcode/Tests/RemoteSessionExportTests.swift b/graphcode/Tests/RemoteSessionExportTests.swift index afb0994..a7c0c22 100644 --- a/graphcode/Tests/RemoteSessionExportTests.swift +++ b/graphcode/Tests/RemoteSessionExportTests.swift @@ -3,6 +3,222 @@ import Testing @testable import GraphcodeKit +/// What `SessionTransplant.exportRemoteArtifact` asks the host for, and how what comes +/// back becomes an artifact. The local export reads this Mac's home directory, which a +/// remote loop never wrote to — so an ssh:// or codespace:// export carried the graph and +/// memory logs and no sessions (issue #333). The remote fetch has to read the id the +/// host banked itself, find the backend's files by that id where the backend keeps them, +/// and hand back exactly the artifact the local export would have built — the same +/// keys — so `restore` and `restoreRemote` stay untouched. @Suite struct RemoteSessionExportTests { + private let location = RemoteProjectLocation( + user: "dev", host: "buildbox", remotePath: "/srv/widget") + + private func node( + _ backend: CLISessionBackendKind, worktree: String? = nil + ) -> LoopNode { + var node = LoopNode(title: "Worker", loopType: .goalBased, goal: GoalSpec(summary: "ship")) + node.backend = backend + if let worktree { + node.worktreeBinding = WorktreeRef( + id: "wt", repositoryPath: "/srv/widget", worktreePath: worktree, branch: "topic") + } + return node + } + + private func script(_ node: LoopNode) throws -> String { + try #require(SessionTransplant.remoteExportScript(forNode: node, at: location)) + } + + // MARK: - Script shape per backend + + @Test + func claudeFetchReadsTheBankedIDThenTheTranscriptByID() throws { + let node = node(.claudeCode) + let script = try script(node) + + // The id decides which transcript; the directory is found, not reconstructed — a + // worktree-bound loop's transcript lives under the worktree's slug. + #expect(script.contains(".graphcode/sessions/\(node.id.uuidString).id")) + #expect(script.contains("\"$HOME\"/.claude/projects/*/\"$S\".jsonl")) + #expect(script.hasSuffix("exec tar -cf - -C \"$(dirname \"$F\")\" \"$S.jsonl\"")) + } + + @Test + func fetchScriptsStreamNothingAndExitCleanlyWhenNothingIsBanked() throws { + for backend in [CLISessionBackendKind.claudeCode, .copilotCLI, .codex] { + let script = try script(node(backend)) + #expect(script.contains("|| exit 0"), "\(backend)") + // The archive is the whole of stdout: nothing may print before `tar` does. + #expect(!script.contains("echo"), "\(backend)") + #expect(!script.contains("printf"), "\(backend)") + #expect(!script.hasPrefix("exec zsh"), "\(backend)") + } + } + + @Test + func copilotFetchFallsBackToTheDirectoryNamedAfterTheZmxSession() throws { + let node = node(.copilotCLI) + let script = try script(node) + let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName + + // Banked id first, then the same `workspace.yaml` walk the ensure's bank fragment + // does — Copilot has no hook to bank its own id, so a session may be unbanked. + let bank = try #require(script.range(of: "cat \"$HOME/.graphcode/sessions\"")) + let walk = try #require(script.range(of: "grep -qx 'name: \(name)'")) + #expect(bank.lowerBound < walk.lowerBound) + #expect(script.contains("/.copilot/session-state/$d/workspace.yaml")) + #expect(script.hasSuffix("exec tar -cf - -C \"$HOME/.copilot/session-state\" \"$S\"")) + } + + @Test + func codexFetchMatchesTheNewestRolloutForTheLoopsWorkingDirectory() throws { + let project = try script(node(.codex)) + let bound = try script(node(.codex, worktree: "/srv/widget-wt/topic")) + + // The working directory is the only handle on a Codex rollout, and it is the + // loop's own worktree when it has one, the project folder on the host otherwise. + #expect(project.contains("W='/srv/widget';")) + #expect(bound.contains("W='/srv/widget-wt/topic';")) + #expect(project.contains("\"$HOME\"/.codex/sessions/*/*/*/rollout-*.jsonl")) + #expect(project.contains("ls -t")) + #expect(project.contains("\\\"cwd\\\":\\\"$W\\\"")) + #expect( + project.hasSuffix("exec tar -cf - -C \"$(dirname \"$F\")\" \"$(basename \"$F\")\"")) + } + + @Test + func openCodeHasNoRemoteFetch() { + #expect(SessionTransplant.remoteExportScript(forNode: node(.openCode), at: location) == nil) + } + + @Test + func bankPathMatchesWhatTheEnsureConsumes() throws { + // The reader here and the reader in `remoteCreateScript` must name the same file + // the remote `SessionStart` hook wrote; drifting apart would not fail loudly — + // every remote export would just silently carry no sessions, the exact bug this + // path exists to end. Same shape as the install-side parity test. + let node = node(.claudeCode) + let script = try script(node) + let consumed = PresenceHooks.remoteSessionIDExpression(forNodeID: node.id) + let read = try #require(script.range(of: "S=$(cat ")) + let end = try #require( + script.range(of: " 2>/dev/null)", range: read.upperBound.. Date: Wed, 9 Sep 2026 10:28:39 -0700 Subject: [PATCH 03/11] Keep ProjectFeature under swiftlint's file-length cap Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb --- graphcode/Sources/Features/Project/ProjectFeature.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/graphcode/Sources/Features/Project/ProjectFeature.swift b/graphcode/Sources/Features/Project/ProjectFeature.swift index 8b28d3d..fe39274 100644 --- a/graphcode/Sources/Features/Project/ProjectFeature.swift +++ b/graphcode/Sources/Features/Project/ProjectFeature.swift @@ -964,11 +964,9 @@ extension ProjectFeature { /// /// Export is read-only, so unlike import it never goes near the daemon: the graph in /// hand is the daemon's own latest broadcast, and memory logs are read straight off - /// disk. Sessions are read off disk too for a local project, and fetched from the - /// host over ssh for a remote one — off the main actor, since that is round-trips - /// and the panel is long dismissed. The finished zip is revealed in Finder — that - /// reveal *is* the success feedback, pointing at the file the user is about to go - /// share. + /// disk. A remote project's sessions are fetched from its host over ssh — off the + /// main actor, since that is round-trips and the panel is long dismissed. The finished + /// zip is revealed in Finder — that reveal *is* the success feedback. private func exportBundle( from graph: LoopGraph, projectPath: String, nodeIDs: [UUID]?, suggestedName: String ) -> Effect { From 1987877a3f22ceb06bb289fe439829fcd797ee17 Mon Sep 17 00:00:00 2001 From: scgopi Date: Wed, 9 Sep 2026 10:29:42 -0700 Subject: [PATCH 04/11] Hand the export slice an Array of the filtered nodes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb --- GraphcodeKit/Sources/ProjectPersistence+Export.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GraphcodeKit/Sources/ProjectPersistence+Export.swift b/GraphcodeKit/Sources/ProjectPersistence+Export.swift index 8f5dbd7..afe80bc 100644 --- a/GraphcodeKit/Sources/ProjectPersistence+Export.swift +++ b/GraphcodeKit/Sources/ProjectPersistence+Export.swift @@ -150,7 +150,7 @@ extension ProjectPersistence { edges: IdentifiedArray(uniqueElements: exportedEdges) ) return Slice( - graph: exportGraph, nodes: exportedNodes, + graph: exportGraph, nodes: Array(exportedNodes), isFullGraph: Set(graph.nodes.map(\.id)) == nodeIDsToExport, includesChildren: includeChildren) } From cbe7c24e7ae2be81f9b6d8da018dd5d95435051d Mon Sep 17 00:00:00 2001 From: scgopi Date: Wed, 9 Sep 2026 10:32:15 -0700 Subject: [PATCH 05/11] Test that an unreachable host exports no session rather than failing Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb --- graphcode/Tests/RemoteSessionExportTests.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/graphcode/Tests/RemoteSessionExportTests.swift b/graphcode/Tests/RemoteSessionExportTests.swift index a7c0c22..c4f3f4b 100644 --- a/graphcode/Tests/RemoteSessionExportTests.swift +++ b/graphcode/Tests/RemoteSessionExportTests.swift @@ -195,6 +195,22 @@ struct RemoteSessionExportTests { fromFetched: ["x.jsonl": Data()], backend: .openCode, workingDirectory: "/") == nil) } + @Test + func anUnreachableHostYieldsNoSessionRatherThanAFailure() async { + // Port 1 on loopback refuses at once, so the dial dies before a byte arrives — the + // runner must hand back nil, and the bundle builder must carry on without it. + let unreachable = RemoteProjectLocation( + user: "nobody", host: "127.0.0.1", port: 1, remotePath: "/srv/none") + let node = node(.claudeCode) + + let artifact = await SessionTransplant.exportRemoteArtifact(forNode: node, at: unreachable) + let sessions = await ProjectPersistence.remoteAwareSessionArtifacts( + for: [node], projectPath: unreachable.projectPath) + + #expect(artifact == nil) + #expect(sessions.isEmpty) + } + // MARK: - The bundle builders @Test From b04300f05fa5f7ff70b18f3f5f4338e184c3fc14 Mon Sep 17 00:00:00 2001 From: scgopi Date: Wed, 9 Sep 2026 10:35:28 -0700 Subject: [PATCH 06/11] Refuse a partial remote session and correct the ControlPath comment The dial's own exit status is recorded beside the staging directory by a POSIX group rather than pipefail, which /bin/sh is not guaranteed to know: a tar that lost a member mid-archive still emits the rest and exits non-zero, and bytes plus a failure is a partial session, not one to carry. The RemoteProjectLocation comment claimed a missing ControlPath directory only warns; on OpenSSH 10.3 it is exit 255 with nothing sent. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb --- .../Domain/RemoteProjectLocation.swift | 6 ++- .../Sources/Sessions/SessionTransplant.swift | 38 ++++++++++++++----- .../Tests/RemoteSessionExportTests.swift | 23 +++++++---- 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/GraphcodeKit/Sources/Domain/RemoteProjectLocation.swift b/GraphcodeKit/Sources/Domain/RemoteProjectLocation.swift index 6b5c08d..e319b1b 100644 --- a/GraphcodeKit/Sources/Domain/RemoteProjectLocation.swift +++ b/GraphcodeKit/Sources/Domain/RemoteProjectLocation.swift @@ -143,8 +143,10 @@ public struct RemoteProjectLocation: Equatable, Sendable { /// is where the sporadic "ssh failed, retrying" noise on healthy networks came from. /// A mux channel over a live master cannot fail in transport the way a fresh dial can. /// `ControlPersist` keeps the master up between ticks; a dead master is redialed by - /// whichever command comes next. If the socket directory is missing ssh just warns and - /// dials directly, so this degrades to the old behaviour, never to a failure. + /// whichever command comes next. A missing socket directory is a hard failure, not a + /// warning: OpenSSH 10.3 exits 255 with `unix_listener: cannot bind to path` and sends + /// nothing (measured on the loopback rig), so every spawn site calls + /// `prepareControlSocketDirectory()` before dialing. /// /// A Codespace dials through `gh codespace ssh -c -- ` /// instead — everything after `--` reaches gh's underlying ssh untouched, so the diff --git a/GraphcodeKit/Sources/Sessions/SessionTransplant.swift b/GraphcodeKit/Sources/Sessions/SessionTransplant.swift index 47fc560..873a674 100644 --- a/GraphcodeKit/Sources/Sessions/SessionTransplant.swift +++ b/GraphcodeKit/Sources/Sessions/SessionTransplant.swift @@ -118,10 +118,15 @@ public enum SessionTransplant { (try? FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)) != nil else { return nil } - defer { try? FileManager.default.removeItem(at: staging) } + let status = remoteExportStatusFile(besideStaging: staging) + defer { + try? FileManager.default.removeItem(at: staging) + try? FileManager.default.removeItem(at: status) + } RemoteProjectLocation.prepareControlSocketDirectory() guard - await runShell(remoteExportPipeline(remoteScript: script, staging: staging, at: location)) + await runShell(remoteExportPipeline(remoteScript: script, staging: staging, at: location)), + (try? String(contentsOf: status, encoding: .utf8)) == "0" else { return nil } return artifact( fromFetched: filesUnder(staging), backend: node.backend, @@ -130,19 +135,34 @@ public enum SessionTransplant { /// The local half of the transfer: the dial's stdout straight into `tar -x`, so the /// bytes never pass through a PTY or a `String` — a transcript is arbitrary bytes at - /// megabytes, the reason `deliver` is a pipeline too. The pipeline's status is the - /// untar's, deliberately not ssh's: `gh codespace ssh` flattens every remote exit to 1, - /// so the archive itself is the verdict. A link that dies mid-stream leaves a truncated - /// archive `tar` rejects; a host that found nothing sends an empty stream, which `tar` - /// accepts and extracts nothing from — and an empty staging directory is "nothing to - /// carry", the answer the local export gives for a loop with nothing banked. + /// megabytes, the reason `deliver` is a pipeline too. Two verdicts, both required: + /// the untar's, which is the pipeline's own status and rejects a stream the link + /// truncated; and the dial's, recorded to a file beside the staging directory because + /// a `tar -c` that lost a member mid-archive still emits a complete archive of the + /// rest and exits non-zero — bytes plus a failure is a *partial* session, not one to + /// carry (measured on the loopback rig, GNU and bsd tar alike). `gh codespace ssh` + /// flattens every remote exit to 1, which is still non-zero, so the rule holds there. + /// Recorded by a POSIX group rather than `set -o pipefail`, which `/bin/sh` is not + /// guaranteed to know (dash rejects it and exits). A host that found nothing sends an + /// empty stream and exits 0, which `tar` accepts and extracts nothing from — an empty + /// staging directory is "nothing to carry", the local export's answer for a loop with + /// nothing banked. static func remoteExportPipeline( remoteScript: String, staging: URL, at location: RemoteProjectLocation ) -> String { - location.sshCommandLine(remoteCommand: remoteScript) + let status = RemoteProjectLocation.shellQuoted( + remoteExportStatusFile(besideStaging: staging).path) + return "{ " + location.sshCommandLine(remoteCommand: remoteScript) + + "; printf %s \"$?\" > \(status); }" + " | tar -xf - -C \(RemoteProjectLocation.shellQuoted(staging.path))" } + /// Where the pipeline leaves the dial's exit status: beside the staging directory, + /// never inside it, or `filesUnder` would carry it as part of the session. + static func remoteExportStatusFile(besideStaging staging: URL) -> URL { + URL(fileURLWithPath: staging.path + ".status") + } + /// What the host runs to find the loop's session and stream it out — the mirror of /// `remoteInstallScript`, and like it a pure function so its shape is testable. Each /// backend's lookup is the one graphcode already trusts elsewhere: diff --git a/graphcode/Tests/RemoteSessionExportTests.swift b/graphcode/Tests/RemoteSessionExportTests.swift index c4f3f4b..1292b2f 100644 --- a/graphcode/Tests/RemoteSessionExportTests.swift +++ b/graphcode/Tests/RemoteSessionExportTests.swift @@ -116,12 +116,15 @@ struct RemoteSessionExportTests { let pipeline = SessionTransplant.remoteExportPipeline( remoteScript: "exec tar -cf - x", staging: staging, at: location) - // ssh's argv, single-quoted for `/bin/sh`, then the untar — the archive never - // passes through a PTY or a String. - #expect(pipeline.hasPrefix("'/usr/bin/ssh' ")) - #expect(pipeline.contains("'dev@buildbox' '--' 'exec tar -cf - x'")) - #expect(pipeline.hasSuffix(" | tar -xf - -C '/tmp/graphcode-export-x'")) - #expect(!pipeline.contains("-t ")) + // ssh's argv, single-quoted for `/bin/sh`, with its exit status recorded beside the + // staging directory — a partial archive arrives with a non-zero status and must not + // be carried — then the untar. The archive never passes through a PTY or a String. + #expect(pipeline.hasPrefix("{ '/usr/bin/ssh' ")) + #expect(pipeline.contains("'dev@buildbox' '--' 'exec tar -cf - x'; ")) + #expect(pipeline.contains("printf %s \"$?\" > '/tmp/graphcode-export-x.status'; }")) + #expect(pipeline.hasSuffix("} | tar -xf - -C '/tmp/graphcode-export-x'")) + #expect(!pipeline.contains("'-t'")) + #expect(!pipeline.contains("zsh")) } @Test @@ -225,8 +228,12 @@ struct RemoteSessionExportTests { let project = ProjectRef(path: directory.path, name: "local") let graph = LoopGraph(project: project, nodes: [node(.claudeCode)]) - let sync = persistence.createExportBundle( - for: [graph.nodes[0].id], from: graph, projectPath: project.path) + // From a synchronous closure: in an async context the async overload wins, which is + // the resolution the app and CLI rely on. + let sync = { + persistence.createExportBundle( + for: [graph.nodes[0].id], from: graph, projectPath: project.path) + }() let async = await persistence.createExportBundle( for: [graph.nodes[0].id], from: graph, projectPath: project.path) let full = await persistence.createFullGraphExportBundle( From 13cdd417a9de02338735f78d9463113fba00f940 Mon Sep 17 00:00:00 2001 From: scgopi Date: Wed, 9 Sep 2026 10:55:01 -0700 Subject: [PATCH 07/11] Bound a remote session fetch at ten minutes A stopped Codespace can take five minutes to its first byte; a silent remote or a wedged ssh master must not hold an export forever. Job control gives the pipeline its own process group so the deadline kills ssh and tar together rather than orphaning them on their pipe, and the loop is exported without a session, never as a failed export. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb --- .../Sources/Sessions/SessionTransplant.swift | 33 ++++++++++++++++--- .../Tests/RemoteSessionExportTests.swift | 19 +++++++++-- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/GraphcodeKit/Sources/Sessions/SessionTransplant.swift b/GraphcodeKit/Sources/Sessions/SessionTransplant.swift index 873a674..18cbca2 100644 --- a/GraphcodeKit/Sources/Sessions/SessionTransplant.swift +++ b/GraphcodeKit/Sources/Sessions/SessionTransplant.swift @@ -104,10 +104,9 @@ public enum SessionTransplant { /// need no remote-aware branch of their own. /// /// Nil for anything short of a whole session: nothing banked, the files gone, the - /// link dying mid-stream. A loop whose fetch fails is exported without a session — - /// the shape a local loop with nothing banked already has — never as a failed export. - /// No deadline of its own: a stopped Codespace can take minutes to deliver its first - /// byte while `gh` starts it, and the dial's keepalives already bound a dead link. + /// link dying mid-stream, the deadline (`remoteExportDeadlineSeconds`) passing. A loop + /// whose fetch fails is exported without a session — the shape a local loop with + /// nothing banked already has — never as a failed export. public static func exportRemoteArtifact( forNode node: LoopNode, at location: RemoteProjectLocation ) async -> Artifact? { @@ -152,9 +151,33 @@ public enum SessionTransplant { ) -> String { let status = RemoteProjectLocation.shellQuoted( remoteExportStatusFile(besideStaging: staging).path) - return "{ " + location.sshCommandLine(remoteCommand: remoteScript) + let pipeline = + "{ " + location.sshCommandLine(remoteCommand: remoteScript) + "; printf %s \"$?\" > \(status); }" + " | tar -xf - -C \(RemoteProjectLocation.shellQuoted(staging.path))" + return bounded(pipeline, seconds: remoteExportDeadlineSeconds) + } + + /// How long one loop's fetch may take before it is abandoned: generous enough for a + /// stopped Codespace, which `gh` starts on the way in and which can take five minutes + /// to deliver its first byte, and finite so that a live-but-silent remote or a wedged + /// ssh master can never hang an export. On the deadline the loop is exported without + /// a session, never as a failed export. + static let remoteExportDeadlineSeconds = 600 + + /// `pipeline` under a watchdog. Job control (`set -m`) puts the pipeline in a process + /// group of its own, and the deadline kills that *group*: terminating only the shell + /// would orphan `ssh` and `tar`, still joined by their pipe, holding the connection + /// open for as long as the remote stayed silent. The watchdog's own group is killed + /// on the way out so a fetch that finished in a second leaves no ten-minute `sleep` + /// behind. Measured on a silent pipeline: dead at the deadline with exit 143 and no + /// process left. `dash` runs this with job control off and a warning, so on a Linux + /// host the deadline ends the wait but not the children — acceptable for the one + /// caller, whose runner only ever runs on the Mac. + static func bounded(_ pipeline: String, seconds: Int) -> String { + "set -m; { \(pipeline); } & gc_p=$!; " + + "{ sleep \(seconds); kill -TERM -- -$gc_p; } 2>/dev/null & gc_w=$!; " + + "wait $gc_p; gc_s=$?; kill -TERM -- -$gc_w 2>/dev/null; exit $gc_s" } /// Where the pipeline leaves the dial's exit status: beside the staging directory, diff --git a/graphcode/Tests/RemoteSessionExportTests.swift b/graphcode/Tests/RemoteSessionExportTests.swift index 1292b2f..0e53eb5 100644 --- a/graphcode/Tests/RemoteSessionExportTests.swift +++ b/graphcode/Tests/RemoteSessionExportTests.swift @@ -119,14 +119,29 @@ struct RemoteSessionExportTests { // ssh's argv, single-quoted for `/bin/sh`, with its exit status recorded beside the // staging directory — a partial archive arrives with a non-zero status and must not // be carried — then the untar. The archive never passes through a PTY or a String. - #expect(pipeline.hasPrefix("{ '/usr/bin/ssh' ")) + #expect(pipeline.contains("{ '/usr/bin/ssh' ")) #expect(pipeline.contains("'dev@buildbox' '--' 'exec tar -cf - x'; ")) #expect(pipeline.contains("printf %s \"$?\" > '/tmp/graphcode-export-x.status'; }")) - #expect(pipeline.hasSuffix("} | tar -xf - -C '/tmp/graphcode-export-x'")) + #expect(pipeline.contains("} | tar -xf - -C '/tmp/graphcode-export-x'; }")) #expect(!pipeline.contains("'-t'")) #expect(!pipeline.contains("zsh")) } + @Test + func pipelineIsBoundedAndKillsItsOwnProcessGroupOnTheDeadline() { + let pipeline = SessionTransplant.remoteExportPipeline( + remoteScript: "x", staging: URL(fileURLWithPath: "/tmp/s"), at: location) + + // Ten minutes: a stopped Codespace can take five to its first byte; a silent remote + // or a wedged ssh master must not hold an export forever. The whole group dies, or + // ssh and tar would outlive the shell still joined by their pipe. + #expect(SessionTransplant.remoteExportDeadlineSeconds == 600) + #expect(pipeline.hasPrefix("set -m; ")) + #expect(pipeline.contains("sleep 600; kill -TERM -- -$gc_p")) + #expect(pipeline.contains("wait $gc_p; gc_s=$?; kill -TERM -- -$gc_w")) + #expect(pipeline.hasSuffix("exit $gc_s")) + } + @Test func codespacePipelineDialsThroughGh() { let codespace = RemoteProjectLocation( From bef747be1c35562b39b9504c56f7ed49abb8c138 Mon Sep 17 00:00:00 2001 From: scgopi Date: Wed, 9 Sep 2026 10:55:37 -0700 Subject: [PATCH 08/11] Check the banked-id read against the expression itself Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb --- graphcode/Tests/RemoteSessionExportTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/graphcode/Tests/RemoteSessionExportTests.swift b/graphcode/Tests/RemoteSessionExportTests.swift index 0e53eb5..8ad7e4f 100644 --- a/graphcode/Tests/RemoteSessionExportTests.swift +++ b/graphcode/Tests/RemoteSessionExportTests.swift @@ -40,7 +40,8 @@ struct RemoteSessionExportTests { // The id decides which transcript; the directory is found, not reconstructed — a // worktree-bound loop's transcript lives under the worktree's slug. - #expect(script.contains(".graphcode/sessions/\(node.id.uuidString).id")) + #expect( + script.contains("S=$(cat \(PresenceHooks.remoteSessionIDExpression(forNodeID: node.id))")) #expect(script.contains("\"$HOME\"/.claude/projects/*/\"$S\".jsonl")) #expect(script.hasSuffix("exec tar -cf - -C \"$(dirname \"$F\")\" \"$S.jsonl\"")) } From e168b97beb07c037a44bb4f7732891e7616bc384 Mon Sep 17 00:00:00 2001 From: scgopi Date: Wed, 9 Sep 2026 10:59:07 -0700 Subject: [PATCH 09/11] Give the bounded fetch a /dev/null stdin A background process group that reads the controlling terminal is stopped with SIGTTIN, and ssh reads its inherited stdin; nothing is ever sent to the host on this path, so the job's stdin is /dev/null. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb --- GraphcodeKit/Sources/Sessions/SessionTransplant.swift | 9 ++++++++- graphcode/Tests/RemoteSessionExportTests.swift | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/GraphcodeKit/Sources/Sessions/SessionTransplant.swift b/GraphcodeKit/Sources/Sessions/SessionTransplant.swift index 18cbca2..a69a511 100644 --- a/GraphcodeKit/Sources/Sessions/SessionTransplant.swift +++ b/GraphcodeKit/Sources/Sessions/SessionTransplant.swift @@ -174,8 +174,15 @@ public enum SessionTransplant { /// process left. `dash` runs this with job control off and a warning, so on a Linux /// host the deadline ends the wait but not the children — acceptable for the one /// caller, whose runner only ever runs on the Mac. + /// + /// The job's stdin is `/dev/null`, and that is load-bearing: a background process + /// group that reads the controlling terminal is stopped with `SIGTTIN`, and `ssh` + /// reads its inherited stdin. From the CLI in a Terminal that stdin *is* the tty, so + /// without the redirect the dial stopped silently, the watchdog fired at the deadline, + /// and the loop exported with no session (found in review; every measurement here had + /// run without a tty). Nothing is ever sent to the host on this path. static func bounded(_ pipeline: String, seconds: Int) -> String { - "set -m; { \(pipeline); } & gc_p=$!; " + "set -m; { \(pipeline); } /dev/null & gc_w=$!; " + "wait $gc_p; gc_s=$?; kill -TERM -- -$gc_w 2>/dev/null; exit $gc_s" } diff --git a/graphcode/Tests/RemoteSessionExportTests.swift b/graphcode/Tests/RemoteSessionExportTests.swift index 8ad7e4f..8bdf660 100644 --- a/graphcode/Tests/RemoteSessionExportTests.swift +++ b/graphcode/Tests/RemoteSessionExportTests.swift @@ -138,6 +138,10 @@ struct RemoteSessionExportTests { // ssh and tar would outlive the shell still joined by their pipe. #expect(SessionTransplant.remoteExportDeadlineSeconds == 600) #expect(pipeline.hasPrefix("set -m; ")) + // A background group reading the controlling tty is stopped with SIGTTIN, and ssh + // reads its inherited stdin: from a Terminal the dial would sit stopped until the + // deadline. The job never sends anything to the host, so its stdin is /dev/null. + #expect(pipeline.contains("; } Date: Wed, 9 Sep 2026 11:05:13 -0700 Subject: [PATCH 10/11] Assert the codespace pipeline's untar inside the bounded job Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb --- graphcode/Tests/RemoteSessionExportTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphcode/Tests/RemoteSessionExportTests.swift b/graphcode/Tests/RemoteSessionExportTests.swift index 8bdf660..5a92ab5 100644 --- a/graphcode/Tests/RemoteSessionExportTests.swift +++ b/graphcode/Tests/RemoteSessionExportTests.swift @@ -155,7 +155,7 @@ struct RemoteSessionExportTests { remoteScript: "exec tar -cf - x", staging: URL(fileURLWithPath: "/tmp/s"), at: codespace) #expect(pipeline.contains("'codespace' 'ssh' '-c' 'fluffy-space' '--'")) - #expect(pipeline.hasSuffix(" | tar -xf - -C '/tmp/s'")) + #expect(pipeline.contains("} | tar -xf - -C '/tmp/s'; }")) } // MARK: - Fetched files become the local export's artifact From 38db5ac64747c731fbc4d41dbbbdf8d7709c59f3 Mon Sep 17 00:00:00 2001 From: scgopi Date: Wed, 9 Sep 2026 13:19:40 -0700 Subject: [PATCH 11/11] Trap signals in the bounded fetch and cap remote fetches at four A signal to the shell alone left ssh, tar, the subshells and the ten-minute sleep running to the deadline in their own process groups; the trap kills both groups and exits 143. Fetches now run four at a time so a large Codespace graph is not thirty tunnels racing to start a stopped codespace. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb --- .../Sources/ProjectPersistence+Export.swift | 21 +++++++++++++++---- .../Sources/Sessions/SessionTransplant.swift | 8 +++++++ .../Tests/RemoteSessionExportTests.swift | 16 +++++++++++++- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/GraphcodeKit/Sources/ProjectPersistence+Export.swift b/GraphcodeKit/Sources/ProjectPersistence+Export.swift index afe80bc..8b9ba78 100644 --- a/GraphcodeKit/Sources/ProjectPersistence+Export.swift +++ b/GraphcodeKit/Sources/ProjectPersistence+Export.swift @@ -96,10 +96,17 @@ extension ProjectPersistence { return artifacts } + /// How many remote fetches run at once. Each is its own dial — multiplexed over one + /// connection for a plain host, a fresh `gh` tunnel per loop for a Codespace — plus + /// a `tar` and a watchdog; a thirty-loop Codespace graph fetched all at once would be + /// thirty tunnels racing to start a stopped codespace. Four keeps the export quick on + /// a live host without turning it into that. + static let remoteSessionFetchConcurrency = 4 + /// `sessionArtifacts` for a project on any host. A remote project's loops are fetched - /// concurrently — each is its own dial, multiplexed over one connection for a plain - /// host and a fresh tunnel per loop for a Codespace — and a loop whose fetch comes - /// back empty is simply exported without a session: the export never fails on one. + /// `remoteSessionFetchConcurrency` at a time — the next dial starts as one finishes — + /// and a loop whose fetch comes back empty is simply exported without a session: the + /// export never fails on one. static func remoteAwareSessionArtifacts( for nodes: some Sequence, projectPath: String ) async -> [String: SessionTransplant.Artifact] { @@ -107,7 +114,14 @@ extension ProjectPersistence { return sessionArtifacts(for: nodes, projectPath: projectPath) } return await withTaskGroup(of: (String, SessionTransplant.Artifact?).self) { group in + var artifacts: [String: SessionTransplant.Artifact] = [:] + var inFlight = 0 for node in nodes { + if inFlight == remoteSessionFetchConcurrency, let (nodeID, artifact) = await group.next() { + inFlight -= 1 + if let artifact { artifacts[nodeID] = artifact } + } + inFlight += 1 group.addTask { ( node.id.uuidString, @@ -115,7 +129,6 @@ extension ProjectPersistence { ) } } - var artifacts: [String: SessionTransplant.Artifact] = [:] for await (nodeID, artifact) in group { if let artifact { artifacts[nodeID] = artifact } } diff --git a/GraphcodeKit/Sources/Sessions/SessionTransplant.swift b/GraphcodeKit/Sources/Sessions/SessionTransplant.swift index a69a511..63cc2d9 100644 --- a/GraphcodeKit/Sources/Sessions/SessionTransplant.swift +++ b/GraphcodeKit/Sources/Sessions/SessionTransplant.swift @@ -181,9 +181,17 @@ public enum SessionTransplant { /// without the redirect the dial stopped silently, the watchdog fired at the deadline, /// and the loop exported with no session (found in review; every measurement here had /// run without a tty). Nothing is ever sent to the host on this path. + /// + /// The trap is the other half of owning those groups: a signal to the shell alone — + /// the app terminating the export, Ctrl-C in a Terminal, which reaches only the CLI's + /// own group — would otherwise leave `ssh`, `tar`, the subshells and the ten-minute + /// `sleep` running to the deadline (measured on the rig: all alive after the shell + /// was TERMed). Both groups are killed and the shell exits 143, the status a TERM + /// would have given it. static func bounded(_ pipeline: String, seconds: Int) -> String { "set -m; { \(pipeline); } /dev/null & gc_w=$!; " + + "trap 'kill -TERM -- -$gc_p -$gc_w 2>/dev/null; exit 143' INT TERM HUP; " + "wait $gc_p; gc_s=$?; kill -TERM -- -$gc_w 2>/dev/null; exit $gc_s" } diff --git a/graphcode/Tests/RemoteSessionExportTests.swift b/graphcode/Tests/RemoteSessionExportTests.swift index 5a92ab5..6bde17f 100644 --- a/graphcode/Tests/RemoteSessionExportTests.swift +++ b/graphcode/Tests/RemoteSessionExportTests.swift @@ -129,7 +129,7 @@ struct RemoteSessionExportTests { } @Test - func pipelineIsBoundedAndKillsItsOwnProcessGroupOnTheDeadline() { + func pipelineIsBoundedAndKillsItsOwnProcessGroupOnTheDeadline() throws { let pipeline = SessionTransplant.remoteExportPipeline( remoteScript: "x", staging: URL(fileURLWithPath: "/tmp/s"), at: location) @@ -145,6 +145,20 @@ struct RemoteSessionExportTests { #expect(pipeline.contains("sleep 600; kill -TERM -- -$gc_p")) #expect(pipeline.contains("wait $gc_p; gc_s=$?; kill -TERM -- -$gc_w")) #expect(pipeline.hasSuffix("exit $gc_s")) + // A signal to the shell alone — the app cancelling, Ctrl-C reaching only the CLI's + // group — must take both groups with it, or ssh, tar and the sleep run to the + // deadline as orphans. The trap is armed before the wait it interrupts. + let trap = try #require( + pipeline.range(of: "trap 'kill -TERM -- -$gc_p -$gc_w 2>/dev/null; exit 143' INT TERM HUP; ")) + let wait = try #require(pipeline.range(of: "wait $gc_p")) + #expect(trap.upperBound <= wait.lowerBound) + } + + @Test + func remoteFetchesAreCappedAtFourInFlight() { + // A thirty-loop Codespace graph must not become thirty gh tunnels racing to start + // a stopped codespace, each with its own tar and watchdog. + #expect(ProjectPersistence.remoteSessionFetchConcurrency == 4) } @Test