Skip to content

relay#589: adopt a terminal Claude on an SSH host - #597

Merged
barelyworkingcode merged 15 commits into
epic/588-host-adoptfrom
feat/589-host-adopt
Oct 23, 2026
Merged

barelyworkingcode merged 15 commits into
epic/588-host-adoptfrom
feat/589-host-adopt

Conversation

@barelyworkingcode

Copy link
Copy Markdown
Owner

Closes #589

Adopt a terminal Claude on an SSH host. The door reads the transcript tail, writes the intents and a dormant record, then starts one detached transition script on the host (internal/sshhost/adopt.go) that checks the pid's start time, runs the idle test and the signal ladder, and starts the relay-agent-<id> session. A dropped link answers 502 host_link_lost and lists the session as dormant; Resume attaches the started agent or starts one.

Non-obvious decisions:

  • Approved behaviour changes (relay#588 risk 7): the host row case in e2e/features/g1_outside_adopt_test.go moves to detach_sessions: false; fakerelay/prooftest/adopt_test.go drops the "adoptable on a host row" refusal case and takes relay's generic host_unsupported message. TM3f.1 claim edit (threat-claim change).
  • relay-sessions saves a failed host adopt (adopt_link_lost, adopt_launch_failed) so it lists dormant; a resume that answers changed deletes that saved copy, so the outside row is adoptable again.
  • adopt.lock keeps one transition script per session. A second command answers failed in_progress (502, record kept).
  • The outer script's setup failures answer failed setup (relay: 502 unavailable, record removed, nothing saved). The new internal code adopt_setup_failed never reaches eve.
  • session-adopt-host sends model: "haiku": on a stack the transcript names no Claude family (relay#588 risk 8). TestAdoptHostSuccess covers the no-model rule.
  • The script copies the probe's lifetime computation (no shared function exists, and probe.go is not in ownership); the reviewer checked byte equivalence.

Fix attempts: 2 (round 0: dormant session not listed after host_link_lost; review round 1: three blocking findings, fixed). Review rounds: 2, default model.

Minor review findings, not fixed (PR notes):

  • deleteHostSession ignores a non-2xx answer (session_adopt_host.go:387); a failed DELETE would leave the saved copy.
  • On a failed host adopt, interrupted records the requested interrupt, not a sent SIGINT.
  • An existing but unwritable session folder answers failed in_progress, not failed setup.
  • docs/ssh-hosts.md omits adopt_setup_failed from the relay-sessions refusal list.
  • No e2e case for "resume answers 409 changed and removes the record" (the contract names four cases; all exist). Relay's 15 s /launch bound: only a Claude that ignores SIGTERM passes it; that ends as host_link_lost with Resume.

Tests: TestAdoptHostSuccess, TestAdoptHostBusy, TestAdoptHostChanged, TestAdoptHostLinkDrop, TestAdoptReasonsOnRows, prooftest TestAdoptHost. Full e2e suite: ok relaye2e/features 875.291s at ae9d6e6; host tests ok 39.251s at HEAD (round-2 reviewer).

Review: no blocking findings (round 2, default model)
Break-and-restore: forced the script's lifetime checks to always match (adopt.go lines 130, 159); TestAdoptHostChanged went red ("answered 201, want 409 changed"); restored byte for byte (git diff clean)
Harness weakening: none (two journeys added, nothing removed or loosened)

🤖 Generated with Claude Code

barelyworkingcode and others added 15 commits October 22, 2026 15:51
The host adopt runs as one detached script on the host (check, ladder,
start); relay writes the audit intents and a dormant record before the
SSH command and reads only its result. A dropped link answers
host_link_lost and keeps the record; Resume attaches the started agent or
re-runs the script with no_signal. fakerelay serves an adoptable host row
and the host_link_lost refusal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
TestAdoptHostSuccess, Busy, Changed and LinkDrop; the host_unsupported case
moves to detach_sessions: false (approved behaviour change); SSHLieStart.
TestAdoptHostLinkDrop is red: the dormant record is not listed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A host adopt that ends in adopt_link_lost or adopt_launch_failed saves its
session so it lists as dormant; the resume no longer takes that session's
own hold on the conversation for another's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…part

A per-session adopt.lock keeps two scripts from running at once. A setup
failure of the SSH command is its own outcome (nothing touched). A resume
answered changed also deletes relay-sessions' saved copy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… host

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…oves the record

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…atches the helper by its executable

A real Claude writes request blobs and attachments of hundreds of KiB into
a turn, so the first turn's prompt lies before the 256 KiB tail. The door
then sent child_test "all", and the script read every MCP server, zombie
and turn helper as busy. The door now reads the transcript backwards below
the tail, 1 MiB per read and 8 MiB at most, until it holds the prompt.

ps comm reads "caffeinate" for the turn helper Claude starts by name, so
the script now matches the executable path (/proc/<pid>/exe, else lsof's
first txt entry), as the local adopt does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
TestAdoptHostNoClaude; SSHNoClaude drops this machine's claude from the stub
host's PATH so the probe finds none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The seeded claude-code template stays; only the probe result makes the row
host_unsupported.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@barelyworkingcode

Copy link
Copy Markdown
Owner Author

Evidence after the stack runs and review round 3 (head 0c3ffd8).

Stack runs found three real defects, fixed on this branch: a host whose probe found no claude listed as adoptable (now host_unsupported; a pre-script refusal answers 502 unavailable and removes the record); every real idle Claude refused as busy (the door now reads back past the 256 KiB tail for the turn's prompt; the script matches the caffeinate helper by its executable). Contract amended by the planner (interfaces 1, 4, 5, 6; criteria 1–2). Journeys wait for the host row to read idle and seed a host probe record.

Fix attempts: 5 (dormant listing; review round 1; stack: probe gate; stack: idle test, escalated to the default model; test for the probe gate). Review rounds: 3 plus a confirm. The confirm finding (the new test guarded the template, not the probe) is fixed: removing the probe condition in hostAdopts turns TestAdoptHostNoClaude red at "host row = adoptable:true"; restored.

Full e2e suite at 70ba45c: FAIL relaye2e/features 873.147s, only TestFeatureMapCoverage (G1.55 did not name TestAdoptHostNoClaude); fixed in 0c3ffd8, TestFeatureMapCoverage ok.

Review: no blocking findings (round 3 plus confirm; default model for rounds 1–3)
Journey: session-adopt-host PASS, session-adopt-host-drop PASS (stack r589 from 4cd5240, SUMMARY pass=12 fail=0 blocked=0 notrun=2)
Main harness vs head: main 6954403 harness on a stack from head b12322c: SUMMARY pass=10 fail=0 blocked=0 notrun=2
Break-and-restore: script lifetime checks forced to match: TestAdoptHostChanged red (201, want 409 changed), restored byte for byte; hostAdopts probe condition removed: TestAdoptHostNoClaude red, restored
Harness weakening: none (two journeys added; nothing removed, skipped or loosened)

@barelyworkingcode

Copy link
Copy Markdown
Owner Author

Weakening reviewed: stateNotRun in journey_adopt_host.go is the new journeys' NOTRUN off a stack, which the contract requires (api phase, stack only); no existing journey or test is skipped or loosened

@barelyworkingcode
barelyworkingcode merged commit 1e96e38 into epic/588-host-adopt Oct 23, 2026
@barelyworkingcode

Copy link
Copy Markdown
Owner Author

merge-check: merged. approved, target, contract, evidence, owner
Report: 2. The gates themselves: skip marker added result{id, stateNotRun (cmd/devboxverify/journey_adopt_host.go) [name-based]; 2. The gates themselves: criterion removed The probe marks a live Claude row on a Unix host adoptable: true when its registry tests pass, the host has detach_sessions absent or true, and the host project has the claude-code template. (issue #589); 2. The gates themselves: criterion removed The probe marks a Claude row on a host with detach_sessions: false or with no claude-code template, and a Pi or Codex row on a host, host_unsupported. (issue #589); 2. The gates themselves: criterion removed The script sends no signal and answers 409 changed when the pid's start time differs from the row's. (issue #589); 2. The gates themselves: criterion removed The script sends no signal and answers 409 busy when the registry status is not idle, the transcript grew since relay's pre-read, or a child started in the last turn's window, unless the request sets interrupt. (issue #589); 2. The gates themselves: criterion removed After a takeover that sent SIGINT, relay sends the recovery message, as for a local adopt. (issue #589); 2. The gates themselves: criterion removed Relay writes the session_adopt intent, the session_interrupt intent (when interrupt is set) and a dormant ledger record with adopt_pending before the SSH command starts. (issue #589); 2. The gates themselves: criterion removed When the SSH command ends with no result, relay answers 502 host_link_lost and keeps the pending record. (issue #589); 2. The gates themselves: criterion removed After the host answers again, the next probe of that host settles each pending adopt: a running agent becomes a live managed session; a script that refused, or never started, leaves the outside session untouched and removes the record; a script that signalled and could not launch leaves a dormant session with Resume. (issue #589); 2. The gates themselves: criterion removed A resume of a pending record settles it first, and answers 409 adopt_in_progress while the script runs. (issue #589); 2. The gates themselves: criterion removed A resume of a dormant adopted host session runs --resume <conversation id> on the host, unless its agent still runs, which it attaches. (issue #589); 2. The gates themselves: criterion removed A resume never starts a Claude while the outside process still runs with its pinned start time. (issue #589); 2. The gates themselves: criterion removed A host adopt with no model never answers model_unknown. The launch uses the tail's family alias when it names one, else no --model. (issue #589); 2. The gates themselves: criterion removed A Chief of Staff send to the session answers 202 before a drop-in and after the hand back. (issue #589); 2. The gates themselves: criterion removed Drop in on the session opens Claude on the same conversation on the host. A send while it holds the session answers 409 dropped_in. (issue #589); 2. The gates themselves: criterion removed No outside row lists the conversation after the 201. (issue #589); 2. The gates themselves: criterion removed The adopt door still refuses the Chief of Staff scope with 403. (issue #589); 2. The gates themselves: criterion removed fakerelay serves the host row rule, the host adopt answer and the host_link_lost refusal as relay does. (issue #589); 2. The gates themselves: criterion removed The script checks the pid's start time before each signal, and on a mismatch it sends nothing and answers 409 changed. (issue #588); 2. The gates themselves: criterion removed When the SSH connection drops at any point of an adopt, the next host probe after the host answers ends it in one of three states: a managed session that runs, a dormant session with Resume and its transcript intact, or the outside session untouched and adoptable again. (issue #588); 2. The gates themselves: and 2 more removed or weakened items

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.

1 participant