From 9b7ddb46fa804fc1f3b5c83344da63dc6c8a9381 Mon Sep 17 00:00:00 2001 From: Robert Queenin <2177841+ecalifornica@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:11:43 -0400 Subject: [PATCH] feat(scripts): resume-remote.sh never overwrites a remote session This commit cuts the decisions the script makes per run. Before it, three remote states (fresh, existing, live) and --force give 5 outcomes, one of them an overwrite of the remote session. After it, two remote facts (is the tmux session live, does the session file exist) give 3 outcomes, and none of them overwrites the remote. - The remote session ID is derived from the toolpath document, and the tmux session name is derived from that ID. One ID names the session file, the tmux session, and the claude resume. - One read-only ssh call reports whether the tmux session is live and whether the session file exists. Live: attach. File present: launch, attach. File absent: ship, launch, attach. - --force is removed. To reset a remote session, delete its file on the remote and re-run. - The rsync of the working tree runs only on a run that ships. Before this commit it runs on every run. - The remote checks (two read-only ssh calls) run before any remote write. Before this commit, --setup seeding and the rsync run between them. - A missing remote project directory fails the run unless --setup or a sync on a run that ships creates it. - The ship writes to a temporary name, checks the byte count, and renames into place, so a present session file is a complete file. --- scripts/resume-remote.sh | 199 +++++++++++++++++---------------------- 1 file changed, 89 insertions(+), 110 deletions(-) diff --git a/scripts/resume-remote.sh b/scripts/resume-remote.sh index 62d69344..9617c7e7 100755 --- a/scripts/resume-remote.sh +++ b/scripts/resume-remote.sh @@ -34,22 +34,8 @@ # project directory. Idempotent. # --no-sync Do not push the working tree to the remote. # --no-pause Do not wait for Enter between steps. -# --force Re-ship over an existing remote session file. The -# remote state decides what a run does: -# fresh no session file, no tmux session: -# ship, launch, attach. --force has no -# effect. -# existing session file present, no tmux -# session: launch on the remote file, -# attach. --force ships the local -# projection first and overwrites the -# remote turns. -# live tmux session running: attach. -# --force is an error; stop the -# session first (the error prints the -# command). -# --dry-run Print the create, setup, and sync commands instead -# of running them, and stop after the plan. Every +# --dry-run Print the plan, including the setup, sync, ship, +# launch, and attach commands, and stop. Every # remote call is read-only. With --create, the run # stops after printing the create command unless the # VM already exists. @@ -68,15 +54,16 @@ # the script quotes nothing and escapes nothing. # - --session is a UUID. At least one Claude session exists for # --project. -# Remote (two read-only ssh calls): +# Remote (two read-only ssh calls, both before any remote write): # - Each reply is exactly the TP_* lines the probe prints. A login # banner or a registration notice fails the run verbatim. -# - Call 1, before any remote write: $HOME is absolute, claude is on -# PATH or in a probed location, tmux is on PATH. -# - Call 2, after setup and sync: exists and is physical -# (pwd -P returns it); the tmux session state and whether the -# target session file exists are known. A live tmux session with -# --force is an error. +# - Call 1: $HOME is absolute, claude is on PATH or in a probed +# location, tmux is on PATH. +# - Call 2: is physical (pwd -P returns it) when it +# exists; when it is missing, --setup or a sync on a run that ships +# creates it, else the run fails. Whether the tmux session for the +# shipped ID is live and whether the target session file exists are +# known. # # Steps (always in this order): # 1. cargo build -p path-cli; the script runs target/debug/path and does @@ -94,19 +81,24 @@ # remote session ID. # [shell] Compute the remote Claude project slug (/, _, and . # become -). -# 6. Optional remote seeding (--setup). rsync the working tree -# (tracked, untracked, and uncommitted files, plus .git; minus -# target/ and anything .gitignore lists) into the remote project -# dir. --delete makes the remote mirror the local tree. The remote -# has no Rust toolchain. -# 7. [shell] Call 2: the physical project dir, whether the tmux -# session is live, and whether the target session file exists. -# These decide the state: fresh, existing, or live (see --force). -# 8. Print the plan, including the state. --dry-run stops here. -# 9. [shell] fresh: ship the JSONL over ssh stdin (0600 via umask -# 077), launch `claude -r ` in a detached tmux session, attach. -# existing: launch on the remote file, attach. live: attach. -# Detach with ctrl-b d. +# 6. [shell] Call 2: the physical project dir, whether the tmux +# session for the shipped ID is live, and whether the target +# session file exists. The remote wins once it exists: a live +# tmux session is attached to as is, a present session file is +# launched as is, and only an absent file is shipped. To reset a +# remote session, delete its file on the remote and re-run. +# 7. Print the plan. --dry-run stops here. +# 8. Optional remote seeding (--setup). When the run ships, rsync +# the working tree (tracked, untracked, and uncommitted files, +# plus .git; minus target/ and anything .gitignore lists) into the +# remote project dir. --delete makes the remote mirror the local +# tree. The remote has no Rust toolchain. +# 9. [shell] Ship the JSONL over ssh stdin (0600 via umask 077) when +# the file is absent: the remote writes .tmp, checks its +# byte count against the local file, and renames it to , +# so a present file is a complete file. Launch `claude -r ` in +# a detached tmux session unless it is live, attach. Detach with +# ctrl-b d. # 10. Print the reattach command. set -euo pipefail @@ -135,7 +127,6 @@ SETUP=0 SYNC=1 PAUSE=1 DRY_RUN=0 -FORCE=0 while [[ $# -gt 0 ]]; do case "$1" in @@ -147,7 +138,6 @@ while [[ $# -gt 0 ]]; do --no-sync) SYNC=0; shift ;; --no-pause) PAUSE=0; shift ;; --dry-run) DRY_RUN=1; shift ;; - --force) FORCE=1; shift ;; -h|--help) usage 0 ;; *) echo "unknown option: $1" >&2; usage ;; esac @@ -260,7 +250,6 @@ WORK_DIR="${TMPDIR:-/tmp}/path-resume-remote" DOC="$WORK_DIR/$SESSION.json" run "$PATH_BIN" p import claude --project "$PROJECT" --session "$SESSION" --no-cache >"$DOC" echo "doc: $DOC ($(wc -c <"$DOC") bytes)" -TMUX_NAME="path-${SESSION:0:8}" # ── 3. Create the VM (optional) ─────────────────────────────────────────── @@ -350,52 +339,19 @@ REMOTE_ID="$(jq -r '.sessionId // empty' "$JSONL" | sort -u)" [[ $REMOTE_ID =~ $UUID_RE ]] || die "the projected JSONL does not carry one sessionId (got '$REMOTE_ID')" [[ $REMOTE_ID != "$SESSION" ]] || die "derived id equals the source session id" echo "remote session id: $REMOTE_ID" +TMUX_NAME="path-${REMOTE_ID:0:8}" # [shell] Claude project slug: /, _, and . become -. SLUG="$(printf '%s' "$REMOTE_DIR" | tr '/_.' '---')" SLUG_DIR="$REMOTE_HOME/.claude/projects/$SLUG" TARGET="$SLUG_DIR/$REMOTE_ID.jsonl" -SHIP_CMD="umask 077; mkdir -p $SLUG_DIR && cat > $TARGET" +JSONL_BYTES="$(wc -c <"$JSONL")" +SHIP_CMD="umask 077; mkdir -p $SLUG_DIR && cat > $TARGET.tmp && [ \$(wc -c < $TARGET.tmp) -eq $JSONL_BYTES ] && mv $TARGET.tmp $TARGET || { rm -f $TARGET.tmp; exit 1; }" LAUNCH_CMD="tmux new-session -d -s $TMUX_NAME -c $REMOTE_DIR 'env LANG=C.UTF-8 $REMOTE_CLAUDE -r $REMOTE_ID'" ATTACH_CMD="tmux attach-session -d -t =$TMUX_NAME" -# ── 6. Seed (optional) and sync ─────────────────────────────────────────── - -if [[ $SETUP -eq 1 ]]; then - step "Seed $REMOTE" - if [[ $DRY_RUN -eq 1 ]]; then - skip "ssh -n $REMOTE mkdir -p ~/.claude $REMOTE_DIR" - skip "scp -pq $CREDS $REMOTE:.claude/" - skip "jq '...' ~/.claude.json | ssh $REMOTE 'umask 077; cat > ~/.claude.json'" - else - run ssh -n "$REMOTE" "mkdir -p ~/.claude $REMOTE_DIR" - run scp -pq "$CREDS" "$REMOTE:.claude/" - show "jq '...' ~/.claude.json | ssh $REMOTE 'umask 077; cat > ~/.claude.json'" - jq --arg dir "$REMOTE_DIR" '{ - hasCompletedOnboarding: true, - theme: (.theme // "dark"), - oauthAccount, - projects: { ($dir): { hasTrustDialogAccepted: true } } - }' "$HOME/.claude.json" | ssh "$REMOTE" 'umask 077; cat > ~/.claude.json' - echo "seeded ~/.claude/.credentials.json and ~/.claude.json" - fi -fi - -if [[ $SYNC -eq 1 ]]; then - step "Sync $PROJECT to $REMOTE:$REMOTE_DIR" - if [[ $DRY_RUN -eq 1 ]]; then - skip "ssh -n $REMOTE mkdir -p $REMOTE_DIR" - skip "rsync -az --delete --exclude=target/ --filter=':- .gitignore' $PROJECT/ $REMOTE:$REMOTE_DIR/" - else - run ssh -n "$REMOTE" "mkdir -p $REMOTE_DIR" - run rsync -az --delete --stats \ - --exclude=target/ --filter=':- .gitignore' \ - "$PROJECT/" "$REMOTE:$REMOTE_DIR/" | grep -E '^(Number of (regular )?files|Total transferred)' - fi -fi - -# ── 7. [shell] Call 2: project dir, tmux state ──────────────────────────── +# ── 6. [shell] Call 2: project dir, tmux state ──────────────────────────── # preflight_script: read-only. Prints one `TP_=` line per fact. preflight_script() { @@ -413,49 +369,46 @@ EOF step "Preflight $REMOTE (read-only)" show "ssh -n $REMOTE " remote_facts "$(preflight_script)" TP_PWD TP_SESSION TP_TARGET +TMUX_STATE="${PF_VALS[1]}" +[[ $TMUX_STATE == live || $TMUX_STATE == none ]] || die "bad tmux state '$TMUX_STATE'" +TARGET_EXISTS="${PF_VALS[2]}" +[[ $TARGET_EXISTS == yes || $TARGET_EXISTS == no ]] || die "bad target state '$TARGET_EXISTS'" + +# The remote wins once it exists: nothing here overwrites a remote +# session file or touches the tree of a live session. +if [[ $TMUX_STATE == live ]]; then + SHIP=0 + LAUNCH=0 + RUN_NOTE="attach to the live session. The remote tree and turns are kept." +elif [[ $TARGET_EXISTS == yes ]]; then + SHIP=0 + LAUNCH=1 + RUN_NOTE="launch on the remote file, attach. The remote tree and turns are kept. To reset the remote session: ssh $REMOTE rm $TARGET, then re-run." +else + SHIP=1 + LAUNCH=1 + RUN_NOTE="ship, launch, attach." +fi + DIR_NOTE="" case "${PF_VALS[0]}" in "$REMOTE_DIR") ;; "") - # Without --dry-run, setup and sync created the dir before this - # call, so a missing dir here means both were off. - if [[ $DRY_RUN -eq 1 && $SETUP -eq 1 ]]; then + if [[ $SETUP -eq 1 ]]; then DIR_NOTE="missing; --setup creates it" - elif [[ $DRY_RUN -eq 1 && $SYNC -eq 1 ]]; then + elif [[ $SYNC -eq 1 && $SHIP -eq 1 ]]; then DIR_NOTE="missing; sync creates it" - else + elif [[ $SHIP -eq 1 ]]; then die "project directory $REMOTE_DIR does not exist on $REMOTE; pass --setup, drop --no-sync, or pass -C" + else + die "project directory $REMOTE_DIR does not exist on $REMOTE and this run does not ship, so the sync does not create it; pass --setup or -C" fi ;; *) die "project directory $REMOTE_DIR is not physical on $REMOTE (it resolves to ${PF_VALS[0]}); pass -C ${PF_VALS[0]}" ;; esac -TMUX_STATE="${PF_VALS[1]}" -[[ $TMUX_STATE == live || $TMUX_STATE == none ]] || die "bad tmux state '$TMUX_STATE'" -TARGET_EXISTS="${PF_VALS[2]}" -[[ $TARGET_EXISTS == yes || $TARGET_EXISTS == no ]] || die "bad target state '$TARGET_EXISTS'" echo "ok: dir=$REMOTE_DIR${DIR_NOTE:+ ($DIR_NOTE)} session=$TMUX_STATE target=$TARGET_EXISTS" -KILL_CMD="ssh $REMOTE tmux kill-session -t =$TMUX_NAME" -if [[ $TMUX_STATE == live ]]; then - [[ $FORCE -eq 0 ]] || die "tmux session $TMUX_NAME is live on $REMOTE; --force cannot re-ship under a running claude. Stop it first: $KILL_CMD" - STATE=live - SHIP=0 - STATE_NOTE="attach. The live session keeps the content of its original push. To push the current content: $KILL_CMD, then re-run." -elif [[ $TARGET_EXISTS == yes && $FORCE -eq 0 ]]; then - STATE=existing - SHIP=0 - STATE_NOTE="launch on the remote file, attach. The remote turns are kept. --force re-ships and overwrites them." -elif [[ $TARGET_EXISTS == yes ]]; then - STATE=existing - SHIP=1 - STATE_NOTE="--force: ship, launch, attach. The remote turns under $REMOTE_ID are overwritten." -else - STATE=fresh - SHIP=1 - STATE_NOTE="ship, launch, attach." -fi - -# ── 8. Plan ─────────────────────────────────────────────────────────────── +# ── 7. Plan ─────────────────────────────────────────────────────────────── step "Plan" cat < ~/.claude.json'" +[[ $SYNC -eq 0 || $SHIP -eq 0 ]] || echo " sync: rsync -az --delete --exclude=target/ --filter=':- .gitignore' $PROJECT/ $REMOTE:$REMOTE_DIR/" [[ $SHIP -eq 0 ]] || echo " ship: ssh $REMOTE \"$SHIP_CMD\" < $JSONL" -[[ $STATE == live ]] || echo " launch: ssh $REMOTE \"$LAUNCH_CMD\"" +[[ $LAUNCH -eq 0 ]] || echo " launch: ssh $REMOTE \"$LAUNCH_CMD\"" echo " attach: ssh -t $REMOTE \"$ATTACH_CMD\"" if [[ $DRY_RUN -eq 1 ]]; then echo "Dry run: nothing was written or launched." @@ -478,6 +433,30 @@ if [[ $DRY_RUN -eq 1 ]]; then fi pause +# ── 8. Seed (optional) and sync ─────────────────────────────────────────── + +if [[ $SETUP -eq 1 ]]; then + step "Seed $REMOTE" + run ssh -n "$REMOTE" "mkdir -p ~/.claude $REMOTE_DIR" + run scp -pq "$CREDS" "$REMOTE:.claude/" + show "jq '...' ~/.claude.json | ssh $REMOTE 'umask 077; cat > ~/.claude.json'" + jq --arg dir "$REMOTE_DIR" '{ + hasCompletedOnboarding: true, + theme: (.theme // "dark"), + oauthAccount, + projects: { ($dir): { hasTrustDialogAccepted: true } } + }' "$HOME/.claude.json" | ssh "$REMOTE" 'umask 077; cat > ~/.claude.json' + echo "seeded ~/.claude/.credentials.json and ~/.claude.json" +fi + +if [[ $SYNC -eq 1 && $SHIP -eq 1 ]]; then + step "Sync $PROJECT to $REMOTE:$REMOTE_DIR" + run ssh -n "$REMOTE" "mkdir -p $REMOTE_DIR" + run rsync -az --delete --stats \ + --exclude=target/ --filter=':- .gitignore' \ + "$PROJECT/" "$REMOTE:$REMOTE_DIR/" | grep -E '^(Number of (regular )?files|Total transferred)' +fi + # ── 9. [shell] Ship, launch, attach ─────────────────────────────────────── if [[ $SHIP -eq 1 ]]; then @@ -485,7 +464,7 @@ if [[ $SHIP -eq 1 ]]; then show "ssh $REMOTE \"$SHIP_CMD\" < $JSONL" ssh -o BatchMode=yes "$REMOTE" "$SHIP_CMD" <"$JSONL" fi -if [[ $STATE != live ]]; then +if [[ $LAUNCH -eq 1 ]]; then step "Launch $TMUX_NAME in $REMOTE_DIR" show "ssh $REMOTE \"$LAUNCH_CMD\"" ssh -n -o BatchMode=yes "$REMOTE" "$LAUNCH_CMD"