Skip to content

feat(server): hand a thread off to a linked environment - #16751

Draft
juliusmarminge wants to merge 1 commit into
t3code/peer/handoff-gitfrom
t3code/peer/handoff
Draft

juliusmarminge wants to merge 1 commit into
t3code/peer/handoff-gitfrom
t3code/peer/handoff

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Part of cross-environment orchestration. A thread can now move to a linked environment with its conversation (#16731) and its git work (#16734), with exactly one copy live.

How a move goes

  1. Departing. ThreadHandoff marks the thread departing with the internal thread.handoff.update command. The orchestrator refuses message.dispatch for it, and startNextQueuedRun starts nothing on it.
  2. Pack. It packs the branch and working tree with HandoffGit.
  3. Upload. It uploads the bundle through the other side's signed attachment route, using a forwarded t3_attachment_prepare_upload plus a POST.
  4. Import. It calls t3_thread_import there. That tool now takes the bundle, applies it in a new worktree, and only then creates the thread with the history and a continuation prompt. If the import itself fails, the worktree is removed.
  5. Departed. The thread here is departed and read-only, with the other thread's id. A send is refused with OrchestratorThreadMovedError ("This thread moved to Box. Continue it there.").
  6. Failure. Any failure marks the move failed with the reason. That state takes turns again, and nothing is left on the other side.

The agent moving its own thread ("I need to wrap here, move this to my VPS")

  • t3_thread_handoff with no threadId moves the calling thread. The link and the target project are checked right away, so a bad target fails while the agent can still say so.
  • The move waits as pending until the agent's turn ends. Then the thread there starts with the agent's continuationPrompt, so it picks up where it said it would.
  • Any message to the thread after the asking turn started cancels the move, because new instructions win. That includes a message steered into that same turn, and one from an agent the user works through.
  • A startup sweep finishes moves that a restart cut short. Import ids derive from the handoff, so a retried move finds the thread already there.

Surfaces

  • MCP: t3_thread_handoff.
  • WS RPCs: threadHandoff.options (where the thread can go, with reasons when it can't), threadHandoff.start and threadHandoff.cancel. The UI comes in the next PR.
  • Docs: "Continue a thread on another machine" in docs/user/remote-access.md, and the one-live-copy invariant in docs/internals/remote.md.

Verification

  • peer/handoff/ThreadHandoff.test.ts runs two real orchestrators. The box sits behind its real /mcp, OAuth and upload route; the laptop is linked to it. Each has a real clone of the same origin. Three tests:
    • The move. The laptop thread, with an unpushed commit, an uncommitted edit and a new file, moves. options lists the box with its matching project. On the box, the thread has the history, its first provider turn carries the laptop's question plus the continuation, and its worktree is on fix/login with the commit, the edit and the file. On the laptop, the thread is departed with the box's thread id, and a new message is refused with "moved to Box".
    • A diverged branch. When the box has its own commit on the branch, the move fails with the git reason. The laptop thread is failed and takes a new turn. No thread exists on the box.
    • Self-handoff. Turns are held open with a queue. Asked mid-turn, the move is pending. An agent message steered into that same turn cancels it once the turn ends. Asked again with nothing after, a settle while the turn still runs leaves it pending; when the turn ends it departs, and the box's first turn is the agent's continuation prompt.
  • Mutation-checked: each of these fails its test when removed:
    • refusing turns on a departed thread;
    • cancel-by-user;
    • waiting for the turn to end;
    • releasing on failure;
    • sending the git work;
    • the continuation prompt.
  • Also ran all of mcp, peer and cli, plus RpcAuthorization, ThreadImportService, ThreadManagementService, ProjectionStore, Orchestrator, the V1 cutover integration and shared t3McpToolPresentation (50 files, 485 tests), and contracts rpc, orchestrationV2 and orchestratorMcp (45 tests). All passed. Server, contracts, client-runtime, web and mobile typecheck clean, and lint is clean on the changed lines.

Opus 5.5 via Claude Code.

🤖 Generated with Claude Code

@juliusmarminge
juliusmarminge added this pull request to stack #16656 October 7, 2026 06:50
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Oct 7, 2026
@juliusmarminge juliusmarminge added the macroscope-review Opt PRs made by unvouched contributors in for Macroscope review. Vouched contributors auto-reviews label Oct 7, 2026
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire — 4.9 KiB — 6.8 KiB ✅
Codex Thread snapshot wire — 3.8 KiB — 4.9 KiB ✅
Codex Live turn WebSocket wire — 1.2 KiB — 2.0 KiB ✅
Codex Live turn WebSocket decoded — 20.8 KiB — 29.3 KiB ✅
Codex Live turn messages — 1 — 8 ✅
Claude Total thread wire — 5.0 KiB — 6.8 KiB ✅
Claude Thread snapshot wire — 3.8 KiB — 4.9 KiB ✅
Claude Live turn WebSocket wire — 1.2 KiB — 2.0 KiB ✅
Claude Live turn WebSocket decoded — 21.2 KiB — 29.3 KiB ✅
Claude Live turn messages — 1 — 8 ✅

Baseline: unavailable · PR result: b626bf2 · Source CI: failure

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 108.5 KiB
  • Claude decoded thread snapshot: 108.8 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

A thread can now move to a linked environment with its conversation and its
git work, with exactly one copy live. ThreadHandoff marks it departing,
which refuses new turns and keeps queued runs from starting; packs the
branch and working tree with HandoffGit; uploads the bundle through the
other side's signed attachment route; and calls t3_thread_import there,
which now takes the bundle and applies it in a new worktree before creating
the thread. Only then is the thread departed here and read-only. A failed
move marks it failed, which takes turns again, and nothing is left there.

An agent can move its own thread with t3_thread_handoff. The move then
waits as pending until its turn ends, starts the thread there with the
agent's continuationPrompt, and is cancelled by a user message sent before
the turn ends. A startup sweep runs moves that a restart cut short; import
ids derive from the handoff, so a retry finds the thread already there.

threadHandoff.options / start / cancel serve the clients.

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

Copy link
Copy Markdown
Member Author

End-to-end run, two real servers

Two t3 serve processes from the top of this stack, each with its own data directory, on one machine. A laptop (port 3971) and a box (port 3972). Their projects are clones of one bare origin, so they share a repository. An outside agent (OAuth with a pairing code) drives the laptop's /mcp, and real Claude Sonnet 5.5 turns run on both sides. The last part repeats the run over Tailscale HTTPS (https://cups.tail131df4.ts.net:3972).

  • Move with git work: see #16734. It ended departed with the new thread id, and the box ran the continuation prompt.
  • The copy left behind is read-only: a later t3_thread_send to it is refused.
  • Deferred self-handoff: a real agent created notes.md, then was asked "hey i need to wrap here, can you move this to my box?". It called t3_thread_handoff with whenTurnEnds. The handoff went pending (08:58:24) → departing (08:58:26) → departed (08:58:28) exactly as its turn ended. Its last reply on the laptop said the move would happen when it finished. On the box, the continuation appended to the uncommitted notes.md, which now reads "started on the laptop / continued on the box".
  • Cancel: an agent asked to move, and a message was steered into the same turn. The move was cancelled, the thread stayed (handoff: null), and it replied "STAYING".

Three bugs found and fixed in this PR:

  1. Project matching had the same cold-cache identity bug as #16719. The test now uses the real RepositoryIdentityResolver on real clones instead of a stubbed identity.
  2. A send to a departed thread failed with only "Failed to dispatch orchestration command message.dispatch (…)". It is now a typed OrchestratorThreadMovedError whose message the agent sees: "This thread moved to cups. Continue it there."
  3. A message steered into the turn that asked for the move did not cancel it. The check compared against the run's start and ignored agent-sent messages, while the e2e message came from an outside agent (an orchestrator acting for the user). Now any message after the asking turn's start cancels the move. The test steers an agent message into that same turn. CapturingCodexAdapter now reports a held turn as running, so steering can target it.

CI also flagged chained Effect.provide calls in the test, now merged into one layer.

Opus 5.5 via Claude Code.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

macroscope-review Opt PRs made by unvouched contributors in for Macroscope review. Vouched contributors auto-reviews size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant