Skip to content

feat(orchestrator): move a running thread between devices - #5544

Draft
Bil0000 wants to merge 8 commits into
pingdotgg:t3code/codex-turn-mappingfrom
Bil0000:feat/thread-handoff
Draft

feat(orchestrator): move a running thread between devices#5544
Bil0000 wants to merge 8 commits into
pingdotgg:t3code/codex-turn-mappingfrom
Bil0000:feat/thread-handoff

Conversation

@Bil0000

@Bil0000 Bil0000 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Stacked on #2829 (t3code/codex-turn-mapping), not main. Every primitive this builds on — orchestration-v2/, OrchestrationV2* contracts, ContextHandoffService, the turn-item model — exists only on that branch; main and the orchestrator branch share no common ancestor, so a version built against main would be rewritten wholesale when the orchestrator lands.

What this does

Today Run on picks the machine a thread starts on and locks once the thread has a message, so a thread is born on one device and dies there. This makes that reversible: from an open thread you pick another connected environment, and the conversation, the provider continuation, and the git working state the thread was left in all move there.

Open a thread, click Run on, pick a device under Move thread to, confirm. The origin thread goes read-only with a banner naming where it went; the thread continues on the other machine with its history and its uncommitted work.

The safety model

A branch tip only ever moves to a descendant of itself, on either machine. classifyIncomingTip reduces that to advance / absorb / diverged / unrelated as a pure function. Diverged or unrelated parks the sender's commits at refs/handoff/<environment>/<branch> and stops with both machines untouched.

Exactly one side is live. thread.handoff.depart locks the giving side before any bundle is applied anywhere. That ordering is the invariant: an interrupted transfer leaves a locked thread that thread.handoff.abort releases, never a second conversation to merge. message.dispatch is refused while a thread is away, in the orchestrator rather than the client.

Before any pointer moves the old tip is tagged and a dirty worktree stashed with the base sha in the label. The patch is dry-run applied first; one that will not apply resets to the tag and pops the stash rather than leaving the tree half-written. applying is the only state in which a repository can have been written to, so it is the only state startup recovery looks at.

What travels

Branch and unpushed commits as a git bundle; tracked changes as a binary patch so images and lockfiles survive; untracked files as a null-delimited tar so paths with spaces do; the conversation as turn items with their covered run ordinals; the workspace strategy so a worktree thread can be reprovisioned. Checkpoints are hidden git refs, so bundling them with the commits carries the whole checkpoint timeline with no payload of its own and revert keeps working on the far side.

How it moves

The manifest is inline; the large parts are content-addressed and fetched by digest, so the transport can change later without moving the manifest or the ids with it. The client brokers the copy because it is the only component authenticated to both environments.

Part bytes move in 4 MB chunks. Chunking keeps neither server holding a whole payload in memory and makes a transfer that dies partway resumable from the offset it reached. The receiver rejects a chunk that does not continue exactly where the staged bytes end — a chunk written at the wrong offset would leave a hole that only surfaces later as a digest mismatch or a corrupt bundle, so it fails at the seam instead.

Payload ceilings are enforced while preparing, before anything has been sent: warn at 200 MB, refuse at 1 GB.

Lineage, not direction

Each hop records the previous hop rather than a push/pull direction, so laptop → server → phone → laptop is a walk and a return trip is an ordinary hop toward an environment already in the chain. One nullable column; retrofitting it later would rewrite every existing row.

Version skew

threadHandoff follows the same contract as threadSettlement: absent means unsupported, so a client lists such an environment as an unavailable destination instead of starting a transfer that fails once bytes are already moving.

Files

Contracts: bundle, ids, capability, commands, events, RPC and HTTP surface.
Server: ThreadHandoffService (prepare / receive / recover), ThreadHandoffGit (classification and git plumbing), orchestrator commands and guards, projection, migration 046, part endpoints, startup recovery.
Client: runThreadHandoffTransfer (the hop across two environments), chunked part copy, the move command, the Run on move group, the dialog, the departed banner.

Verification

  • packages/contracts: 264 pass.
  • packages/client-runtime: 570 pass.
  • apps/server: 732 pass across orchestration-v2, persistence, environment, ws and startup. New coverage: the full tip-classification table, ref/tag/stash naming, payload-ceiling boundaries, chunk-window clamping and completion, part-name collisions, digest behaviour, conversation coverage, and four migration tests including duplicate-hop rejection and mid-apply recovery lookup.
  • apps/web: 1841 pass. One pre-existing failure, imageCompression.test.ts, which fails identically on a clean tree here (a 15s timeout on this box) and is unrelated to this branch.
  • pnpm typecheck clean across the monorepo. vp lint clean. vp fmt --check clean.
  • Only two existing test expectations changed, both structurally forced: the migration-contiguity assertion (45 → 46) and the startup-phase list, which now includes handoff recovery.

Not yet exercised end to end against two live servers. The unit and integration coverage is real, but the round trip on two real environments with a dirty worktree has not been run.

Not in this PR

Attachment and terminal payloads (the part kind and the manifest field exist; nothing packs them yet), worktree reprovisioning and clone-from-bundle on arrival, mid-run departure via interruptAndAwaitTerminal, the pull-back affordance in the UI, and mobile.

Note for review

Migration id 046 is also taken by #5499 (session import) on a sibling branch off the same base. Whichever merges second renumbers.

Thread handoff moves a thread from the environment that owns it to another
connected environment: the conversation, the provider continuation, and the
git working state the thread was left in.

This lands the parts that are expensive to change once two servers have
exchanged anything, ahead of the services that use them:

- `OrchestrationV2HandoffBundleV1`, a manifest plus content-addressed parts.
  Structural state is inline; large payloads (git bundle, tracked patch,
  untracked tar, attachments) are fetched separately by digest, so the
  transport can change later without moving the manifest or the ids with it.
- `ThreadHandoffId`, shared by both sides of a hop, which makes it both the
  idempotency key for applying a bundle and the link along a lineage.
- A lineage that records the previous hop rather than a push/pull direction,
  so laptop -> server -> phone -> laptop is a walk instead of a special case.
- The `threadHandoff` environment capability, following the same version-skew
  contract as `threadSettlement`: absent means unsupported, so a client lists
  such an environment as unavailable instead of starting a transfer that
  fails once bytes are already moving. No server advertises it yet.
- `orchestration_v2_thread_handoffs`, which records what a receiving
  repository looked like before a bundle was applied so a partial apply is
  rolled back from a lookup rather than a reconstruction.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f8353d1e-bee8-4878-b7c5-3acfb7ec9121

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One finding on the new handoff error contract; the migration, its tests, and the bundle schemas otherwise follow the Effect conventions (subpath namespace imports, no service-instance injection, no ManagedRuntime/runPromise, no Layer.succeed shortcuts).

Posted via Macroscope — Effect Service Conventions

Comment thread packages/contracts/src/orchestrationV2.ts
Bil0000 added 2 commits August 6, 2026 20:05
Adds the handoff link to the thread aggregate and the three commands that
move it, so a thread can record that its work left for another environment
and came back.

- `thread.handoff.depart` locks the giving side before any bundle is applied
  anywhere. Locking first is what makes "exactly one side is live" an
  invariant: an interrupted transfer leaves a locked thread that
  `thread.handoff.abort` releases, never a second conversation to merge.
- `thread.handoff.complete` records the peer's thread id once it confirms.
- `message.dispatch` is refused while a thread is away, in the orchestrator
  rather than the client, naming the environment that owns it.

The link rides in the thread payload, which the projection already stores as
`payload_json`, so no projection migration is needed and older stored
payloads decode unchanged. It is also carried on the thread shell so a
sidebar can badge a thread without loading its detail.

`ThreadHandoffGit` lands the safety core: a branch tip only ever moves to a
descendant of itself, on either machine. `classifyIncomingTip` reduces that
to advance / absorb / diverged / unrelated as a pure function, with the ref,
tag and stash naming that make a refusal or a rollback legible afterwards.
`ThreadHandoffService` is the hop itself: prepare on the giving side, receive
on the taking side, and a startup pass for anything left half-applied.

Prepare stages the parts and writes nothing the user can see, so the counts a
preflight shows come from the code path the transfer actually uses. It bundles
the branch together with the checkpoint refs — checkpoints are hidden git refs,
so the whole checkpoint timeline travels inside the object bundle with no
payload of its own and revert keeps working on the far side. Tracked changes
travel as a binary patch so images and lockfiles survive; untracked files
travel as a null-delimited tar so paths with spaces do. The two payload
ceilings are enforced here, before anything has been sent, so a refusal costs
nothing on either machine.

Receive verifies every part against its digest before touching anything, then
classifies the incoming tip. A diverged or unrelated branch parks the sender's
commits at `refs/handoff/<environment>/<branch>` and stops with both machines
untouched. Otherwise the old tip is tagged and a dirty worktree stashed before
any pointer moves, the patch is dry-run applied before it is really applied,
and a patch that will not apply rolls the repository back to the tag and pops
the stash rather than leaving it half-written.

Hops are serialized per thread, and `applying` is the only state in which a
repository can have been written to, so it is the only one startup recovery
has to look at.

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effect service conventions review of the two new service modules. Three findings, all in apps/server/src/orchestration-v2/. Earlier feedback on OrchestrationV2HandoffError.message still stands and is not repeated here.

Posted via Macroscope — Effect Service Conventions

...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
});

export interface ThreadHandoffGitShape {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here: ThreadHandoffGitShape should be inlined into the Context.Service<ThreadHandoffGit, { ... }>() declaration, with the local implementation bindings annotated as ThreadHandoffGit["Service"]["resolveTip"] and so on, instead of exporting a separate shape type.

Posted via Macroscope — Effect Service Conventions

ThreadHandoffServiceShape
>()("t3/orchestration-v2/ThreadHandoffService") {}

const handoffError = (input: {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handoffError only forwards its arguments into new OrchestrationV2HandoffError({ ... }), so it adds an indirection without normalization. Consider constructing the error directly at each failure boundary (the optional-field spreads are only needed for handoffId/cause, which each call site already knows) and keeping asHandoffError as the one mapper, since that one does real work by passing through an existing handoff error.

Posted via Macroscope — Effect Service Conventions

readonly preTag: string | null;
}

export interface ThreadHandoffServiceShape {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New service modules should define the service interface inline in the Context.Service declaration rather than keeping a standalone ThreadHandoffServiceShape. Consider moving this body into Context.Service<ThreadHandoffService, { ... }>() below and referring to members as ThreadHandoffService["Service"]["partPath"] etc. at the implementation sites (partPath, verifyStagedPart, prepare, receive, recoverInterrupted).

Posted via Macroscope — Effect Service Conventions

Comment thread packages/contracts/src/orchestrationV2.ts
const process = yield* VcsProcess;
const git = (input: GitInput) => runGit(process, input);

const resolveTip: ThreadHandoffGitShape["resolveTip"] = (input) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High orchestration-v2/ThreadHandoffGit.ts:195

resolveTip maps any nonzero git rev-parse exit to null, so a corrupted or unreadable ref returns null instead of failing with VcsError. The caller then treats the branch as nonexistent, classifies the transfer as advance, skips the pre-tag and stash, and runs checkout -B — overwriting an existing branch without the rollback tag the safety model requires. Only the expected "ref not found" case should map to null; other nonzero exits should propagate as VcsError.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/ThreadHandoffGit.ts around line 195:

`resolveTip` maps any nonzero `git rev-parse` exit to `null`, so a corrupted or unreadable ref returns `null` instead of failing with `VcsError`. The caller then treats the branch as nonexistent, classifies the transfer as `advance`, skips the pre-tag and stash, and runs `checkout -B` — overwriting an existing branch without the rollback tag the safety model requires. Only the expected "ref not found" case should map to `null`; other nonzero exits should propagate as `VcsError`.

Comment on lines +250 to +256
if (sha256(contents) !== input.part.digest) {
return yield* handoffError({
reason: "part_digest_mismatch",
message: `Handoff part ${input.part.kind} does not match the digest in the manifest.`,
handoffId: input.handoffId,
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium orchestration-v2/ThreadHandoffService.ts:250

verifyStagedPart checks the SHA-256 digest but never compares contents.length against input.part.byteLength, so a manifest with a wrong declared byte length is accepted. Any receive-side size accounting that trusts the declared byteLength (e.g., payload ceilings or transfer-progress tracking) uses the incorrect value, and a peer can declare a smaller length to bypass size checks. Consider rejecting the part when contents.length !== input.part.byteLength.

      if (sha256(contents) !== input.part.digest) {
        return yield* handoffError({
          reason: "part_digest_mismatch",
          message: `Handoff part ${input.part.kind} does not match the digest in the manifest.`,
          handoffId: input.handoffId,
        });
      }
+      if (contents.length !== input.part.byteLength) {
+        return yield* handoffError({
+          reason: "part_digest_mismatch",
+          message: `Handoff part ${input.part.kind} byte length does not match the manifest.`,
+          handoffId: input.handoffId,
+        });
+      }
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/ThreadHandoffService.ts around lines 250-256:

`verifyStagedPart` checks the SHA-256 digest but never compares `contents.length` against `input.part.byteLength`, so a manifest with a wrong declared byte length is accepted. Any receive-side size accounting that trusts the declared `byteLength` (e.g., payload ceilings or transfer-progress tracking) uses the incorrect value, and a peer can declare a smaller length to bypass size checks. Consider rejecting the part when `contents.length !== input.part.byteLength`.

});
}

yield* markHop({ handoffId: bundle.handoffId, state: "applying", lastError: null });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High orchestration-v2/ThreadHandoffService.ts:693

When receive processes a first-time arrival, markHop is called with state: "applying" before recordHop has inserted a row for bundle.handoffId. Because markHop is only an UPDATE, it affects zero rows on a first arrival. If the server stops after repository writes begin, recoverInterrupted finds no row in the applying state and cannot mark the partially modified repository as failed — leaving the handoff unrecoverable by the advertised recovery path. Consider calling recordHop with state: "applying" before the first markHop, or making markHop upsert.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/ThreadHandoffService.ts around line 693:

When `receive` processes a first-time arrival, `markHop` is called with `state: "applying"` before `recordHop` has inserted a row for `bundle.handoffId`. Because `markHop` is only an `UPDATE`, it affects zero rows on a first arrival. If the server stops after repository writes begin, `recoverInterrupted` finds no row in the `applying` state and cannot mark the partially modified repository as failed — leaving the handoff unrecoverable by the advertised recovery path. Consider calling `recordHop` with `state: "applying"` before the first `markHop`, or making `markHop` upsert.

serialize.withLock(
input.bundle.handoffId,
Effect.gen(function* () {
const { bundle } = input;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High orchestration-v2/ThreadHandoffService.ts:617

receive has no idempotency check: when called again with the same bundle.handoffId that already succeeded, it re-imports the git bundle, re-applies the tracked patch (which typically fails because the changes are already present), and creates a second thread with a new random threadId instead of returning the existing one. The duplicate recordHop row is upserted to arrived state, but writeArrival already emitted a second thread.created event with a different threadId. Consider looking up the handoff row by bundle.handoffId before processing and short-circuiting if its state is already arrived, returning the previously recorded thread_id and classification.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/ThreadHandoffService.ts around line 617:

`receive` has no idempotency check: when called again with the same `bundle.handoffId` that already succeeded, it re-imports the git bundle, re-applies the tracked patch (which typically fails because the changes are already present), and creates a second thread with a new random `threadId` instead of returning the existing one. The duplicate `recordHop` row is upserted to `arrived` state, but `writeArrival` already emitted a second `thread.created` event with a different `threadId`. Consider looking up the handoff row by `bundle.handoffId` before processing and short-circuiting if its state is already `arrived`, returning the previously recorded `thread_id` and classification.

}),
);

const recoverInterrupted: ThreadHandoffServiceShape["recoverInterrupted"] = () =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High orchestration-v2/ThreadHandoffService.ts:785

recoverInterrupted marks interrupted hops as failed but never reverts the partial working-tree changes. After a shutdown during receive, the repository is left with a half-applied checkout, patch, or untracked archive, and startup recovery only updates the database row to failed — it does not read the recorded pre_tag or stash_ref to run rollback. The user's worktree is permanently left in the partial state. Consider having recoverInterrupted load each row's pre_tag, stash_ref, and cwd and call rollback (or the equivalent git.resetHardTo / git.popStash) before marking the hop failed.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/ThreadHandoffService.ts around line 785:

`recoverInterrupted` marks interrupted hops as `failed` but never reverts the partial working-tree changes. After a shutdown during `receive`, the repository is left with a half-applied checkout, patch, or untracked archive, and startup recovery only updates the database row to `failed` — it does not read the recorded `pre_tag` or `stash_ref` to run `rollback`. The user's worktree is permanently left in the partial state. Consider having `recoverInterrupted` load each row's `pre_tag`, `stash_ref`, and `cwd` and call `rollback` (or the equivalent `git.resetHardTo` / `git.popStash`) before marking the hop `failed`.


const receive: ThreadHandoffServiceShape["receive"] = (input) =>
serialize.withLock(
input.bundle.handoffId,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High orchestration-v2/ThreadHandoffService.ts:615

receive serializes on bundle.handoffId, but prepare serializes on input.threadId. Two concurrent handoffs for the same thread acquire different locks, as does a concurrent prepare and receive on the same thread, so both can execute overlapping checkout, stash, patch, and archive operations in the same worktree simultaneously. This corrupts or cross-contaminates both handoffs' working-state application. Both prepare and receive should serialize on a key derived from the thread identity (e.g. the thread id or workspace root) rather than on the handoff id.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/ThreadHandoffService.ts around line 615:

`receive` serializes on `bundle.handoffId`, but `prepare` serializes on `input.threadId`. Two concurrent handoffs for the same thread acquire different locks, as does a concurrent `prepare` and `receive` on the same thread, so both can execute overlapping checkout, stash, patch, and archive operations in the same worktree simultaneously. This corrupts or cross-contaminates both handoffs' working-state application. Both `prepare` and `receive` should serialize on a key derived from the thread identity (e.g. the thread id or workspace root) rather than on the handoff id.

handoffId: bundle.handoffId,
});
}
yield* git

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High orchestration-v2/ThreadHandoffService.ts:740

After the patch dry-run succeeds, any failure from the real git.applyPatch, git.extractArchive, writeArrival, recordHop, or markHop escapes the receive block without calling rollback. The branch may have already been moved to the incoming commit and the local worktree stashed, so the repository is left partially modified and the user's stashed changes are never restored. The rollback({ cwd, preTag, stashRef }) call is only wired into the dry-run-failure path; it is not used as a cleanup handler for the rest of the apply step.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/ThreadHandoffService.ts around line 740:

After the patch dry-run succeeds, any failure from the real `git.applyPatch`, `git.extractArchive`, `writeArrival`, `recordHop`, or `markHop` escapes the `receive` block without calling `rollback`. The branch may have already been moved to the incoming commit and the local worktree stashed, so the repository is left partially modified and the user's stashed changes are never restored. The `rollback({ cwd, preTag, stashRef })` call is only wired into the dry-run-failure path; it is not used as a cleanup handler for the rest of the apply step.

verifyStagedPart({ handoffId: bundle.handoffId, part }),
);

const cwd = yield* workspaceRootFor(input.projectId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High orchestration-v2/ThreadHandoffService.ts:622

receive always applies git state to the project's root workspace regardless of the bundle's workspace.strategy. When the bundle specifies existing_worktree, the git import, branch update, stash, and patch application are all performed at the root workspace path, while the created/updated thread is assigned the sender's worktreePath. This means the handoff reports success even though the repository at worktreePath was never modified and the project root was modified instead. The cwd for git operations should resolve from the bundle's workspace strategy (or the existing thread's worktreePath for returning hops) rather than always using workspaceRootFor(input.projectId).

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/ThreadHandoffService.ts around line 622:

`receive` always applies git state to the project's root workspace regardless of the bundle's `workspace.strategy`. When the bundle specifies `existing_worktree`, the git import, branch update, stash, and patch application are all performed at the root workspace path, while the created/updated thread is assigned the sender's `worktreePath`. This means the handoff reports success even though the repository at `worktreePath` was never modified and the project root was modified instead. The `cwd` for git operations should resolve from the bundle's workspace strategy (or the existing thread's `worktreePath` for returning hops) rather than always using `workspaceRootFor(input.projectId)`.

},
]),
];
yield* eventStore.append({ events });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High orchestration-v2/ThreadHandoffService.ts:604

When receive completes a handoff, the thread is created or continued on the destination without any of its conversation history. writeArrival only appends the thread.created/thread.handoff-returned and thread.handoff-arrived lifecycle events, and never emits events for bundle.conversation.items or bundle.conversation.coveredRunOrdinals. As a result the receiving environment has a thread with no turn items or run ordinals, so the provider session has no prior context to continue from. Consider replaying the carried conversation items and run ordinals as domain events alongside the lifecycle events in writeArrival.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/ThreadHandoffService.ts around line 604:

When `receive` completes a handoff, the thread is created or continued on the destination without any of its conversation history. `writeArrival` only appends the `thread.created`/`thread.handoff-returned` and `thread.handoff-arrived` lifecycle events, and never emits events for `bundle.conversation.items` or `bundle.conversation.coveredRunOrdinals`. As a result the receiving environment has a thread with no turn items or run ordinals, so the provider session has no prior context to continue from. Consider replaying the carried conversation items and run ordinals as domain events alongside the lifecycle events in `writeArrival`.

Wires the handoff service into the running server:

- `orchestration.prepareThreadHandoff` and `orchestration.receiveThreadHandoff`
  join the orchestration RPC group, both behind the operate scope. Preparing
  stages bytes and records a hop, so it is an operate even though nothing the
  user can see has changed yet.
- The RPC results narrow to what a caller can act on: a payload over the hard
  ceiling fails with `payload_too_large` rather than returning a verdict
  nobody can do anything about, and a diverged or unrelated branch fails with
  `workspace_diverged` rather than returning a classification that wrote
  nothing.
- `ThreadHandoffService` is layered with its git, project, repository-identity
  and provider-registry dependencies.
- Startup fails any hop still marked `applying`, which is the only state in
  which a transfer that never finished can have written to this environment's
  repository.
@Bil0000 Bil0000 changed the title feat(contracts): thread handoff bundle contract feat(orchestrator): move a running thread between devices Aug 6, 2026
Bil0000 added 3 commits August 6, 2026 21:28
Adds the transport the manifest was designed for, and turns the capability
on now that an environment can actually receive a hop.

Part bytes move in bounded chunks rather than as one body. A part can be as
large as the payload ceiling, so chunking keeps neither server holding a
whole payload in memory, and it makes a transfer that dies partway resumable
from the offset it reached instead of starting over.

- `POST /api/orchestration/handoffs/:handoffId/parts/:kind/read` returns a
  window of a staged part plus whether it is the last one, so a caller stops
  asking without comparing offsets itself. Reading takes a payload rather
  than a query string: the offset is part of a resumable transfer, not a
  filter, and it keeps both directions on the same typed shape.
- `POST .../:kind` appends a chunk, and rejects one that does not continue
  exactly where the staged bytes end. A chunk written at the wrong offset
  would leave a hole that only surfaces later as a digest mismatch or a
  corrupt bundle, so it fails at the seam instead.
- Both are behind the orchestration operate scope.
- `handoffChunkWindow` is the chunk math on its own, clamping rather than
  failing so a retry that asks past the end resolves instead of erroring.

Servers now advertise `threadHandoff`, and the client projection applies the
four handoff events, so a departed or arrived thread updates live rather
than only after a reload.
`runThreadHandoffTransfer` is the hop from the client's side: it is the only
component authenticated to both environments, so it is what moves the bytes.

The order is the safety model — stage, lock the giving side, move the bytes,
apply, record where the thread went. Locking before the bytes move guarantees
the two sides can never both be live, and every failure after the lock
releases it, so a transient network error leaves the thread usable where it
started rather than stranded with nobody able to type in it.

Parts copy a chunk at a time between the two servers. The receiver rejects a
chunk that does not continue where the staged bytes end, so the reader's
reported offset is the only thing deciding where a write lands and a retried
chunk cannot punch a hole in a part.

Progress is reported per phase, split by whether the receiving repository has
been written to yet: everything through `upload` is free to cancel, `apply`
onward is not.
`Run on` already answers "which machine is this thread on", so it is where
moving belongs. Once a thread is locked to its environment the control stays
interactive instead of collapsing to a label: the current device sits under
"Running on", and the others under "Move thread to" — a separate group with
its own verb rather than the same rows quietly meaning something different.

The dialog shows what travels, then the steps as they run, split by whether
the receiving repository has been written to yet. Cancel is offered through
the upload and withdrawn at apply, because from there only the servers can
put the other machine back. A failure surfaces the server's own message: a
divergence names the ref the commits were parked at and a payload refusal
names the size, both of which beat anything generic the dialog could say.

The move command spans two environments, so it is a runtime command rather
than a per-environment one, and it resolves both connections before locking
anything — discovering halfway through that the destination was never
reachable would mean pausing a thread for a transfer that could not work.
handoffId: bundle.handoffId,
targetThreadId: application.threadId,
} satisfies ThreadHandoffTransferResult;
}).pipe(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High state/threadHandoffTransfer.ts:201

Effect.tapCause calls release (which sends thread.handoff.abort) on every failure, including failures that occur after receiveThreadHandoff has already applied the bundle on the target. If completeThreadHandoff fails, release unlocks the origin side even though the target is already live — leaving both copies of the thread usable and free to diverge. Cleanup after the apply boundary must not abort/unlock the giving side; consider splitting the error handler so only pre-apply failures trigger release, while post-apply failures surface without aborting.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/threadHandoffTransfer.ts around line 201:

`Effect.tapCause` calls `release` (which sends `thread.handoff.abort`) on every failure, including failures that occur after `receiveThreadHandoff` has already applied the bundle on the target. If `completeThreadHandoff` fails, `release` unlocks the origin side even though the target is already live — leaving both copies of the thread usable and free to diverge. Cleanup after the apply boundary must not abort/unlock the giving side; consider splitting the error handler so only pre-apply failures trigger `release`, while post-apply failures surface without aborting.

)}
</DialogPanel>
<div className="flex justify-end gap-2 px-4 pb-4">
<Button

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High components/ThreadHandoffDialog.tsx:194

When the user clicks Cancel during a safe-to-cancel phase (prepare, depart, or upload), the button calls onOpenChange(false) which closes the dialog but does not abort the in-flight move promise. The transfer keeps running, and on success onMoved is still invoked — so the thread is moved even though the user cancelled. The onOpenChange guard only blocks the backdrop/escape path, not the explicit Cancel button, and there is no mechanism wired to interrupt move. Consider having the Cancel button actually cancel the handoff (e.g. via an AbortSignal/abort command exposed by threadHandoff) rather than just closing the dialog.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ThreadHandoffDialog.tsx around line 194:

When the user clicks `Cancel` during a safe-to-cancel phase (`prepare`, `depart`, or `upload`), the button calls `onOpenChange(false)` which closes the dialog but does not abort the in-flight `move` promise. The transfer keeps running, and on success `onMoved` is still invoked — so the thread is moved even though the user cancelled. The `onOpenChange` guard only blocks the backdrop/escape path, not the explicit Cancel button, and there is no mechanism wired to interrupt `move`. Consider having the Cancel button actually cancel the handoff (e.g. via an `AbortSignal`/abort command exposed by `threadHandoff`) rather than just closing the dialog.

if (!exists) {
return yield* failEnvironmentNotFound("handoff_part_not_found");
}
const contents = yield* fs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High orchestration-v2/http.ts:126

readHandoffPart calls fs.readFile(target) to load the entire staged part into memory before slicing out one fixed-size chunk. A handoff part can approach the 1 GiB payload limit, so every chunk request allocates the whole file, and repeated requests reread the full file for each chunk. Consider using a bounded positional read for the requested window instead of reading the entire file.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/http.ts around line 126:

`readHandoffPart` calls `fs.readFile(target)` to load the entire staged part into memory before slicing out one fixed-size chunk. A handoff part can approach the 1 GiB payload limit, so every chunk request allocates the whole file, and repeated requests reread the full file for each chunk. Consider using a bounded positional read for the requested window instead of reading the entire file.

if (args.payload.offset !== staged.length) {
return yield* failEnvironmentInvalidRequest("handoff_part_offset_mismatch");
}
const next = new Uint8Array(staged.length + args.payload.data.length);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High orchestration-v2/http.ts:167

In writeHandoffPart, the offset check and file write are not serialized. If two requests for the same handoff part arrive concurrently with the same offset (e.g., an overlapping retry), both read the same staged length, both pass the offset !== staged.length check, and both write their own next buffer — the second write silently overwrites the first, so one accepted chunk is lost even though both requests report success. The transfer ends up corrupted and only surfaces later as a digest mismatch. Consider serializing writes per handoff part (e.g., a lock keyed by handoffId + kind) so concurrent writes cannot interleave.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/http.ts around line 167:

In `writeHandoffPart`, the offset check and file write are not serialized. If two requests for the same handoff part arrive concurrently with the same offset (e.g., an overlapping retry), both read the same `staged` length, both pass the `offset !== staged.length` check, and both write their own `next` buffer — the second write silently overwrites the first, so one accepted chunk is lost even though both requests report success. The transfer ends up corrupted and only surfaces later as a digest mismatch. Consider serializing writes per handoff part (e.g., a lock keyed by `handoffId` + `kind`) so concurrent writes cannot interleave.

*/
export const ENVIRONMENT_HANDOFF_PART_CHUNK_BYTES = 4 * 1024 * 1024;

const EnvironmentHandoffPartParams = Schema.Struct({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High src/environmentHttp.ts:492

EnvironmentHandoffPartParams.handoffId accepts any trimmed non-empty string, including path traversal sequences like ../../etc. The readHandoffPart and writeHandoffPart endpoints interpolate handoffId directly into the request path /api/orchestration/handoffs/:handoffId/parts/:kind, and the downstream ThreadHandoffService.partPath joins it with config.handoffsDir via path.join. A client with orchestration:operate scope can supply a traversal payload as handoffId to read or overwrite files (e.g. git.bundle) in arbitrary directories reachable from handoffsDir. Consider restricting ThreadHandoffId to a path-safe identifier pattern, or validating that the resolved path remains beneath handoffsDir before any filesystem operation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/contracts/src/environmentHttp.ts around line 492:

`EnvironmentHandoffPartParams.handoffId` accepts any trimmed non-empty string, including path traversal sequences like `../../etc`. The `readHandoffPart` and `writeHandoffPart` endpoints interpolate `handoffId` directly into the request path `/api/orchestration/handoffs/:handoffId/parts/:kind`, and the downstream `ThreadHandoffService.partPath` joins it with `config.handoffsDir` via `path.join`. A client with `orchestration:operate` scope can supply a traversal payload as `handoffId` to read or overwrite files (e.g. `git.bundle`) in arbitrary directories reachable from `handoffsDir`. Consider restricting `ThreadHandoffId` to a path-safe identifier pattern, or validating that the resolved path remains beneath `handoffsDir` before any filesystem operation.

Wires the handoff into the thread view:

- The `Run on` control offers the other devices once a thread is locked to
  its environment, and opens the move dialog for the one picked. It is only
  offered for a real thread that is not already away — a draft has nothing to
  move, and a thread running elsewhere is not this side's to send.
- A departed thread shows a composer banner naming the environment that owns
  it. The server refuses the send either way; this is what keeps that refusal
  from being a mystery.
- The client thread shell carries the handoff link, so the banner and the
  move affordance both read it without loading thread detail. Absent on
  servers that predate handoff, which is what stops a client offering a move
  an environment cannot perform.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

When layout === "panel", onMoveThread is never forwarded to BranchToolbarEnvironmentSelector — the panel branch returns at line 406, before the only forwarding site at line 472. As a result, a locked thread rendered in the thread-details panel cannot be moved to another environment even when the caller supplies onMoveThread: the environment selector collapses to a static label instead of offering move targets. The panel branch should forward onMoveThread to its own BranchToolbarEnvironmentSelector (or to the BranchToolbar that renders it), so the move-thread control stays interactive in the panel layout.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/BranchToolbar.tsx around line 407:

When `layout === "panel"`, `onMoveThread` is never forwarded to `BranchToolbarEnvironmentSelector` — the panel branch returns at line 406, before the only forwarding site at line 472. As a result, a locked thread rendered in the thread-details panel cannot be moved to another environment even when the caller supplies `onMoveThread`: the environment selector collapses to a static label instead of offering move targets. The panel branch should forward `onMoveThread` to its own `BranchToolbarEnvironmentSelector` (or to the `BranchToolbar` that renders it), so the move-thread control stays interactive in the panel layout.

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