Carry sessions when exporting a remote project (#333) - #334
Merged
Conversation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #333.
Exporting loops from an
ssh://orcodespace://project produced a bundle with the graph and memory logs but no sessions:SessionTransplant.exportArtifactreads only this Mac's home directory, while a remote loop banks its session id at$HOME/.graphcode/sessions/<nodeID>.idon the host, with its transcript there too. Import already had the remote twin (restoreRemote, e3e80a5); this is the mirror.What changes
SessionTransplant.exportRemoteArtifact(forNode:at:)— the remote twin ofexportArtifact.remoteExportScriptis a pure function returning the host-side script per backend (unit-testable, likeremoteInstallScript): Claude reads the banked id and finds$HOME/.claude/projects/*/<id>.jsonl; Copilot reads the banked id, else walkssession-state/*/workspace.yamlfor the zmx session name the way the ensure's bank fragment does; Codex takes the newest rollout whose header opened in the loop's working directory (worktree if bound, else the project folder on the host); OpenCode isnil. The script streams the session astarto stdout, with the archive's first path component carrying the session id.sshCommandLine(remoteCommand:) | tar -xf - -C <staging>— the dial's stdout goes straight into a local untar, never through a PTY or a String.artifact(fromFetched:…)re-keys the result to exactly what the local export produces (transcript.jsonl, Copilot relative paths,rollout.jsonl), sorestoreandrestoreRemoteare untouched. Not wrapped in the login shell: stdout is the archive.ProjectPersistence.createExportBundle/createFullGraphExportBundlegainasyncoverloads that collect sessions from wherever the project lives (concurrently per loop for a remote project; the existing disk read for a local one). The app'sProjectFeature.exportBundleand the CLI'sexport-node/export-graphuse them; the CLI printsSessions: NafterMemory logs.graphcode/Tests/RemoteSessionExportTests.swift(16): script shape per backend, Codex worktree-vs-project directory, OpenCodenil, the exit-cleanly-with-nothing shape, a parity test that the id file the fetch reads is exactlyPresenceHooks.remoteSessionIDExpression, the pipeline for ssh and forgh codespace ssh, artifact re-keying per backend and the refusals, that the async builders match the sync ones for a local project, that an unreachable host yields no session rather than a failure, the deadline, the trap, and the fetch cap.Transport design, from the probe findings
Mailroom #617 (RemoteExportTransportProbe, from gh v2.87.0 source):
gh codespace sshpasses the remote command's stdout through untouched, writes every notice to stderr, flattens remote exit codes to 1, and can take minutes before the first byte while a stopped codespace starts. So the runner: trusts the archive, not ssh's exit status (the pipeline's status is the untar's); sets no deadline of its own (the dial's keepalives already bound a dead link); and treats an empty stream as "nothing to carry" (bsdtar exits 0 on empty stdin and extracts nothing, verified locally).Verification scope: the codespace path is verified by source reading and the loopback ssh rig only — no codespace is reachable from this Mac (gh token lacks the
codespacescope). Live codespace testing happens on another machine from a beta build. The three host-side scripts were exercised end-to-end against a fake$HOMEthrough the same local untar (banked Claude id → transcript; unbanked Copilot →workspace.yamlwalk; stale banked Copilot id → nothing; Codex newest-by-cwd; unbanked → empty stream, exit 0).Docs
docs/09-remote-repositories.mdis gitignored (/docs/*), so the one-line note that remote exports carry sessions was added to the local copy and does not ride in this PR.Gate
Gate on
38db5ac6(= remote head), private DerivedData under the worktree:xcodebuild testexit 0 — 1718 tests / 182 suites / 0 failures;graphcode-clibuild exit 0;graphcodedbuild exit 0; swiftlint 0 errors; swift-format--strictclean;swift buildexit 0;scripts/cli-smoke.shagainst a throwaway daemon exit 0 (5/5); the built CLI'snode exportandgraph exportexit 0 printingSessions: N; Linux pass (https://github.com/scgopi/GraphCode/actions/runs/34400371290/job/102630484589).Since the first push, per review: the dial's exit status is recorded beside the staging directory (bytes plus a non-zero status is a partial session and is not carried; a POSIX group rather than
pipefail, which/bin/shis not guaranteed to know); a ten-minute deadline that kills the pipeline's whole process group (measured: a silent pipeline dies at the deadline with exit 143 and no orphan); a trap on INT/TERM/HUP that kills both the pipeline's and the watchdog's groups (measured: a TERM to the shell leaves no ssh, tar, subshell or sleep behind); the job's stdin is/dev/nullas SIGTTIN hardening; remote fetches capped at four in flight (remoteSessionFetchConcurrency); and theRemoteProjectLocationControlPath comment corrected (a missing socket directory is exit 255, not a warning). The verifier loop measured the built CLI against the loopback rig:graph exportSessions: 2,node exportSessions: 1, transcript and events files byte-identical to the host's, 0.5s under a real pty.🤖 Generated with Claude Code
https://claude.ai/code/session_01JjjrmYRrETeTY5UnsqVmeb