Skip to content

Carry sessions when exporting a remote project (#333) - #334

Merged
scgopi merged 11 commits into
mainfrom
fix/333-remote-export-sessions
Sep 9, 2026
Merged

Carry sessions when exporting a remote project (#333)#334
scgopi merged 11 commits into
mainfrom
fix/333-remote-export-sessions

Conversation

@scgopi

@scgopi scgopi commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Fixes #333.

Exporting loops from an ssh:// or codespace:// project produced a bundle with the graph and memory logs but no sessions: SessionTransplant.exportArtifact reads only this Mac's home directory, while a remote loop banks its session id at $HOME/.graphcode/sessions/<nodeID>.id on 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 of exportArtifact. remoteExportScript is a pure function returning the host-side script per backend (unit-testable, like remoteInstallScript): Claude reads the banked id and finds $HOME/.claude/projects/*/<id>.jsonl; Copilot reads the banked id, else walks session-state/*/workspace.yaml for 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 is nil. The script streams the session as tar to stdout, with the archive's first path component carrying the session id.
  • Runner: 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), so restore and restoreRemote are untouched. Not wrapped in the login shell: stdout is the archive.
  • ProjectPersistence.createExportBundle / createFullGraphExportBundle gain async overloads 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's ProjectFeature.exportBundle and the CLI's export-node / export-graph use them; the CLI prints Sessions: N after Memory logs.
  • A loop whose fetch fails is exported without a session. A fetch failure never fails the export.
  • Tests in graphcode/Tests/RemoteSessionExportTests.swift (16): script shape per backend, Codex worktree-vs-project directory, OpenCode nil, the exit-cleanly-with-nothing shape, a parity test that the id file the fetch reads is exactly PresenceHooks.remoteSessionIDExpression, the pipeline for ssh and for gh 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 ssh passes 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 codespace scope). Live codespace testing happens on another machine from a beta build. The three host-side scripts were exercised end-to-end against a fake $HOME through the same local untar (banked Claude id → transcript; unbanked Copilot → workspace.yaml walk; stale banked Copilot id → nothing; Codex newest-by-cwd; unbanked → empty stream, exit 0).

Docs

docs/09-remote-repositories.md is 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 test exit 0 — 1718 tests / 182 suites / 0 failures; graphcode-cli build exit 0; graphcoded build exit 0; swiftlint 0 errors; swift-format --strict clean; swift build exit 0; scripts/cli-smoke.sh against a throwaway daemon exit 0 (5/5); the built CLI's node export and graph export exit 0 printing Sessions: 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/sh is 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/null as SIGTTIN hardening; remote fetches capped at four in flight (remoteSessionFetchConcurrency); and the RemoteProjectLocation ControlPath comment corrected (a missing socket directory is exit 255, not a warning). The verifier loop measured the built CLI against the loopback rig: graph export Sessions: 2, node export Sessions: 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

scgopi and others added 11 commits September 9, 2026 08:11
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
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
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
@scgopi
scgopi merged commit 4715a30 into main Sep 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

When exported from remote repositories, only graph structure is exported and not the sessions

1 participant