diff --git a/.gitignore b/.gitignore index 07793efe9b5..93db4f8f2e6 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ node_modules/ *.log .env* !.env.example +apps/mobile/modules/*/android/.gradle/ diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index eb254734664..2f9d9d42049 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -43,6 +43,7 @@ import { ThreadComposer, } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; +import { ThreadHandoffBanner } from "./ThreadHandoffBanner"; import { ThreadRelationshipsBanner } from "./ThreadRelationshipsBanner"; import { ThreadQueueControl } from "./ThreadQueueControl"; import type { ThreadContentPresentation } from "./threadContentPresentation"; @@ -409,10 +410,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread contentBottomInset={estimatedOverlayHeight} contentMaxWidth={contentMaxWidth} topAccessory={ - + <> + + + } layoutVariant={layoutVariant} usesAutomaticContentInsets={props.usesAutomaticContentInsets} diff --git a/apps/mobile/src/features/threads/ThreadHandoffBanner.tsx b/apps/mobile/src/features/threads/ThreadHandoffBanner.tsx new file mode 100644 index 00000000000..cc37824d4c0 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadHandoffBanner.tsx @@ -0,0 +1,84 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { SymbolView } from "expo-symbols"; +import { useCallback, useState } from "react"; +import { ActivityIndicator, Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useThreadShells } from "../../state/entities"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; + +/** + * Shows where a departed thread's work lives, with the escape hatch to make + * this side live again. Moving a thread FROM the phone is not offered yet; + * this keeps a thread that was moved elsewhere legible rather than silently + * refusing every send. + */ +export function ThreadHandoffBanner(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +}) { + const shells = useThreadShells(); + const shell = + shells.find( + (candidate) => + candidate.environmentId === props.environmentId && candidate.id === props.threadId, + ) ?? null; + const handoff = shell?.handoff ?? null; + const releaseHandoff = useAtomCommand(threadEnvironment.releaseHandoff, { + reportFailure: false, + }); + const [releasing, setReleasing] = useState(false); + const borderColor = useThemeColor("--color-border"); + + const handleRelease = useCallback(async () => { + if (handoff === null || releasing) return; + setReleasing(true); + try { + await releaseHandoff({ + environmentId: props.environmentId, + input: { threadId: props.threadId, handoffId: handoff.handoffId }, + }); + } finally { + setReleasing(false); + } + }, [handoff, props.environmentId, props.threadId, releaseHandoff, releasing]); + + if (handoff === null || handoff.presence !== "away") { + return null; + } + + return ( + + + + + Running on {handoff.peerLabel ?? "another device"} + + + The thread now lives there — keep working with it from any device. + + + { + void handleRelease(); + }} + className="rounded-lg px-3 py-1.5" + style={{ borderWidth: 1, borderColor }} + > + {releasing ? ( + + ) : ( + Continue here + )} + + + ); +} diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 882f87826b8..c108f1ab2c2 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -29,6 +29,10 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.launchThread]: AuthOrchestrationOperateScope, + // Preparing stages bytes and records a hop, so it is an operate even + // though nothing the user can see has changed yet. + [ORCHESTRATION_V2_WS_METHODS.prepareThreadHandoff]: AuthOrchestrationOperateScope, + [ORCHESTRATION_V2_WS_METHODS.receiveThreadHandoff]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index e678264dde5..8f980bdfdca 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -33,6 +33,8 @@ export interface ServerDerivedPaths { readonly providerStatusCacheDir: string; readonly worktreesDir: string; readonly attachmentsDir: string; + /** Staging area for thread handoff bundle parts, one directory per hop. */ + readonly handoffsDir: string; readonly logsDir: string; readonly serverLogPath: string; readonly serverTracePath: string; @@ -119,6 +121,7 @@ export const deriveServerPaths = Effect.fn(function* ( providerStatusCacheDir, worktreesDir: join(baseDir, "worktrees"), attachmentsDir, + handoffsDir: join(stateDir, "handoffs"), logsDir, serverLogPath: join(logsDir, "server.log"), serverTracePath: join(logsDir, "server.trace.ndjson"), @@ -144,6 +147,7 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server fs.makeDirectory(derivedPaths.terminalLogsDir, { recursive: true }), fs.makeDirectory(derivedPaths.attachmentsDir, { recursive: true }), fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }), + fs.makeDirectory(derivedPaths.handoffsDir, { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }), fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index b7cf5b58c8b..31ddf4354dd 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -148,6 +148,7 @@ export const make = Effect.gen(function* () { threadPinning: true, threadTitleRegeneration: true, threadVisitedTracking: true, + threadHandoff: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts index 16c695fe780..42c19e9b85e 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts @@ -75,6 +75,7 @@ export function makeReplayServerConfig( const terminalLogsDir = path.join(logsDir, "terminals"); const attachmentsDir = path.join(stateDir, "attachments"); const worktreesDir = path.join(baseDir, "worktrees"); + const handoffsDir = path.join(stateDir, "handoffs"); const providerStatusCacheDir = path.join(baseDir, "caches"); for (const directory of [ @@ -84,6 +85,7 @@ export function makeReplayServerConfig( terminalLogsDir, attachmentsDir, worktreesDir, + handoffsDir, providerStatusCacheDir, ]) { yield* fs.makeDirectory(directory, { recursive: true }); @@ -121,6 +123,7 @@ export function makeReplayServerConfig( settingsPath: path.join(stateDir, "settings.json"), providerStatusCacheDir, worktreesDir, + handoffsDir, attachmentsDir, logsDir, serverLogPath: path.join(logsDir, "server.log"), diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts index 29d210ec98e..4925cb75663 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts @@ -538,6 +538,7 @@ function makeReplayServerConfig( const terminalLogsDir = path.join(logsDir, "terminals"); const attachmentsDir = path.join(stateDir, "attachments"); const worktreesDir = path.join(baseDir, "worktrees"); + const handoffsDir = path.join(stateDir, "handoffs"); const providerStatusCacheDir = path.join(baseDir, "caches"); for (const directory of [ stateDir, @@ -546,6 +547,7 @@ function makeReplayServerConfig( terminalLogsDir, attachmentsDir, worktreesDir, + handoffsDir, providerStatusCacheDir, ]) { yield* fs.makeDirectory(directory, { recursive: true }); @@ -582,6 +584,7 @@ function makeReplayServerConfig( settingsPath: path.join(stateDir, "settings.json"), providerStatusCacheDir, worktreesDir, + handoffsDir, attachmentsDir, logsDir, serverLogPath: path.join(logsDir, "server.log"), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 17ec6290fb6..af40da2a57e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -217,6 +217,9 @@ function commandThreadId(command: OrchestrationV2Command): ThreadId { case "runtime-request.respond": case "checkpoint.rollback": case "provider.switch": + case "thread.handoff.depart": + case "thread.handoff.complete": + case "thread.handoff.abort": return command.threadId; case "delegated_task.request": case "delegated_task.wake-policy": @@ -1353,7 +1356,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | "thread.runtime-mode.set" | "thread.interaction-mode.set" | "thread.model-selection.set" - | "provider.switch"; + | "provider.switch" + | "thread.handoff.depart" + | "thread.handoff.complete" + | "thread.handoff.abort"; } >, events: Ref.Ref>, @@ -1430,6 +1436,34 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } + // A thread can only leave once, and only the hop that locked it may report + // its landing or release it. Without these the client could lock a thread + // twice and lose track of which peer owns it. + if (command.type === "thread.handoff.depart" && thread.handoff?.presence === "away") { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} is already handed off to ${thread.handoff.peerEnvironmentId}.`, + }); + } + if (command.type === "thread.handoff.complete" || command.type === "thread.handoff.abort") { + const link = thread.handoff ?? null; + if (link === null || link.handoffId !== command.handoffId) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} has no handoff ${command.handoffId} in flight.`, + }); + } + if (link.presence !== "away") { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} owns handoff ${command.handoffId} and cannot report on it.`, + }); + } + } + const providerSwitchPlan = command.type === "thread.model-selection.set" || command.type === "provider.switch" ? yield* Effect.gen(function* () { @@ -1638,6 +1672,39 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio modelSelection: command.modelSelection, updatedAt: now, }; + case "thread.handoff.depart": + return { + ...thread, + handoff: { + handoffId: command.handoffId, + presence: "away" as const, + peerEnvironmentId: command.peerEnvironmentId, + peerThreadId: null, + peerLabel: command.peerLabel, + previousHandoffId: command.previousHandoffId, + hopCount: command.hopCount, + updatedAt: now, + }, + updatedAt: now, + }; + case "thread.handoff.complete": + return { + ...thread, + ...(thread.handoff == null + ? {} + : { + handoff: { + ...thread.handoff, + peerThreadId: command.peerThreadId, + updatedAt: now, + }, + }), + updatedAt: now, + }; + // Releasing the link is what makes this side live again, so an aborted + // hop leaves no trace to clean up later. + case "thread.handoff.abort": + return { ...thread, handoff: null, updatedAt: now }; } })(); const eventType = (() => { @@ -1675,6 +1742,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return "thread.model-selection-updated" as const; case "provider.switch": return "thread.provider-switched" as const; + case "thread.handoff.depart": + return "thread.handoff-departed" as const; + case "thread.handoff.complete": + return "thread.handoff-arrived" as const; + case "thread.handoff.abort": + return "thread.handoff-failed" as const; } })(); yield* emit( @@ -2778,6 +2851,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) => Effect.gen(function* () { let projection = yield* getProjectionWithPendingEvents(command.threadId, events); + // A departed thread is a read-only record of work that is now running + // somewhere else. Refusing the send here — rather than in the client — is + // what makes "exactly one side is live" an invariant instead of a + // convention, and it is the reason a handoff never has to merge two + // divergent conversations. + const handoff = projection.thread.handoff ?? null; + if (handoff !== null && handoff.presence === "away") { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} is running on ${handoff.peerLabel ?? handoff.peerEnvironmentId}.`, + }); + } if (projection.thread.settledOverride !== null) { const now = yield* DateTime.now; const thread: OrchestrationV2AppThread = { @@ -6700,6 +6786,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.interaction-mode.set": case "thread.model-selection.set": case "provider.switch": + case "thread.handoff.depart": + case "thread.handoff.complete": + case "thread.handoff.abort": yield* dispatchThreadMutation(command, events, effects); break; case "provider-session.detach": diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 84fb7e7a891..a2aaf7e4be3 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -214,6 +214,10 @@ export function applyToProjection( case "thread.interaction-mode-updated": case "thread.model-selection-updated": case "thread.provider-switched": + case "thread.handoff-departed": + case "thread.handoff-arrived": + case "thread.handoff-returned": + case "thread.handoff-failed": return { ...base, thread: event.payload, @@ -916,6 +920,7 @@ export function threadShellFromProjection( pinnedAt: projection.thread.pinnedAt ?? null, lastVisitedAt: projection.thread.lastVisitedAt, titleRegeneration: projection.thread.titleRegeneration ?? null, + handoff: projection.thread.handoff ?? null, deletedAt: projection.thread.deletedAt, }; } @@ -1100,6 +1105,7 @@ function shellFromState(input: { pinnedAt: input.state.thread.pinnedAt ?? null, lastVisitedAt: input.state.thread.lastVisitedAt, titleRegeneration: input.state.thread.titleRegeneration ?? null, + handoff: input.state.thread.handoff ?? null, deletedAt: input.state.thread.deletedAt, }; } @@ -1128,7 +1134,11 @@ export const layer: Layer.Layer = case "thread.runtime-mode-updated": case "thread.interaction-mode-updated": case "thread.model-selection-updated": - case "thread.provider-switched": { + case "thread.provider-switched": + case "thread.handoff-departed": + case "thread.handoff-arrived": + case "thread.handoff-returned": + case "thread.handoff-failed": { const payloadJson = yield* encodeThreadPayload(event.payload); const payload = parseEncodedPayload(payloadJson); yield* sql` diff --git a/apps/server/src/orchestration-v2/ThreadHandoffGit.test.ts b/apps/server/src/orchestration-v2/ThreadHandoffGit.test.ts new file mode 100644 index 00000000000..ffc5a582bcd --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadHandoffGit.test.ts @@ -0,0 +1,82 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + classifyIncomingTip, + handoffPreTagName, + handoffRefName, + handoffStashLabel, + type ClassifyIncomingTipInput, +} from "./ThreadHandoffGit.ts"; + +const input = (overrides: Partial): ClassifyIncomingTipInput => ({ + localTip: "local", + incomingTip: "incoming", + incomingContainsLocal: false, + localContainsIncoming: false, + hasCommonAncestor: true, + ...overrides, +}); + +describe("classifyIncomingTip", () => { + it("advances a branch the receiving repository does not have yet", () => { + assert.strictEqual(classifyIncomingTip(input({ localTip: null })), "advance"); + }); + + it("absorbs an identical tip instead of moving anything", () => { + assert.strictEqual( + classifyIncomingTip(input({ localTip: "same", incomingTip: "same" })), + "absorb", + ); + }); + + it("advances when the incoming commit descends from the local tip", () => { + assert.strictEqual(classifyIncomingTip(input({ incomingContainsLocal: true })), "advance"); + }); + + it("absorbs when the receiving side is already ahead", () => { + assert.strictEqual(classifyIncomingTip(input({ localContainsIncoming: true })), "absorb"); + }); + + it("refuses when both sides moved, so neither tip is a descendant of the other", () => { + assert.strictEqual(classifyIncomingTip(input({})), "diverged"); + }); + + it("refuses unrelated histories rather than treating them as a divergence to rebase", () => { + assert.strictEqual(classifyIncomingTip(input({ hasCommonAncestor: false })), "unrelated"); + }); + + it("treats a fast-forward as advance even when common ancestry was not computed", () => { + assert.strictEqual( + classifyIncomingTip(input({ incomingContainsLocal: true, hasCommonAncestor: false })), + "advance", + ); + }); + + it("never advances on a tip that only the local side contains", () => { + const classification = classifyIncomingTip( + input({ localContainsIncoming: true, hasCommonAncestor: true }), + ); + + assert.notStrictEqual(classification, "advance"); + }); +}); + +describe("handoff ref names", () => { + it("parks refused commits under an environment-scoped namespace", () => { + assert.strictEqual( + handoffRefName("environment-mac", "feat/thread-handoff"), + "refs/handoff/environment-mac/feat/thread-handoff", + ); + }); + + it("names the pre-move tag after the hop that moved the pointer", () => { + assert.strictEqual(handoffPreTagName("handoff-1"), "handoff-pre-handoff-1"); + }); + + it("puts the base sha in the stash label so a later pop is legible", () => { + assert.strictEqual( + handoffStashLabel("handoff-1", "a91f2c4"), + "handoff-overwritten-handoff-1-base-a91f2c4", + ); + }); +}); diff --git a/apps/server/src/orchestration-v2/ThreadHandoffGit.ts b/apps/server/src/orchestration-v2/ThreadHandoffGit.ts new file mode 100644 index 00000000000..32f98bcc23c --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadHandoffGit.ts @@ -0,0 +1,550 @@ +import type { VcsError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { VcsProcess } from "../vcs/VcsProcess.ts"; + +/** + * How an incoming branch tip relates to the one the receiving repository is + * already sitting on. + * + * The whole safety model of a handoff reduces to this classification, and to + * the rule it enforces: a branch tip only ever moves to a descendant of + * itself, on either machine. `advance` is the only outcome that moves a + * pointer forward, `absorb` keeps the local tip and merges the sender's + * working state on top of it, and the remaining two write nothing at all. + */ +export type HandoffTipClassification = "advance" | "absorb" | "diverged" | "unrelated"; + +export interface ClassifyIncomingTipInput { + /** Null when the receiving repository has no such branch yet. */ + readonly localTip: string | null; + readonly incomingTip: string; + /** The incoming commit has the local tip in its ancestry. */ + readonly incomingContainsLocal: boolean; + /** The local tip has the incoming commit in its ancestry. */ + readonly localContainsIncoming: boolean; + /** The two commits share any ancestor at all. */ + readonly hasCommonAncestor: boolean; +} + +export function classifyIncomingTip(input: ClassifyIncomingTipInput): HandoffTipClassification { + if (input.localTip === null) return "advance"; + if (input.localTip === input.incomingTip) return "absorb"; + // Containment is checked before common ancestry: a commit that contains the + // other trivially shares history with it, and answering "unrelated" for a + // fast-forward would refuse a transfer that is safe by construction. + if (input.incomingContainsLocal) return "advance"; + if (input.localContainsIncoming) return "absorb"; + return input.hasCommonAncestor ? "diverged" : "unrelated"; +} + +/** + * Where the sender's commits are parked when a hop is refused. The receiving + * side writes them under its own namespace before deciding, so a refusal still + * leaves the user holding both histories and able to join them by hand. + */ +export function handoffRefName(environmentId: string, branch: string): string { + return `refs/handoff/${environmentId}/${branch}`; +} + +/** Tag written over the old tip before any pointer moves. */ +export function handoffPreTagName(handoffId: string): string { + return `handoff-pre-${handoffId}`; +} + +/** Stash label for a dirty receiving worktree; the base sha makes a later pop legible. */ +export function handoffStashLabel(handoffId: string, baseSha: string): string { + return `handoff-overwritten-${handoffId}-base-${baseSha}`; +} + +interface GitInput { + readonly operation: string; + readonly args: ReadonlyArray; + readonly cwd: string; + readonly stdin?: string; + readonly allowNonZeroExit?: boolean; + readonly maxOutputBytes?: number; + readonly timeoutMs?: number; +} + +const runGit = (process: VcsProcess["Service"], input: GitInput) => + process.run({ + operation: `thread-handoff.${input.operation}`, + command: "git", + args: input.args, + cwd: input.cwd, + ...(input.stdin === undefined ? {} : { stdin: input.stdin }), + ...(input.allowNonZeroExit === undefined ? {} : { allowNonZeroExit: input.allowNonZeroExit }), + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + }); + +export interface ThreadHandoffGitShape { + /** Current tip of `branch`, or null when the branch does not exist. */ + readonly resolveTip: (input: { + readonly cwd: string; + readonly branch: string; + }) => Effect.Effect; + readonly resolveHead: (input: { readonly cwd: string }) => Effect.Effect; + readonly isAncestor: (input: { + readonly cwd: string; + readonly ancestor: string; + readonly descendant: string; + }) => Effect.Effect; + readonly hasCommonAncestor: (input: { + readonly cwd: string; + readonly left: string; + readonly right: string; + }) => Effect.Effect; + readonly hasCommit: (input: { + readonly cwd: string; + readonly commit: string; + }) => Effect.Effect; + /** Tracked changes against HEAD, binary-safe so images and lockfiles survive. */ + readonly trackedPatch: (input: { readonly cwd: string }) => Effect.Effect; + readonly untrackedPaths: (input: { + readonly cwd: string; + }) => Effect.Effect, VcsError>; + readonly dirtyFileCount: (input: { readonly cwd: string }) => Effect.Effect; + /** True when `commit` is reachable from any remote-tracking ref, so rewriting it is off the table. */ + readonly isPublished: (input: { + readonly cwd: string; + readonly commit: string; + }) => Effect.Effect; + readonly tagCommit: (input: { + readonly cwd: string; + readonly tag: string; + readonly commit: string; + }) => Effect.Effect; + readonly stashWorktree: (input: { + readonly cwd: string; + readonly label: string; + }) => Effect.Effect; + readonly writeRef: (input: { + readonly cwd: string; + readonly ref: string; + readonly commit: string; + }) => Effect.Effect; + /** + * Writes a bundle carrying `refs`, excluding anything the receiver already + * has. With no exclusions this is full history, which is what lets a + * repository the target has never seen arrive without a remote, credentials, + * or network. + */ + readonly createBundle: (input: { + readonly cwd: string; + readonly outputPath: string; + readonly refs: ReadonlyArray; + readonly excludeTips: ReadonlyArray; + }) => Effect.Effect; + /** Imports a bundle's objects and parks its refs under `refs/handoff-incoming/`. */ + readonly importBundle: (input: { + readonly cwd: string; + readonly bundlePath: string; + }) => Effect.Effect; + readonly cloneFromBundle: (input: { + readonly bundlePath: string; + readonly targetPath: string; + readonly branch: string | null; + }) => Effect.Effect; + /** + * Applies a tracked-changes patch. `check` runs the same apply as a dry run, + * which is what lets a hop refuse before touching the working tree. + */ + readonly applyPatch: (input: { + readonly cwd: string; + readonly patch: string; + readonly check: boolean; + }) => Effect.Effect; + readonly checkoutBranchAt: (input: { + readonly cwd: string; + readonly branch: string; + readonly commit: string; + }) => Effect.Effect; + readonly resetHardTo: (input: { + readonly cwd: string; + readonly commit: string; + }) => Effect.Effect; + readonly listCheckpointRefs: (input: { + readonly cwd: string; + }) => Effect.Effect, VcsError>; + readonly archivePaths: (input: { + readonly cwd: string; + readonly paths: ReadonlyArray; + readonly outputPath: string; + }) => Effect.Effect; + readonly extractArchive: (input: { + readonly cwd: string; + readonly archivePath: string; + }) => Effect.Effect; + /** Points `origin` at the repository's real remote after a clone from a bundle. */ + readonly setOriginRemote: (input: { + readonly cwd: string; + readonly remoteUrl: string; + }) => Effect.Effect; + /** Path of the worktree that has `branch` checked out, if any. */ + readonly findWorktreeForBranch: (input: { + readonly cwd: string; + readonly branch: string; + }) => Effect.Effect; + /** Adds a detached worktree at `commit`; attaching a branch is a separate, fallible step. */ + readonly addWorktree: (input: { + readonly cwd: string; + readonly path: string; + readonly commit: string; + }) => Effect.Effect; + /** True when `branch` is checked out by the repository or any worktree. */ + readonly isBranchCheckedOut: (input: { + readonly cwd: string; + readonly branch: string; + }) => Effect.Effect; + /** Restores a stash this hop created, used when an apply is rolled back. */ + readonly popStash: (input: { + readonly cwd: string; + readonly stashRef: string; + }) => Effect.Effect; +} + +export class ThreadHandoffGit extends Context.Service()( + "t3/orchestration-v2/ThreadHandoffGit", +) {} + +export const make = Effect.gen(function* () { + const process = yield* VcsProcess; + const git = (input: GitInput) => runGit(process, input); + + const resolveTip: ThreadHandoffGitShape["resolveTip"] = (input) => + git({ + operation: "resolve-tip", + args: ["rev-parse", "--verify", "--quiet", `refs/heads/${input.branch}`], + cwd: input.cwd, + allowNonZeroExit: true, + }).pipe( + Effect.map((output) => { + const tip = output.stdout.trim(); + return output.exitCode === 0 && tip.length > 0 ? tip : null; + }), + ); + + const resolveHead: ThreadHandoffGitShape["resolveHead"] = (input) => + git({ operation: "resolve-head", args: ["rev-parse", "HEAD"], cwd: input.cwd }).pipe( + Effect.map((output) => output.stdout.trim()), + ); + + const isAncestor: ThreadHandoffGitShape["isAncestor"] = (input) => + git({ + operation: "is-ancestor", + args: ["merge-base", "--is-ancestor", input.ancestor, input.descendant], + cwd: input.cwd, + allowNonZeroExit: true, + }).pipe(Effect.map((output) => output.exitCode === 0)); + + const hasCommonAncestor: ThreadHandoffGitShape["hasCommonAncestor"] = (input) => + git({ + operation: "has-common-ancestor", + args: ["merge-base", input.left, input.right], + cwd: input.cwd, + allowNonZeroExit: true, + }).pipe(Effect.map((output) => output.exitCode === 0 && output.stdout.trim().length > 0)); + + const hasCommit: ThreadHandoffGitShape["hasCommit"] = (input) => + git({ + operation: "has-commit", + args: ["cat-file", "-e", `${input.commit}^{commit}`], + cwd: input.cwd, + allowNonZeroExit: true, + }).pipe(Effect.map((output) => output.exitCode === 0)); + + const trackedPatch: ThreadHandoffGitShape["trackedPatch"] = (input) => + git({ + operation: "tracked-patch", + // --binary keeps images and other non-text changes intact; without it a + // dirty png silently arrives as "Binary files differ" and never applies. + args: ["diff", "--binary", "--no-color", "HEAD"], + cwd: input.cwd, + maxOutputBytes: Number.MAX_SAFE_INTEGER, + }).pipe(Effect.map((output) => output.stdout)); + + const untrackedPaths: ThreadHandoffGitShape["untrackedPaths"] = (input) => + git({ + operation: "untracked-paths", + args: ["ls-files", "--others", "--exclude-standard", "-z"], + cwd: input.cwd, + maxOutputBytes: Number.MAX_SAFE_INTEGER, + }).pipe(Effect.map((output) => output.stdout.split("\0").filter((path) => path.length > 0))); + + const dirtyFileCount: ThreadHandoffGitShape["dirtyFileCount"] = (input) => + git({ + operation: "dirty-file-count", + args: ["status", "--porcelain", "-z"], + cwd: input.cwd, + maxOutputBytes: Number.MAX_SAFE_INTEGER, + }).pipe( + Effect.map( + (output) => output.stdout.split("\0").filter((entry) => entry.trim().length > 0).length, + ), + ); + + const isPublished: ThreadHandoffGitShape["isPublished"] = (input) => + git({ + operation: "is-published", + args: ["branch", "--remotes", "--contains", input.commit], + cwd: input.cwd, + allowNonZeroExit: true, + }).pipe(Effect.map((output) => output.exitCode === 0 && output.stdout.trim().length > 0)); + + const tagCommit: ThreadHandoffGitShape["tagCommit"] = (input) => + git({ + operation: "tag-commit", + args: ["tag", "--force", input.tag, input.commit], + cwd: input.cwd, + }).pipe(Effect.asVoid); + + const stashWorktree: ThreadHandoffGitShape["stashWorktree"] = (input) => + Effect.gen(function* () { + const dirty = yield* dirtyFileCount({ cwd: input.cwd }); + if (dirty === 0) return null; + yield* git({ + operation: "stash-worktree", + args: ["stash", "push", "--include-untracked", "--message", input.label], + cwd: input.cwd, + }); + const stash = yield* git({ + operation: "stash-ref", + args: ["rev-parse", "--verify", "--quiet", "refs/stash"], + cwd: input.cwd, + allowNonZeroExit: true, + }); + const ref = stash.stdout.trim(); + return ref.length > 0 ? ref : null; + }); + + const writeRef: ThreadHandoffGitShape["writeRef"] = (input) => + git({ + operation: "write-ref", + args: ["update-ref", input.ref, input.commit], + cwd: input.cwd, + }).pipe(Effect.asVoid); + + const createBundle: ThreadHandoffGitShape["createBundle"] = (input) => + Effect.gen(function* () { + const revListArgs = [ + ...input.refs, + ...(input.excludeTips.length === 0 ? [] : ["--not", ...input.excludeTips]), + ]; + // A branch whose every commit is already on the excluded tips — fully + // pushed, the common case — would make `git bundle` refuse with "empty + // bundle". That is a normal state, not a failure, so it is detected + // first and reported as "nothing to bundle". + const count = yield* git({ + operation: "count-bundle-commits", + args: ["rev-list", "--count", ...revListArgs], + cwd: input.cwd, + timeoutMs: 600_000, + }); + if (count.stdout.trim() === "0") return false; + yield* git({ + operation: "create-bundle", + args: ["bundle", "create", input.outputPath, ...revListArgs], + cwd: input.cwd, + // Bundling can walk a lot of history; the default probe timeout is + // far too short for a real repository. + timeoutMs: 600_000, + }); + return true; + }); + + const importBundle: ThreadHandoffGitShape["importBundle"] = (input) => + git({ + operation: "import-bundle", + // Fetching from the bundle imports objects and names its refs without + // moving any local branch, so classification happens before anything the + // user can see changes. + args: ["fetch", "--no-tags", input.bundlePath, "+refs/*:refs/handoff-incoming/*"], + cwd: input.cwd, + }).pipe(Effect.asVoid); + + const cloneFromBundle: ThreadHandoffGitShape["cloneFromBundle"] = (input) => + git({ + operation: "clone-from-bundle", + args: [ + "clone", + ...(input.branch === null ? [] : ["--branch", input.branch]), + input.bundlePath, + input.targetPath, + ], + cwd: ".", + }).pipe(Effect.asVoid); + + const applyPatch: ThreadHandoffGitShape["applyPatch"] = (input) => + git({ + operation: input.check ? "apply-patch-check" : "apply-patch", + args: ["apply", "--3way", "--binary", ...(input.check ? ["--check"] : []), "-"], + cwd: input.cwd, + stdin: input.patch, + allowNonZeroExit: true, + }).pipe(Effect.map((output) => output.exitCode === 0)); + + const checkoutBranchAt: ThreadHandoffGitShape["checkoutBranchAt"] = (input) => + git({ + operation: "checkout-branch-at", + args: ["checkout", "-B", input.branch, input.commit], + cwd: input.cwd, + }).pipe(Effect.asVoid); + + const resetHardTo: ThreadHandoffGitShape["resetHardTo"] = (input) => + git({ + operation: "reset-hard-to", + args: ["reset", "--hard", input.commit], + cwd: input.cwd, + }).pipe(Effect.asVoid); + + const listCheckpointRefs: ThreadHandoffGitShape["listCheckpointRefs"] = (input) => + git({ + operation: "list-checkpoint-refs", + args: ["for-each-ref", "--format=%(refname)", "refs/t3code"], + cwd: input.cwd, + allowNonZeroExit: true, + maxOutputBytes: Number.MAX_SAFE_INTEGER, + }).pipe( + Effect.map((output) => + output.exitCode === 0 + ? output.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + : [], + ), + ); + + const archivePaths: ThreadHandoffGitShape["archivePaths"] = (input) => + // A null-delimited file list keeps paths containing spaces or newlines + // intact, which is the form `git ls-files -z` already produces. + process + .run({ + operation: "thread-handoff.archive-paths", + command: "tar", + args: ["--null", "--files-from", "-", "-czf", input.outputPath], + cwd: input.cwd, + stdin: input.paths.length === 0 ? "" : `${input.paths.join("\0")}\0`, + }) + .pipe(Effect.asVoid); + + const extractArchive: ThreadHandoffGitShape["extractArchive"] = (input) => + process + .run({ + operation: "thread-handoff.extract-archive", + command: "tar", + args: ["-xzf", input.archivePath], + cwd: input.cwd, + }) + .pipe(Effect.asVoid); + + const worktreeEntries = (input: { readonly cwd: string }) => + git({ + operation: "list-worktrees", + args: ["worktree", "list", "--porcelain"], + cwd: input.cwd, + maxOutputBytes: Number.MAX_SAFE_INTEGER, + }).pipe( + Effect.map((output) => { + const entries: Array<{ path: string; branch: string | null }> = []; + let current: { path: string; branch: string | null } | null = null; + for (const line of output.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + if (current !== null) entries.push(current); + current = { path: line.slice("worktree ".length).trim(), branch: null }; + } else if (line.startsWith("branch ") && current !== null) { + current.branch = line + .slice("branch ".length) + .trim() + .replace(/^refs\/heads\//, ""); + } + } + if (current !== null) entries.push(current); + return entries; + }), + ); + + const findWorktreeForBranch: ThreadHandoffGitShape["findWorktreeForBranch"] = (input) => + worktreeEntries(input).pipe( + Effect.map((entries) => entries.find((entry) => entry.branch === input.branch)?.path ?? null), + ); + + const isBranchCheckedOut: ThreadHandoffGitShape["isBranchCheckedOut"] = (input) => + worktreeEntries(input).pipe( + Effect.map((entries) => entries.some((entry) => entry.branch === input.branch)), + ); + + const addWorktree: ThreadHandoffGitShape["addWorktree"] = (input) => + git({ + operation: "add-worktree", + args: ["worktree", "add", "--detach", input.path, input.commit], + cwd: input.cwd, + timeoutMs: 600_000, + }).pipe(Effect.asVoid); + + const setOriginRemote: ThreadHandoffGitShape["setOriginRemote"] = (input) => + git({ + operation: "set-origin-remote", + // A clone from a bundle has the bundle file as its origin; replace it + // with the real remote so fetch and push work afterwards. + args: ["remote", "set-url", "origin", input.remoteUrl], + cwd: input.cwd, + allowNonZeroExit: true, + }).pipe( + Effect.flatMap((output) => + output.exitCode === 0 + ? Effect.void + : git({ + operation: "add-origin-remote", + args: ["remote", "add", "origin", input.remoteUrl], + cwd: input.cwd, + }).pipe(Effect.asVoid), + ), + ); + + const popStash: ThreadHandoffGitShape["popStash"] = (input) => + git({ + operation: "pop-stash", + args: ["stash", "pop", input.stashRef], + cwd: input.cwd, + allowNonZeroExit: true, + }).pipe(Effect.asVoid); + + return { + resolveTip, + resolveHead, + isAncestor, + hasCommonAncestor, + hasCommit, + trackedPatch, + untrackedPaths, + dirtyFileCount, + isPublished, + tagCommit, + stashWorktree, + writeRef, + createBundle, + importBundle, + cloneFromBundle, + applyPatch, + checkoutBranchAt, + resetHardTo, + listCheckpointRefs, + archivePaths, + extractArchive, + popStash, + setOriginRemote, + findWorktreeForBranch, + addWorktree, + isBranchCheckedOut, + } satisfies ThreadHandoffGitShape; +}); + +export const layer: Layer.Layer = Layer.effect( + ThreadHandoffGit, + make, +); diff --git a/apps/server/src/orchestration-v2/ThreadHandoffService.test.ts b/apps/server/src/orchestration-v2/ThreadHandoffService.test.ts new file mode 100644 index 00000000000..db19664cd33 --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadHandoffService.test.ts @@ -0,0 +1,128 @@ +import { + ORCHESTRATION_V2_HANDOFF_PAYLOAD_MAX_BYTES, + ORCHESTRATION_V2_HANDOFF_PAYLOAD_WARN_BYTES, + type OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; + +import { + classifyPayloadSize, + handoffChunkWindow, + conversationPayload, + partFileName, + sha256, +} from "./ThreadHandoffService.ts"; + +describe("classifyPayloadSize", () => { + it("passes an ordinary payload", () => { + assert.strictEqual(classifyPayloadSize(4 * 1024 * 1024), "ok"); + }); + + it("warns above the warning threshold but still allows the transfer", () => { + assert.strictEqual( + classifyPayloadSize(ORCHESTRATION_V2_HANDOFF_PAYLOAD_WARN_BYTES + 1), + "warn", + ); + }); + + it("treats the warning threshold itself as ordinary", () => { + assert.strictEqual(classifyPayloadSize(ORCHESTRATION_V2_HANDOFF_PAYLOAD_WARN_BYTES), "ok"); + }); + + it("refuses above the hard ceiling", () => { + assert.strictEqual( + classifyPayloadSize(ORCHESTRATION_V2_HANDOFF_PAYLOAD_MAX_BYTES + 1), + "refuse", + ); + }); + + it("treats the ceiling itself as a warning rather than a refusal", () => { + assert.strictEqual(classifyPayloadSize(ORCHESTRATION_V2_HANDOFF_PAYLOAD_MAX_BYTES), "warn"); + }); + + it("passes an empty payload", () => { + assert.strictEqual(classifyPayloadSize(0), "ok"); + }); +}); + +describe("partFileName", () => { + it("names every part kind distinctly so staged parts cannot collide", () => { + const names = [ + partFileName("git-bundle"), + partFileName("tracked-patch"), + partFileName("untracked-tar"), + partFileName("attachments-tar"), + ]; + + assert.strictEqual(new Set(names).size, names.length); + }); +}); + +describe("sha256", () => { + it("addresses identical bytes identically", () => { + assert.strictEqual( + sha256(new TextEncoder().encode("t3")), + sha256(new TextEncoder().encode("t3")), + ); + }); + + it("addresses different bytes differently", () => { + assert.notStrictEqual( + sha256(new TextEncoder().encode("t3")), + sha256(new TextEncoder().encode("t4")), + ); + }); + + it("produces the hex digest length the manifest schema requires", () => { + assert.match(sha256(new Uint8Array([1, 2, 3])), /^[0-9a-f]{64}$/); + }); +}); + +describe("conversationPayload", () => { + const projection = (runCount: number, itemCount: number) => + ({ + turnItems: Array.from({ length: itemCount }, (_, index) => ({ ordinal: index })), + runs: Array.from({ length: runCount }, (_, index) => ({ id: `run-${index}` })), + }) as unknown as OrchestrationV2ThreadProjection; + + it("covers every run the thread has, one ordinal per run", () => { + assert.deepStrictEqual(conversationPayload(projection(3, 5)).coveredRunOrdinals, [1, 2, 3]); + }); + + it("covers nothing for a thread that has never run", () => { + assert.deepStrictEqual(conversationPayload(projection(0, 0)).coveredRunOrdinals, []); + }); + + it("carries every turn item so the far side replays the whole conversation", () => { + assert.strictEqual(conversationPayload(projection(2, 7)).items.length, 7); + }); +}); + +describe("handoffChunkWindow", () => { + const window = (totalBytes: number, offset: number, chunkBytes = 4) => + handoffChunkWindow({ totalBytes, offset, chunkBytes }); + + it("returns the first chunk of a part larger than one chunk", () => { + assert.deepStrictEqual(window(10, 0), { offset: 0, end: 4, complete: false }); + }); + + it("marks the last chunk complete so a caller knows to stop asking", () => { + assert.deepStrictEqual(window(10, 8), { offset: 8, end: 10, complete: true }); + }); + + it("returns the whole part in one chunk when it fits", () => { + assert.deepStrictEqual(window(3, 0), { offset: 0, end: 3, complete: true }); + }); + + it("clamps an offset past the end instead of failing a retry", () => { + assert.deepStrictEqual(window(10, 99), { offset: 10, end: 10, complete: true }); + }); + + it("clamps a negative offset rather than slicing from the end", () => { + assert.deepStrictEqual(window(10, -5), { offset: 0, end: 4, complete: false }); + }); + + it("treats an empty part as immediately complete", () => { + assert.deepStrictEqual(window(0, 0), { offset: 0, end: 0, complete: true }); + }); +}); diff --git a/apps/server/src/orchestration-v2/ThreadHandoffService.ts b/apps/server/src/orchestration-v2/ThreadHandoffService.ts new file mode 100644 index 00000000000..3af2e28677c --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadHandoffService.ts @@ -0,0 +1,1216 @@ +import { + CommandId, + EventId, + ORCHESTRATION_V2_HANDOFF_PAYLOAD_MAX_BYTES, + ORCHESTRATION_V2_HANDOFF_PAYLOAD_WARN_BYTES, + OrchestrationV2HandoffBundleV1, + OrchestrationV2HandoffError, + ProjectId, + ThreadHandoffId, + ThreadId, + type EnvironmentId, + type OrchestrationV2AppThread, + type OrchestrationV2DomainEvent, + type OrchestrationV2HandoffPart, + type OrchestrationV2HandoffPartKind, + type OrchestrationV2ThreadProjection, + type OrchestrationV2TurnItem, + type VcsError, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type { PlatformError } from "effect/PlatformError"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as NodeCrypto from "node:crypto"; + +import { toSafeThreadAttachmentSegment } from "../attachmentStore.ts"; +import { toSafeThreadId as terminalHistoryFilePrefix } from "../terminal/Manager.ts"; +import { ServerConfig } from "../config.ts"; +import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; +import { ProjectService } from "../project/ProjectService.ts"; +import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts"; +import { EventSinkV2 } from "./EventSink.ts"; +import { makeKeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; +import { ProjectionStoreV2 } from "./ProjectionStore.ts"; +import { ProviderAdapterRegistryV2 } from "./ProviderAdapterRegistry.ts"; +import { + classifyIncomingTip, + handoffPreTagName, + handoffRefName, + handoffStashLabel, + ThreadHandoffGit, + type HandoffTipClassification, +} from "./ThreadHandoffGit.ts"; + +const HANDOFF_EVENT_PREFIX = "handoff"; + +/** File a part is staged under. Derived from the kind so both sides agree without negotiating. */ +export function partFileName(kind: OrchestrationV2HandoffPartKind): string { + switch (kind) { + case "git-bundle": + return "objects.bundle"; + case "tracked-patch": + return "tracked.patch"; + case "untracked-tar": + return "untracked.tar.gz"; + case "attachments-tar": + return "attachments.tar.gz"; + case "terminals-tar": + return "terminals.tar.gz"; + } +} + +export function sha256(contents: Uint8Array): string { + return NodeCrypto.createHash("sha256").update(contents).digest("hex"); +} + +/** + * Total payload size against the two ceilings. + * + * Both are checked while preparing, before anything has been sent, so a + * refusal costs nothing on either machine and the warning has somewhere useful + * to appear. + */ +export type HandoffPayloadVerdict = "ok" | "warn" | "refuse"; + +export function classifyPayloadSize(totalBytes: number): HandoffPayloadVerdict { + if (totalBytes > ORCHESTRATION_V2_HANDOFF_PAYLOAD_MAX_BYTES) return "refuse"; + if (totalBytes > ORCHESTRATION_V2_HANDOFF_PAYLOAD_WARN_BYTES) return "warn"; + return "ok"; +} + +/** + * The conversation this hop carries. + * + * Every item goes, along with the run ordinals they cover, which is the same + * pairing `ContextHandoffService` already uses to describe what a receiving + * provider session has and has not seen. + */ +export function conversationPayload(projection: OrchestrationV2ThreadProjection): { + readonly items: ReadonlyArray; + readonly coveredRunOrdinals: ReadonlyArray; +} { + return { + items: projection.turnItems, + coveredRunOrdinals: projection.runs.map((_, index) => index + 1), + }; +} + +/** + * The window of a staged part a read should return. + * + * Clamping the offset rather than rejecting a stale one keeps a resumed + * transfer from failing on a retry that asks for bytes past the end, and + * `complete` is what tells the caller to stop asking rather than making it + * compare offsets itself. + */ +export function handoffChunkWindow(input: { + readonly totalBytes: number; + readonly offset: number; + readonly chunkBytes: number; +}): { readonly offset: number; readonly end: number; readonly complete: boolean } { + const offset = Math.max(0, Math.min(input.offset, input.totalBytes)); + const end = Math.min(offset + input.chunkBytes, input.totalBytes); + return { offset, end, complete: end >= input.totalBytes }; +} + +/** + * Rewrites one carried turn item into this environment's history. + * + * The origin's run, node and provider references do not exist here, so they + * are dropped rather than left dangling; the item id and ordinal survive, + * which is what keeps the conversation ordered and makes a later return trip + * deduplicable instead of doubling every message. + */ +function localizeTurnItem( + item: OrchestrationV2TurnItem, + threadId: ThreadId, +): OrchestrationV2TurnItem { + return { + ...item, + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + } as OrchestrationV2TurnItem; +} + +export interface ThreadHandoffPreparation { + readonly bundle: OrchestrationV2HandoffBundleV1; + readonly totalBytes: number; + readonly verdict: HandoffPayloadVerdict; + readonly dirtyFileCount: number; + readonly untrackedFileCount: number; +} + +export interface ThreadHandoffApplication { + readonly threadId: ThreadId; + readonly projectId: ProjectId; + readonly classification: HandoffTipClassification; + readonly stashRef: string | null; + readonly preTag: string | null; +} + +export interface ThreadHandoffServiceShape { + /** + * Reads the thread and its worktree and stages the parts. Writes nothing the + * user can see and does not lock the thread, so the preflight a user + * approves comes from the same code path the transfer itself uses. + */ + readonly prepare: (input: { + readonly threadId: ThreadId; + readonly peerEnvironmentId: EnvironmentId; + /** The destination's current tip for this branch, so the bundle carries only what it lacks. */ + readonly peerBranchTip: string | null; + /** Bundle the whole history so the destination can clone with no remote. */ + readonly fullHistory: boolean; + readonly previousHandoffId: ThreadHandoffId | null; + readonly hopCount: number; + }) => Effect.Effect; + /** Absolute path a part is staged at, for the transport to read from or write to. */ + readonly partPath: (input: { + readonly handoffId: ThreadHandoffId; + readonly kind: OrchestrationV2HandoffPartKind; + }) => string; + readonly verifyStagedPart: (input: { + readonly handoffId: ThreadHandoffId; + readonly part: OrchestrationV2HandoffPart; + }) => Effect.Effect; + /** + * Applies a staged bundle, creating the thread or — when the hop returns to + * a thread this environment already owns — continuing it. + */ + readonly receive: (input: { + readonly bundle: OrchestrationV2HandoffBundleV1; + /** Null when the repository must first be cloned from the bundle. */ + readonly projectId: ProjectId | null; + readonly cloneWorkspaceRoot: string | null; + readonly returningThreadId: ThreadId | null; + }) => Effect.Effect; + /** + * Fails hops that were still applying when the server stopped. That is the + * only state in which a repository can have been written to, so it is the + * only state that needs recovering. + */ + readonly recoverInterrupted: () => Effect.Effect; +} + +export class ThreadHandoffService extends Context.Service< + ThreadHandoffService, + ThreadHandoffServiceShape +>()("t3/orchestration-v2/ThreadHandoffService") {} + +const handoffError = (input: { + readonly reason: OrchestrationV2HandoffError["reason"]; + readonly message: string; + readonly handoffId?: ThreadHandoffId; + readonly cause?: unknown; +}) => + new OrchestrationV2HandoffError({ + reason: input.reason, + message: input.message, + ...(input.handoffId === undefined ? {} : { handoffId: input.handoffId }), + ...(input.cause === undefined ? {} : { cause: input.cause }), + }); + +const isHandoffError = Schema.is(OrchestrationV2HandoffError); + +/** + * Turns a dependency's failure into a handoff failure, leaving one that is + * already a handoff failure alone so the specific reason a step chose is not + * flattened into the generic one of its caller. + */ +const asHandoffError = (reason: OrchestrationV2HandoffError["reason"], message: string) => + Effect.mapError( + (cause: unknown): OrchestrationV2HandoffError => + isHandoffError(cause) ? cause : handoffError({ reason, message, cause }), + ); + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig; + const eventSink = yield* EventSinkV2; + const projectionStore = yield* ProjectionStoreV2; + const git = yield* ThreadHandoffGit; + const projects = yield* ProjectService; + const repositoryIdentity = yield* RepositoryIdentityResolver; + const environment = yield* ServerEnvironment; + const providerAdapters = yield* ProviderAdapterRegistryV2; + const encodeManifest = Schema.encodeEffect(Schema.fromJsonString(OrchestrationV2HandoffBundleV1)); + + // One hop at a time per thread: prepare reads a working tree a receive may be + // rewriting, and two concurrent hops would each believe they own the thread. + const serialize = yield* makeKeyedSerialExecutor(); + + const handoffDir = (handoffId: ThreadHandoffId) => path.join(config.handoffsDir, handoffId); + + const partPath: ThreadHandoffServiceShape["partPath"] = (input) => + path.join(handoffDir(input.handoffId), partFileName(input.kind)); + + const stagePart = (input: { + readonly handoffId: ThreadHandoffId; + readonly kind: OrchestrationV2HandoffPartKind; + readonly write: (targetPath: string) => Effect.Effect; + }) => + Effect.gen(function* () { + const target = partPath({ handoffId: input.handoffId, kind: input.kind }); + yield* fs.makeDirectory(handoffDir(input.handoffId), { recursive: true }); + yield* input.write(target); + const exists = yield* fs.exists(target); + if (!exists) return null; + const contents = yield* fs.readFile(target); + // An empty part is the absence of a payload, not a payload of zero bytes: + // dropping it keeps the manifest an accurate list of what has to move. + if (contents.length === 0) { + yield* fs.remove(target).pipe(Effect.ignore); + return null; + } + return { + kind: input.kind, + digest: sha256(contents), + byteLength: contents.length, + } satisfies OrchestrationV2HandoffPart; + }).pipe(asHandoffError("store_failed", `Could not stage the ${input.kind} part.`)); + + const verifyStagedPart: ThreadHandoffServiceShape["verifyStagedPart"] = (input) => + Effect.gen(function* () { + const target = partPath({ handoffId: input.handoffId, kind: input.part.kind }); + const exists = yield* fs + .exists(target) + .pipe(asHandoffError("store_failed", "Could not read a staged handoff part.")); + if (!exists) { + return yield* handoffError({ + reason: "part_missing", + message: `Handoff part ${input.part.kind} was never uploaded.`, + handoffId: input.handoffId, + }); + } + const contents = yield* fs + .readFile(target) + .pipe(asHandoffError("store_failed", "Could not read a staged handoff part.")); + 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, + }); + } + }); + + const recordHop = (input: { + readonly handoffId: ThreadHandoffId; + readonly threadId: ThreadId; + readonly peerEnvironmentId: EnvironmentId; + readonly peerThreadId: ThreadId | null; + readonly previousHandoffId: ThreadHandoffId | null; + readonly hopCount: number; + readonly state: string; + readonly bundle: OrchestrationV2HandoffBundleV1; + }) => + Effect.gen(function* () { + const now = DateTime.formatIso(yield* DateTime.now); + const manifestJson = yield* encodeManifest(input.bundle); + yield* sql` + INSERT INTO orchestration_v2_thread_handoffs ( + handoff_id, + thread_id, + peer_environment_id, + peer_thread_id, + previous_handoff_id, + hop_count, + state, + manifest_json, + created_at, + updated_at + ) VALUES ( + ${input.handoffId}, + ${input.threadId}, + ${input.peerEnvironmentId}, + ${input.peerThreadId}, + ${input.previousHandoffId}, + ${input.hopCount}, + ${input.state}, + ${manifestJson}, + ${now}, + ${now} + ) + ON CONFLICT(handoff_id) DO UPDATE SET + state = excluded.state, + peer_thread_id = excluded.peer_thread_id, + manifest_json = excluded.manifest_json, + updated_at = excluded.updated_at + `; + }).pipe(asHandoffError("store_failed", "Could not record the handoff.")); + + const markHop = (input: { + readonly handoffId: ThreadHandoffId; + readonly state: string; + readonly lastError: string | null; + readonly appliedHeadSha?: string | null; + readonly stashRef?: string | null; + readonly preTag?: string | null; + }) => + Effect.gen(function* () { + const now = DateTime.formatIso(yield* DateTime.now); + yield* sql` + UPDATE orchestration_v2_thread_handoffs + SET + state = ${input.state}, + last_error = ${input.lastError}, + applied_head_sha = COALESCE(${input.appliedHeadSha ?? null}, applied_head_sha), + stash_ref = COALESCE(${input.stashRef ?? null}, stash_ref), + pre_tag = COALESCE(${input.preTag ?? null}, pre_tag), + updated_at = ${now} + WHERE handoff_id = ${input.handoffId} + `; + }).pipe(asHandoffError("store_failed", "Could not update the handoff.")); + + const workspaceRootFor = (projectId: ProjectId) => + projects.getById(projectId).pipe( + asHandoffError("project_missing", `Project ${projectId} could not be read.`), + Effect.flatMap((project) => + Option.isNone(project) + ? handoffError({ + reason: "project_missing", + message: `Project ${projectId} is not on this environment.`, + }) + : Effect.succeed(project.value.workspaceRoot), + ), + ); + + const repositoryIdentityFor = (cwd: string) => + repositoryIdentity.resolve(cwd).pipe( + Effect.flatMap((identity) => + identity === null + ? handoffError({ + reason: "repository_mismatch", + message: + "This thread's workspace has no git remote, so the other machine cannot recognise the repository.", + }) + : Effect.succeed(identity), + ), + ); + + const driverKindFor = (thread: OrchestrationV2AppThread) => + providerAdapters.get(thread.providerInstanceId).pipe( + Effect.map((adapter) => adapter.driver), + asHandoffError( + "environment_unsupported", + `Provider ${thread.providerInstanceId} is not configured here.`, + ), + ); + + const threadCwd = (thread: OrchestrationV2AppThread) => + thread.worktreePath === null + ? workspaceRootFor(thread.projectId) + : Effect.succeed(thread.worktreePath); + + const prepare: ThreadHandoffServiceShape["prepare"] = (input) => + serialize.withLock( + input.threadId, + Effect.gen(function* () { + const projection = yield* projectionStore + .getThreadProjection(input.threadId) + .pipe(asHandoffError("thread_missing", `Thread ${input.threadId} could not be read.`)); + const thread = projection.thread; + // Only an away thread refuses: a thread that arrived here is live and + // free to move again — onward, or back where it came from. + if (thread.handoff?.presence === "away") { + return yield* handoffError({ + reason: "thread_already_away", + message: `Thread ${input.threadId} is already handed off.`, + }); + } + // A running agent is writing the worktree this hop is about to + // snapshot; a bundle cut mid-write would carry a half-finished state + // to the other machine. Refuse with the action the user can take. + const busy = projection.runs.some((run) => + ["preparing", "queued", "starting", "running", "waiting"].includes(run.status), + ); + if (busy) { + return yield* handoffError({ + reason: "thread_busy", + message: + "The agent is still working in this thread. Interrupt it or let it finish, then send.", + }); + } + + const cwd = yield* threadCwd(thread); + const handoffId = ThreadHandoffId.make(NodeCrypto.randomUUID()); + const branch = thread.branch; + const headSha = yield* git + .resolveHead({ cwd }) + .pipe(asHandoffError("apply_failed", "Could not read the thread's HEAD.")); + // Checkpoint refs are hidden git refs, so bundling them alongside the + // commits carries the whole checkpoint timeline with no payload of its + // own, and revert keeps working on the far side. + const checkpointRefs = yield* git + .listCheckpointRefs({ cwd }) + .pipe(asHandoffError("apply_failed", "Could not list checkpoint refs.")); + const patch = yield* git + .trackedPatch({ cwd }) + .pipe(asHandoffError("apply_failed", "Could not read the thread's tracked changes.")); + const untracked = yield* git + .untrackedPaths({ cwd }) + .pipe(asHandoffError("apply_failed", "Could not list untracked files.")); + const dirtyFileCount = yield* git + .dirtyFileCount({ cwd }) + .pipe(asHandoffError("apply_failed", "Could not count changed files.")); + + const parts: Array = []; + const bundlePart = yield* stagePart({ + handoffId, + kind: "git-bundle", + write: (target) => + git.createBundle({ + cwd, + outputPath: target, + refs: [...(branch === null ? ["HEAD"] : [`refs/heads/${branch}`]), ...checkpointRefs], + // With no known peer tip, cut against this repository's + // remote-tracking refs: both sides clone the same remote, so + // anything a remote already has is not worth shipping. Without + // this a first hop bundles the repository's entire history. + excludeTips: input.fullHistory + ? [] + : input.peerBranchTip === null + ? ["--remotes"] + : [input.peerBranchTip], + }), + }); + if (bundlePart !== null) parts.push(bundlePart); + + const patchPart = yield* stagePart({ + handoffId, + kind: "tracked-patch", + write: (target) => fs.writeFileString(target, patch), + }); + if (patchPart !== null) parts.push(patchPart); + + if (untracked.length > 0) { + const untrackedPart = yield* stagePart({ + handoffId, + kind: "untracked-tar", + write: (target) => git.archivePaths({ cwd, paths: untracked, outputPath: target }), + }); + if (untrackedPart !== null) parts.push(untrackedPart); + } + + // Attachments are flat files named by a thread-derived prefix. They + // travel under their original names, which is what the carried turn + // items reference, so nothing has to be rewritten on arrival. + const threadSegment = toSafeThreadAttachmentSegment(thread.id); + const attachmentFiles = + threadSegment === null + ? [] + : yield* fs.readDirectory(config.attachmentsDir).pipe( + Effect.map((entries) => + entries.filter((entry) => entry.startsWith(`${threadSegment}-`)), + ), + Effect.orElseSucceed(() => [] as ReadonlyArray), + ); + if (attachmentFiles.length > 0) { + const attachmentsPart = yield* stagePart({ + handoffId, + kind: "attachments-tar", + write: (target) => + git.archivePaths({ + cwd: config.attachmentsDir, + paths: attachmentFiles, + outputPath: target, + }), + }); + if (attachmentsPart !== null) parts.push(attachmentsPart); + } + + // Terminal scrollback lives as flat history files named by a + // thread-derived prefix. The PTY itself cannot travel; the history the + // user reads can, and the manager restores a session from it on first + // open exactly as it does after a restart. + const terminalPrefix = terminalHistoryFilePrefix(thread.id); + const terminalFiles = yield* fs.readDirectory(config.terminalLogsDir).pipe( + Effect.map((entries) => + entries.filter( + (entry) => + entry === `${terminalPrefix}.log` || entry.startsWith(`${terminalPrefix}_`), + ), + ), + Effect.orElseSucceed(() => [] as ReadonlyArray), + ); + if (terminalFiles.length > 0) { + const terminalsPart = yield* stagePart({ + handoffId, + kind: "terminals-tar", + write: (target) => + git.archivePaths({ + cwd: config.terminalLogsDir, + paths: terminalFiles, + outputPath: target, + }), + }); + if (terminalsPart !== null) parts.push(terminalsPart); + } + + const totalBytes = parts.reduce((sum, part) => sum + part.byteLength, 0); + const verdict = classifyPayloadSize(totalBytes); + if (verdict === "refuse") { + yield* fs.remove(handoffDir(handoffId), { recursive: true }).pipe(Effect.ignore); + return yield* handoffError({ + reason: "payload_too_large", + message: `This thread's working state is ${Math.round( + totalBytes / (1024 * 1024), + )} MB, over the ${Math.round( + ORCHESTRATION_V2_HANDOFF_PAYLOAD_MAX_BYTES / (1024 * 1024), + )} MB limit. Ignore or clean build output and try again.`, + handoffId, + }); + } + + const environmentId = yield* environment.getEnvironmentId; + const descriptor = yield* environment.getDescriptor; + const bundle: OrchestrationV2HandoffBundleV1 = { + version: 1, + handoffId, + origin: { + environmentId, + threadId: thread.id, + serverVersion: descriptor.serverVersion, + label: descriptor.label, + }, + repository: yield* repositoryIdentityFor(cwd), + workspace: { + branch, + headSha, + strategy: + thread.worktreePath === null + ? { type: "root", ...(branch === null ? {} : { branch }) } + : { + type: "existing_worktree", + worktreePath: thread.worktreePath, + ...(branch === null ? {} : { branch }), + }, + }, + conversation: conversationPayload(projection), + provider: { + driverKind: yield* driverKindFor(thread), + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + }, + thread: { title: thread.title }, + terminals: [], + lineage: { + previousHandoffId: input.previousHandoffId, + hopCount: input.hopCount, + }, + parts, + }; + + yield* recordHop({ + handoffId, + threadId: thread.id, + peerEnvironmentId: input.peerEnvironmentId, + peerThreadId: null, + previousHandoffId: input.previousHandoffId, + hopCount: input.hopCount, + state: "preparing", + bundle, + }); + + return { + bundle, + totalBytes, + verdict, + dirtyFileCount, + untrackedFileCount: untracked.length, + } satisfies ThreadHandoffPreparation; + }), + ); + + const rollback = (input: { + readonly cwd: string; + readonly preTag: string | null; + readonly stashRef: string | null; + }) => + Effect.gen(function* () { + if (input.preTag !== null) { + yield* git.resetHardTo({ cwd: input.cwd, commit: input.preTag }).pipe(Effect.ignore); + } + if (input.stashRef !== null) { + yield* git.popStash({ cwd: input.cwd, stashRef: input.stashRef }).pipe(Effect.ignore); + } + }); + + /** + * Replays the carried conversation as this environment's own history. A + * returning hop continues the thread that is already here; a first arrival + * creates one. Either way the handoff link is what marks this side live. + */ + const writeArrival = (input: { + readonly bundle: OrchestrationV2HandoffBundleV1; + readonly threadId: ThreadId; + readonly projectId: ProjectId; + readonly existing: OrchestrationV2AppThread | null; + readonly existingItems: ReadonlyArray; + readonly worktreePath: string | null; + }) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const { bundle } = input; + const base: OrchestrationV2AppThread = input.existing ?? { + id: input.threadId, + projectId: input.projectId, + title: bundle.thread.title, + providerInstanceId: bundle.provider.modelSelection.instanceId, + modelSelection: bundle.provider.modelSelection, + runtimeMode: bundle.provider.runtimeMode, + interactionMode: bundle.provider.interactionMode, + branch: bundle.workspace.branch, + worktreePath: input.worktreePath, + activeProviderThreadId: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: input.threadId, + }, + forkedFrom: null, + createdBy: "user", + creationSource: "server", + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; + const returning = input.existing !== null; + const thread: OrchestrationV2AppThread = { + ...base, + // The carried title wins: a rename made on either side reaches the + // pair at the next hop instead of leaving a stale name behind. + title: bundle.thread.title, + // Arrival revives: a copy the user archived (or one archived by an + // older build) returns to the sidebar the moment work lands in it. + archivedAt: returning ? null : base.archivedAt, + // Every arrival keeps a "here" link — it is provenance, not a lock. + // Only "away" restricts anything, so keeping the link through round + // trips is what preserves "moved from X" and the pull-back verbs + // after any number of hops. + handoff: { + handoffId: bundle.handoffId, + presence: "here", + peerEnvironmentId: bundle.origin.environmentId, + peerThreadId: bundle.origin.threadId, + peerLabel: bundle.origin.label ?? null, + previousHandoffId: bundle.lineage.previousHandoffId, + hopCount: bundle.lineage.hopCount, + updatedAt: now, + }, + updatedAt: now, + }; + // The carried conversation replays as this environment's own events — + // the same message/turn-item pair the v1 importer writes. A returning + // hop skips every item this side already has, so a round trip adds only + // what happened away instead of doubling the history. + const existingItemIds = new Set( + input.existingItems.map((existingItem) => String(existingItem.id)), + ); + const conversationEvents: Array = []; + for (const item of bundle.conversation.items) { + if (existingItemIds.has(String(item.id))) continue; + const localized = localizeTurnItem(item, thread.id); + if ( + (localized.type === "user_message" || localized.type === "assistant_message") && + localized.messageId !== null + ) { + conversationEvents.push({ + id: EventId.make(`${HANDOFF_EVENT_PREFIX}:${bundle.handoffId}:message:${item.id}`), + type: "message.updated", + threadId: thread.id, + occurredAt: now, + payload: { + createdBy: localized.type === "user_message" ? "user" : "agent", + creationSource: "server", + id: localized.messageId, + threadId: thread.id, + runId: null, + nodeId: null, + role: localized.type === "user_message" ? "user" : "assistant", + text: localized.text ?? "", + attachments: localized.type === "user_message" ? localized.attachments : [], + streaming: false, + createdAt: localized.startedAt ?? now, + updatedAt: localized.updatedAt ?? now, + }, + }); + } + conversationEvents.push({ + id: EventId.make(`${HANDOFF_EVENT_PREFIX}:${bundle.handoffId}:item:${item.id}`), + type: "turn-item.updated", + threadId: thread.id, + occurredAt: now, + payload: localized, + }); + } + + const events: Array = [ + { + id: EventId.make(`${HANDOFF_EVENT_PREFIX}:${bundle.handoffId}:thread`), + type: returning ? "thread.handoff-returned" : "thread.created", + threadId: thread.id, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: thread, + }, + ...conversationEvents, + ...(returning + ? [] + : [ + { + id: EventId.make(`${HANDOFF_EVENT_PREFIX}:${bundle.handoffId}:arrived`), + type: "thread.handoff-arrived" as const, + threadId: thread.id, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: thread, + }, + ]), + ]; + // Through the sink, not the raw event store: the sink applies the + // projections and broadcasts the shell delta, which is what makes the + // arrived thread appear on every connected client immediately instead + // of after the next projection rebuild. Batched, because a long + // conversation is hundreds of events and one giant write would hold the + // sink's serial lane for the whole payload. + for (let index = 0; index < events.length; index += 100) { + yield* eventSink.write({ events: events.slice(index, index + 100) }); + } + return thread; + }).pipe( + asHandoffError( + "store_failed", + "Could not write the arrival into this environment's history.", + ), + ); + + const receive: ThreadHandoffServiceShape["receive"] = (input) => + serialize.withLock( + input.bundle.handoffId, + Effect.gen(function* () { + const { bundle } = input; + yield* Effect.forEach(bundle.parts, (part) => + verifyStagedPart({ handoffId: bundle.handoffId, part }), + ); + + const branch = bundle.workspace.branch; + const incomingTip = bundle.workspace.headSha; + const bundlePart = bundle.parts.find((part) => part.kind === "git-bundle") ?? null; + + // No project yet: the bundle carries the whole history, so clone from + // it, point origin at the real remote, and register the project — no + // network or credentials needed on this machine. + let projectId = input.projectId; + let cloned = false; + if (projectId === null) { + if (input.cloneWorkspaceRoot === null || bundlePart === null) { + return yield* handoffError({ + reason: "project_missing", + message: + "This environment does not have the repository, and the transfer did not carry enough history to clone it.", + handoffId: bundle.handoffId, + }); + } + yield* markHop({ handoffId: bundle.handoffId, state: "applying", lastError: null }); + yield* git + .cloneFromBundle({ + bundlePath: partPath({ handoffId: bundle.handoffId, kind: "git-bundle" }), + targetPath: input.cloneWorkspaceRoot, + branch, + }) + .pipe( + asHandoffError("apply_failed", "Could not clone the repository from the bundle."), + ); + yield* git + .setOriginRemote({ + cwd: input.cloneWorkspaceRoot, + remoteUrl: bundle.repository.locator.remoteUrl, + }) + .pipe(Effect.ignore); + projectId = ProjectId.make(`project:${NodeCrypto.randomUUID()}`); + yield* projects + .create({ + commandId: CommandId.make(`handoff:${bundle.handoffId}:project`), + projectId, + title: bundle.repository.displayName ?? bundle.repository.name ?? "Imported project", + workspaceRoot: input.cloneWorkspaceRoot, + }) + .pipe(asHandoffError("store_failed", "Could not register the cloned project.")); + cloned = true; + } + + const cwd = + cloned && input.cloneWorkspaceRoot !== null + ? input.cloneWorkspaceRoot + : yield* workspaceRootFor(projectId); + // A fresh clone already sits at the incoming tip; worktree handling is + // for repositories that existed here before the hop. + const wantsWorktree = !cloned && bundle.workspace.strategy.type !== "root"; + + // A hop between the same two threads is a revival, not a new copy — + // even when the client no longer knows the pair. The lineage table + // remembers every hop this environment took part in, so an incoming + // origin thread that matches a prior peer lands back in that thread. + let returningThreadId = input.returningThreadId; + if (returningThreadId === null) { + const prior = yield* sql<{ readonly thread_id: string }>` + SELECT thread_id FROM orchestration_v2_thread_handoffs + WHERE peer_thread_id = ${bundle.origin.threadId} + ORDER BY updated_at DESC + LIMIT 1 + `.pipe(Effect.orElseSucceed(() => [])); + const priorThreadId = prior[0]?.thread_id; + if (priorThreadId !== undefined) { + const revivable = yield* projectionStore + .getThreadProjection(ThreadId.make(priorThreadId)) + .pipe( + Effect.map((projection) => projection.thread.deletedAt === null), + Effect.orElseSucceed(() => false), + ); + if (revivable) { + returningThreadId = ThreadId.make(priorThreadId); + } + } + } + + const existingProjection = + returningThreadId === null + ? null + : yield* projectionStore + .getThreadProjection(returningThreadId) + .pipe( + asHandoffError( + "thread_missing", + `Thread ${returningThreadId} could not be read.`, + ), + ); + const existing = existingProjection?.thread ?? null; + + let classification: HandoffTipClassification = "advance"; + let preTag: string | null = null; + let stashRef: string | null = null; + + if (bundlePart !== null && !cloned) { + const bundlePath = partPath({ handoffId: bundle.handoffId, kind: "git-bundle" }); + yield* git + .importBundle({ cwd, bundlePath }) + .pipe(asHandoffError("apply_failed", "Could not import the incoming git objects.")); + } + const incomingCommitKnown = yield* git + .hasCommit({ cwd, commit: incomingTip }) + .pipe(asHandoffError("apply_failed", "Could not inspect the incoming commit.")); + if (!incomingCommitKnown) { + yield* markHop({ + handoffId: bundle.handoffId, + state: "failed", + lastError: "incoming commit missing after import", + }); + return yield* handoffError({ + reason: "apply_failed", + message: + "The incoming commit is not available here even after importing the bundle. Fetch the repository on this machine and try again.", + handoffId: bundle.handoffId, + }); + } + { + const localTip = + branch === null + ? null + : yield* git + .resolveTip({ cwd, branch }) + .pipe(asHandoffError("apply_failed", "Could not read the local branch tip.")); + classification = classifyIncomingTip({ + localTip, + incomingTip, + incomingContainsLocal: + localTip !== null && + (yield* git + .isAncestor({ cwd, ancestor: localTip, descendant: incomingTip }) + .pipe(asHandoffError("apply_failed", "Could not compare the branch tips."))), + localContainsIncoming: + localTip !== null && + (yield* git + .isAncestor({ cwd, ancestor: incomingTip, descendant: localTip }) + .pipe(asHandoffError("apply_failed", "Could not compare the branch tips."))), + hasCommonAncestor: + localTip !== null && + (yield* git + .hasCommonAncestor({ cwd, left: localTip, right: incomingTip }) + .pipe(asHandoffError("apply_failed", "Could not compare the branch tips."))), + }); + + if (classification === "diverged" || classification === "unrelated") { + // Park the sender's commits and stop. Nothing on either machine has + // moved, and the user is left holding both histories. + const parkedRef = handoffRefName(bundle.origin.environmentId, branch ?? "HEAD"); + yield* git + .writeRef({ cwd, ref: parkedRef, commit: incomingTip }) + .pipe(asHandoffError("apply_failed", "Could not park the incoming commits.")); + yield* markHop({ + handoffId: bundle.handoffId, + state: "failed", + lastError: `branch ${classification}`, + }); + return yield* handoffError({ + reason: "workspace_diverged", + message: `The branch moved on both machines, so nothing here was changed. The incoming commits are at ${parkedRef}.`, + handoffId: bundle.handoffId, + }); + } + + yield* markHop({ handoffId: bundle.handoffId, state: "applying", lastError: null }); + + if (localTip !== null) { + preTag = handoffPreTagName(bundle.handoffId); + yield* git + .tagCommit({ cwd, tag: preTag, commit: localTip }) + .pipe(asHandoffError("apply_failed", "Could not tag the current tip.")); + stashRef = yield* git + .stashWorktree({ cwd, label: handoffStashLabel(bundle.handoffId, localTip) }) + .pipe(asHandoffError("apply_failed", "Could not set the local changes aside.")); + } + + if (classification === "advance" && branch !== null && !wantsWorktree) { + yield* git + .checkoutBranchAt({ cwd, branch, commit: incomingTip }) + .pipe( + asHandoffError("apply_failed", "Could not move the branch to the incoming commit."), + ); + } + } + + // A thread that lived in a worktree lands in one here too: reuse the + // worktree that already has the branch checked out, otherwise add a + // fresh one at the incoming commit. The branch attaches only when no + // other checkout holds it — git forbids two checkouts of one branch, + // and a detached worktree at the right commit still runs the thread. + let applyCwd = cwd; + if (wantsWorktree && branch !== null) { + const existingWorktree = yield* git + .findWorktreeForBranch({ cwd, branch }) + .pipe(asHandoffError("apply_failed", "Could not inspect the repository's worktrees.")); + if (existingWorktree !== null && existingWorktree !== cwd) { + applyCwd = existingWorktree; + stashRef = + stashRef ?? + (yield* git + .stashWorktree({ + cwd: applyCwd, + label: handoffStashLabel(bundle.handoffId, incomingTip), + }) + .pipe( + asHandoffError("apply_failed", "Could not set the worktree's changes aside."), + )); + if (classification === "advance") { + yield* git + .resetHardTo({ cwd: applyCwd, commit: incomingTip }) + .pipe(asHandoffError("apply_failed", "Could not advance the worktree.")); + } + } else if (existingWorktree === null) { + const worktreePath = path.join( + config.worktreesDir, + `handoff-${bundle.handoffId.slice(0, 8)}`, + ); + yield* git + .addWorktree({ cwd, path: worktreePath, commit: incomingTip }) + .pipe(asHandoffError("apply_failed", "Could not create a worktree for the thread.")); + applyCwd = worktreePath; + const branchTaken = yield* git + .isBranchCheckedOut({ cwd, branch }) + .pipe( + asHandoffError("apply_failed", "Could not inspect the repository's worktrees."), + ); + if (!branchTaken) { + yield* git + .checkoutBranchAt({ cwd: applyCwd, branch, commit: incomingTip }) + .pipe( + asHandoffError("apply_failed", "Could not attach the branch to the worktree."), + ); + } + } + } + + const patchPart = bundle.parts.find((part) => part.kind === "tracked-patch") ?? null; + if (patchPart !== null) { + const patch = yield* fs + .readFileString(partPath({ handoffId: bundle.handoffId, kind: "tracked-patch" })) + .pipe(asHandoffError("store_failed", "Could not read the staged patch.")); + // Dry run first: a patch that will not apply must leave the working + // tree exactly as it was, not half-written. + const applies = yield* git + .applyPatch({ cwd: applyCwd, patch, check: true }) + .pipe(asHandoffError("apply_failed", "Could not test the incoming changes.")); + if (!applies) { + yield* rollback({ cwd: applyCwd, preTag, stashRef }); + yield* markHop({ + handoffId: bundle.handoffId, + state: "failed", + lastError: "patch did not apply", + }); + return yield* handoffError({ + reason: "apply_failed", + message: + "The incoming changes could not be applied here, so this repository was put back exactly as it was.", + handoffId: bundle.handoffId, + }); + } + yield* git + .applyPatch({ cwd: applyCwd, patch, check: false }) + .pipe(asHandoffError("apply_failed", "Could not apply the incoming changes.")); + } + + if (bundle.parts.some((part) => part.kind === "untracked-tar")) { + yield* git + .extractArchive({ + cwd: applyCwd, + archivePath: partPath({ handoffId: bundle.handoffId, kind: "untracked-tar" }), + }) + .pipe(asHandoffError("apply_failed", "Could not restore the untracked files.")); + } + + if (bundle.parts.some((part) => part.kind === "attachments-tar")) { + yield* git + .extractArchive({ + cwd: config.attachmentsDir, + archivePath: partPath({ handoffId: bundle.handoffId, kind: "attachments-tar" }), + }) + .pipe(asHandoffError("apply_failed", "Could not restore the thread's attachments.")); + } + + const threadId = returningThreadId ?? ThreadId.make(`thread:${NodeCrypto.randomUUID()}`); + + if (bundle.parts.some((part) => part.kind === "terminals-tar")) { + yield* git + .extractArchive({ + cwd: config.terminalLogsDir, + archivePath: partPath({ handoffId: bundle.handoffId, kind: "terminals-tar" }), + }) + .pipe(asHandoffError("apply_failed", "Could not restore the thread's terminals.")); + // History files are named by thread id, and the thread has a new id + // here; rename the extracted files so the manager finds them. + const originPrefix = terminalHistoryFilePrefix(bundle.origin.threadId); + const localPrefix = terminalHistoryFilePrefix(threadId); + if (originPrefix !== localPrefix) { + const extracted = yield* fs.readDirectory(config.terminalLogsDir).pipe( + Effect.map((entries) => + entries.filter( + (entry) => + entry === `${originPrefix}.log` || entry.startsWith(`${originPrefix}_`), + ), + ), + Effect.orElseSucceed(() => [] as ReadonlyArray), + ); + yield* Effect.forEach( + extracted, + (entry) => + fs + .rename( + path.join(config.terminalLogsDir, entry), + path.join( + config.terminalLogsDir, + `${localPrefix}${entry.slice(originPrefix.length)}`, + ), + ) + .pipe(Effect.ignore), + { discard: true }, + ); + } + } + yield* writeArrival({ + bundle, + threadId, + projectId, + existing, + existingItems: existingProjection?.turnItems ?? [], + worktreePath: wantsWorktree && applyCwd !== cwd ? applyCwd : null, + }); + yield* recordHop({ + handoffId: bundle.handoffId, + threadId, + peerEnvironmentId: bundle.origin.environmentId, + peerThreadId: bundle.origin.threadId, + previousHandoffId: bundle.lineage.previousHandoffId, + hopCount: bundle.lineage.hopCount, + state: "arrived", + bundle, + }); + yield* markHop({ + handoffId: bundle.handoffId, + state: "arrived", + lastError: null, + appliedHeadSha: incomingTip, + stashRef, + preTag, + }); + + return { + threadId, + projectId, + classification, + stashRef, + preTag, + } satisfies ThreadHandoffApplication; + }), + ); + + const recoverInterrupted: ThreadHandoffServiceShape["recoverInterrupted"] = () => + Effect.gen(function* () { + const rows = yield* sql<{ readonly handoff_id: string }>` + SELECT handoff_id FROM orchestration_v2_thread_handoffs WHERE state = 'applying' + `; + yield* Effect.forEach( + rows, + (row) => + markHop({ + handoffId: ThreadHandoffId.make(row.handoff_id), + state: "failed", + lastError: "server stopped while applying", + }).pipe(Effect.ignore), + { discard: true }, + ); + return rows.length; + }).pipe(Effect.orElseSucceed(() => 0)); + + return { + prepare, + partPath, + verifyStagedPart, + receive, + recoverInterrupted, + } satisfies ThreadHandoffServiceShape; +}); + +export const layer: Layer.Layer< + ThreadHandoffService, + never, + | SqlClient.SqlClient + | FileSystem.FileSystem + | Path.Path + | ServerConfig + | EventSinkV2 + | ProjectionStoreV2 + | ThreadHandoffGit + | ProjectService + | RepositoryIdentityResolver + | ServerEnvironment + | ProviderAdapterRegistryV2 +> = Layer.effect(ThreadHandoffService, make); diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index ef4ebd9fa1d..adbec306cc4 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -1,9 +1,13 @@ import { + AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + ENVIRONMENT_HANDOFF_PART_CHUNK_BYTES, EnvironmentHttpApi, type OrchestrationProjectShell, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Predicate from "effect/Predicate"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -11,14 +15,18 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { annotateEnvironmentRequest, failEnvironmentInternal, + failEnvironmentInvalidRequest, failEnvironmentNotFound, requireEnvironmentScope, } from "../auth/http.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts"; import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; +import * as ThreadHandoffService from "./ThreadHandoffService.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; +const EMPTY_PART = new Uint8Array(0); + function isThreadNotFound(error: unknown): boolean { return ( Predicate.hasProperty(error, "cause") && @@ -42,6 +50,9 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const projectEnrichment = yield* ProjectEnrichmentService.ProjectEnrichmentService; + const threadHandoff = yield* ThreadHandoffService.ThreadHandoffService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const enrichProjectShells = Effect.fn("http.orchestration.enrichProjectShells")( (projects: ReadonlyArray) => @@ -93,6 +104,86 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( ); }), ) + .handle( + "readHandoffPart", + Effect.fn("environment.orchestration.readHandoffPart")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthOrchestrationOperateScope); + const target = threadHandoff.partPath({ + handoffId: args.params.handoffId, + kind: args.params.kind, + }); + const exists = yield* fs + .exists(target) + .pipe( + Effect.catch((cause) => + failEnvironmentInternal("orchestration_handoff_part_failed", cause), + ), + ); + if (!exists) { + return yield* failEnvironmentNotFound("handoff_part_not_found"); + } + const contents = yield* fs + .readFile(target) + .pipe( + Effect.catch((cause) => + failEnvironmentInternal("orchestration_handoff_part_failed", cause), + ), + ); + const window = ThreadHandoffService.handoffChunkWindow({ + totalBytes: contents.length, + offset: args.payload.offset, + chunkBytes: ENVIRONMENT_HANDOFF_PART_CHUNK_BYTES, + }); + return { + offset: window.offset, + totalBytes: contents.length, + data: contents.slice(window.offset, window.end), + complete: window.complete, + }; + }), + ) + .handle( + "writeHandoffPart", + Effect.fn("environment.orchestration.writeHandoffPart")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthOrchestrationOperateScope); + const target = threadHandoff.partPath({ + handoffId: args.params.handoffId, + kind: args.params.kind, + }); + const staged = yield* fs.exists(target).pipe( + Effect.flatMap((exists) => (exists ? fs.readFile(target) : Effect.succeed(EMPTY_PART))), + Effect.catch((cause) => + failEnvironmentInternal("orchestration_handoff_part_failed", cause), + ), + ); + // A chunk that does not continue exactly where the staged bytes end + // would silently produce a part with a hole in it, which would only + // surface later as a digest mismatch or a corrupt bundle. + if (args.payload.offset !== staged.length) { + return yield* failEnvironmentInvalidRequest("handoff_part_offset_mismatch"); + } + const next = new Uint8Array(staged.length + args.payload.data.length); + next.set(staged, 0); + next.set(args.payload.data, staged.length); + yield* fs + .makeDirectory(path.dirname(target), { recursive: true }) + .pipe( + Effect.catch((cause) => + failEnvironmentInternal("orchestration_handoff_part_failed", cause), + ), + ); + yield* fs + .writeFile(target, next) + .pipe( + Effect.catch((cause) => + failEnvironmentInternal("orchestration_handoff_part_failed", cause), + ), + ); + return { receivedBytes: next.length }; + }), + ) .handle( "threadSnapshot", Effect.fn("environment.orchestration.threadSnapshot")(function* (args) { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 1f34589ec36..2b89f90bdf5 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -4,6 +4,7 @@ import { type ApplicationStoredEvent, CommandId, ContextTransferId, + EnvironmentId, EventId, MessageId, type ModelSelection, @@ -12,6 +13,7 @@ import { ProviderInstanceId, ProviderThreadId, RunId, + ThreadHandoffId, ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -50,6 +52,7 @@ import { OrchestrationV2EventSinkLayerLive, OrchestrationV2LayerLive } from "./r import { shellStreamItemFromThreadShell } from "./ShellStream.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { ThreadManagementService } from "./ThreadManagementService.ts"; +import { userFacingDispatchErrorMessage } from "./UserFacingErrors.ts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-orchestration-v2-runtime-layer-", @@ -707,6 +710,86 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("departs, completes and releases a thread handoff", () => + Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const threadManagement = yield* ThreadManagementService; + const threadId = ThreadId.make("runtime-layer-handoff-thread"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-handoff-create"), + threadId, + projectId: ProjectId.make("runtime-layer-handoff-project"), + title: "Handoff thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feat/handoff", + worktreePath: null, + }); + + yield* threadManagement.dispatch({ + type: "thread.handoff.depart", + commandId: CommandId.make("runtime-layer-handoff-depart"), + threadId, + handoffId: ThreadHandoffId.make("handoff-runtime-1"), + peerEnvironmentId: EnvironmentId.make("environment-staging"), + peerLabel: "calendaty-staging", + previousHandoffId: null, + hopCount: 0, + }); + const departed = yield* orchestrator.getThreadProjection(threadId); + assert.equal(departed.thread.handoff?.presence, "away"); + assert.equal(departed.thread.handoff?.peerLabel, "calendaty-staging"); + + // A departed thread refuses a second departure. + const second = yield* threadManagement + .dispatch({ + type: "thread.handoff.depart", + commandId: CommandId.make("runtime-layer-handoff-depart-2"), + threadId, + handoffId: ThreadHandoffId.make("handoff-runtime-2"), + peerEnvironmentId: EnvironmentId.make("environment-other"), + peerLabel: null, + previousHandoffId: null, + hopCount: 0, + }) + .pipe(Effect.flip); + assert.instanceOf(second, OrchestratorDispatchError); + // The refusal must carry a reason a user can act on, not the generic + // dispatch failure — the dialog shows exactly this string. + const detail = userFacingDispatchErrorMessage(second); + assert.isDefined(detail); + assert.include(detail ?? "", "already handed off"); + + yield* threadManagement.dispatch({ + type: "thread.handoff.complete", + commandId: CommandId.make("runtime-layer-handoff-complete"), + threadId, + handoffId: ThreadHandoffId.make("handoff-runtime-1"), + peerThreadId: ThreadId.make("thread-on-staging"), + }); + const completed = yield* orchestrator.getThreadProjection(threadId); + assert.equal(completed.thread.handoff?.peerThreadId, "thread-on-staging"); + // The residual copy stays unarchived: the sidebar's one-row rule hides + // it while the live peer is visible, and it must remain reachable as + // the return target and the offline-peer safety net. + assert.isNull(completed.thread.archivedAt); + + yield* threadManagement.dispatch({ + type: "thread.handoff.abort", + commandId: CommandId.make("runtime-layer-handoff-abort"), + threadId, + handoffId: ThreadHandoffId.make("handoff-runtime-1"), + reason: "test release", + }); + const released = yield* orchestrator.getThreadProjection(threadId); + assert.isNull(released.thread.handoff ?? null); + }), + ); + it.effect("persists rejected command receipts across retries", () => Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 6f4df38e1d9..bbb2aeae9c8 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -43,6 +43,10 @@ import { layerWithLegacyImporter as threadManagementServiceLayer } from "./Threa import { layer as threadLaunchServiceLayer } from "./ThreadLaunchService.ts"; import { layer as threadLifecycleServiceLayer } from "./ThreadLifecycleService.ts"; import { layer as threadForkServiceLayer } from "./ThreadForkService.ts"; +import { layer as repositoryIdentityResolverLayer } from "../project/RepositoryIdentityResolver.ts"; +import { layer as vcsProcessLayer } from "../vcs/VcsProcess.ts"; +import { layer as threadHandoffGitLayer } from "./ThreadHandoffGit.ts"; +import { layer as threadHandoffServiceLayer } from "./ThreadHandoffService.ts"; import { layer as turnItemPositionStoreLayer } from "./TurnItemPositionStore.ts"; import { layer as scheduledTaskServiceLayer } from "../scheduledTasks/ScheduledTaskService.ts"; @@ -253,6 +257,20 @@ export const OrchestrationV2LayerLive = Layer.mergeAll( legacyV1ThreadImporterProvided, ); +const threadHandoffProvided = threadHandoffServiceLayer.pipe( + Layer.provide( + Layer.mergeAll( + storesLayer, + projectionStoreLayer, + eventSinkProvided, + ProjectServiceLayerLive, + providerAdapterRegistryProvided, + repositoryIdentityResolverLayer, + threadHandoffGitLayer.pipe(Layer.provide(vcsProcessLayer)), + ), + ), +); + export const OrchestrationV2ProductionLayerLive = Layer.mergeAll( OrchestrationLayerLive, OrchestrationV2LayerLive, @@ -261,4 +279,5 @@ export const OrchestrationV2ProductionLayerLive = Layer.mergeAll( threadLifecycleProvided, scheduledTaskProvided, providerContinuationWorkerProvided, + threadHandoffProvided, ); diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index ba8a13b9740..18279fbbf23 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -143,6 +143,9 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/038_039_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/038_039_OrchestrationV2.test.ts index 24f94423569..c63d45de492 100644 --- a/apps/server/src/persistence/Migrations/038_039_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/038_039_OrchestrationV2.test.ts @@ -13,7 +13,7 @@ layer("038_039_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 46 }, (_, index) => index + 1), + Array.from({ length: 47 }, (_, index) => index + 1), ); }), ); diff --git a/apps/server/src/persistence/Migrations/047_OrchestrationV2ThreadHandoffs.test.ts b/apps/server/src/persistence/Migrations/047_OrchestrationV2ThreadHandoffs.test.ts new file mode 100644 index 00000000000..58a92de7301 --- /dev/null +++ b/apps/server/src/persistence/Migrations/047_OrchestrationV2ThreadHandoffs.test.ts @@ -0,0 +1,129 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +const insertHandoff = (input: { + readonly handoffId: string; + readonly previousHandoffId?: string | null; + readonly state?: string; +}) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO orchestration_v2_thread_handoffs ( + handoff_id, + thread_id, + peer_environment_id, + peer_thread_id, + previous_handoff_id, + hop_count, + state, + manifest_json, + created_at, + updated_at + ) VALUES ( + ${input.handoffId}, + 'thread:1', + 'environment:staging', + 'thread:2', + ${input.previousHandoffId ?? null}, + 0, + ${input.state ?? "departed"}, + '{"version":1}', + '2026-08-06T00:00:00.000Z', + '2026-08-06T00:00:00.000Z' + ) + `; + }); + +layer("046_OrchestrationV2ThreadHandoffs", (it) => { + it.effect("stores a hop and leaves recovery columns empty until a bundle is applied", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 47 }); + + yield* insertHandoff({ handoffId: "handoff:1" }); + + const rows = yield* sql<{ + readonly handoff_id: string; + readonly hop_count: number; + readonly previous_handoff_id: string | null; + readonly applied_head_sha: string | null; + readonly stash_ref: string | null; + readonly pre_tag: string | null; + }>`SELECT * FROM orchestration_v2_thread_handoffs`; + + assert.strictEqual(rows.length, 1); + assert.strictEqual(rows[0]?.handoff_id, "handoff:1"); + assert.strictEqual(rows[0]?.hop_count, 0); + assert.strictEqual(rows[0]?.previous_handoff_id, null); + assert.strictEqual(rows[0]?.applied_head_sha, null); + assert.strictEqual(rows[0]?.stash_ref, null); + assert.strictEqual(rows[0]?.pre_tag, null); + }), + ); + + it.effect("rejects a second row for the same hop, so a bundle cannot be applied twice", () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 47 }); + + yield* insertHandoff({ handoffId: "handoff:duplicate" }); + const duplicate = yield* Effect.result(insertHandoff({ handoffId: "handoff:duplicate" })); + + assert.strictEqual(duplicate._tag, "Failure"); + }), + ); + + it.effect("chains hops so a lineage can be walked back to an earlier environment", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 47 }); + + yield* insertHandoff({ handoffId: "handoff:hop-1" }); + yield* insertHandoff({ handoffId: "handoff:hop-2", previousHandoffId: "handoff:hop-1" }); + + const rows = yield* sql<{ + readonly handoff_id: string; + readonly previous_handoff_id: string | null; + }>` + SELECT handoff_id, previous_handoff_id + FROM orchestration_v2_thread_handoffs + WHERE handoff_id LIKE 'handoff:hop-%' + ORDER BY handoff_id + `; + + assert.deepStrictEqual( + rows.map((row) => [row.handoff_id, row.previous_handoff_id]), + [ + ["handoff:hop-1", null], + ["handoff:hop-2", "handoff:hop-1"], + ], + ); + }), + ); + + it.effect("finds hops left mid-apply, the only state that can have touched a repository", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 47 }); + + yield* insertHandoff({ handoffId: "handoff:arrived", state: "arrived" }); + yield* insertHandoff({ handoffId: "handoff:applying", state: "applying" }); + + const rows = yield* sql<{ readonly handoff_id: string }>` + SELECT handoff_id FROM orchestration_v2_thread_handoffs WHERE state = 'applying' + `; + + assert.deepStrictEqual( + rows.map((row) => row.handoff_id), + ["handoff:applying"], + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/047_OrchestrationV2ThreadHandoffs.ts b/apps/server/src/persistence/Migrations/047_OrchestrationV2ThreadHandoffs.ts new file mode 100644 index 00000000000..1d5645a4065 --- /dev/null +++ b/apps/server/src/persistence/Migrations/047_OrchestrationV2ThreadHandoffs.ts @@ -0,0 +1,50 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Tracks thread handoffs: one row per hop, on both the environment that sends + * a thread and the one that receives it, keyed by the handoff id both sides + * share. + * + * `applied_head_sha`, `stash_ref` and `pre_tag` record what the receiving + * repository looked like before the bundle was applied, so rolling a partial + * apply back is a lookup rather than a reconstruction. `previous_handoff_id` + * chains hops together, which is what makes a return trip an ordinary hop + * toward an environment already in the lineage. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE orchestration_v2_thread_handoffs ( + handoff_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + peer_environment_id TEXT NOT NULL, + peer_thread_id TEXT, + previous_handoff_id TEXT, + hop_count INTEGER NOT NULL DEFAULT 0, + state TEXT NOT NULL, + manifest_json TEXT NOT NULL, + applied_head_sha TEXT, + stash_ref TEXT, + pre_tag TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX orchestration_v2_thread_handoffs_thread_idx + ON orchestration_v2_thread_handoffs(thread_id, created_at) + `; + + /** + * Startup recovery scans for hops left mid-apply, and the destination side + * of an unfinished hop is the only place a repository can have been touched. + */ + yield* sql` + CREATE INDEX orchestration_v2_thread_handoffs_state_idx + ON orchestration_v2_thread_handoffs(state, updated_at) + `; +}); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 56f847be268..340a751e40c 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -26,6 +26,7 @@ it.effect("runs projection repair, recovery, worker startup, and bootstrap in or verify: record("verify").pipe(Effect.as({ valid: false })), rebuild: record("rebuild").pipe(Effect.as({ valid: true })), recover: record("recover").pipe(Effect.as({ closedRequests: 2 })), + recoverHandoffs: record("recover-handoffs").pipe(Effect.as(1)), startEffectWorker: record("worker"), autoBootstrap: record("bootstrap").pipe(Effect.as({ projectId: "project-1" })), }); @@ -35,11 +36,13 @@ it.effect("runs projection repair, recovery, worker startup, and bootstrap in or "verify", "rebuild", "recover", + "recover-handoffs", "worker", "bootstrap", ]); assert.deepEqual(result, { recovery: { closedRequests: 2 }, + interruptedHandoffs: 1, bootstrap: { projectId: "project-1" }, }); }), @@ -53,6 +56,7 @@ it.effect("does not rebuild valid projections", () => verify: Effect.succeed({ valid: true }), rebuild: Ref.set(rebuilt, true).pipe(Effect.as({ valid: true })), recover: Effect.void, + recoverHandoffs: Effect.succeed(0), startEffectWorker: Effect.void, autoBootstrap: Effect.void, }); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 99af107696c..de115031aff 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -30,6 +30,7 @@ import * as EffectWorker from "./orchestration-v2/EffectWorker.ts"; import * as LegacyV1ThreadImporter from "./orchestration-v2/LegacyV1ThreadImporter.ts"; import * as ProjectionMaintenance from "./orchestration-v2/ProjectionMaintenance.ts"; import * as ProviderRuntimeRecovery from "./orchestration-v2/ProviderRuntimeRecoveryService.ts"; +import * as ThreadHandoffService from "./orchestration-v2/ThreadHandoffService.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as ThreadLaunch from "./orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagement from "./orchestration-v2/ThreadManagementService.ts"; @@ -342,6 +343,8 @@ export function runOrderedV2StartupPhases< readonly verify: Effect.Effect; readonly rebuild: Effect.Effect; readonly recover: Effect.Effect; + /** Number of handoffs that were still applying when this server last stopped. */ + readonly recoverHandoffs: Effect.Effect; readonly startEffectWorker: Effect.Effect; readonly autoBootstrap: Effect.Effect; }) { @@ -357,9 +360,10 @@ export function runOrderedV2StartupPhases< } } const recovery = yield* input.recover; + const interruptedHandoffs = yield* input.recoverHandoffs; yield* input.startEffectWorker; const bootstrap = yield* input.autoBootstrap; - return { recovery, bootstrap } as const; + return { recovery, interruptedHandoffs, bootstrap } as const; }); } @@ -370,6 +374,7 @@ export const make = (options?: StartupOptions) => const projectionMaintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; const legacyV1ThreadImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; const providerRuntimeRecovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; + const threadHandoff = yield* ThreadHandoffService.ThreadHandoffService; const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; @@ -488,6 +493,14 @@ export const make = (options?: StartupOptions) => projectionMaintenance.rebuild, ), recover: runStartupPhase("orchestration-v2.recovery", providerRuntimeRecovery.recover), + // A hop left in `applying` is the only state in which this + // environment's repository can have been written to by a transfer + // that never finished, so it is failed here rather than left to + // look in-flight forever. + recoverHandoffs: runStartupPhase( + "orchestration-v2.handoff.recovery", + threadHandoff.recoverInterrupted(), + ), startEffectWorker: runStartupPhase( "orchestration-v2.effect-worker.start", startEffectWorkerWithRelay({ diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 6dc9e1892b6..a66287edd8b 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1070,7 +1070,7 @@ function legacySafeThreadId(threadId: string): string { return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); } -function toSafeThreadId(threadId: string): string { +export function toSafeThreadId(threadId: string): string { return `terminal_${Encoding.encodeBase64Url(threadId)}`; } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4632589f5a3..0ec72bf0347 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -72,6 +72,7 @@ import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as ThreadManagementService from "./orchestration-v2/ThreadManagementService.ts"; +import * as ThreadHandoffService from "./orchestration-v2/ThreadHandoffService.ts"; import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import { @@ -415,6 +416,7 @@ const makeWsRpcLayer = ( ), ); const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; + const threadHandoff = yield* ThreadHandoffService.ThreadHandoffService; const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; const projectService = yield* ProjectService.ProjectService; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; @@ -1180,6 +1182,63 @@ const makeWsRpcLayer = ( "orchestration_v2.project_id": input.projectId, }, ), + [ORCHESTRATION_V2_WS_METHODS.prepareThreadHandoff]: (input) => + observeRpcEffect( + ORCHESTRATION_V2_WS_METHODS.prepareThreadHandoff, + threadHandoff + .prepare({ + threadId: input.threadId, + peerEnvironmentId: input.peerEnvironmentId, + peerBranchTip: input.peerBranchTip, + fullHistory: input.fullHistory ?? false, + previousHandoffId: input.previousHandoffId, + hopCount: input.hopCount, + }) + .pipe( + Effect.map((preparation) => ({ + bundle: preparation.bundle, + totalBytes: preparation.totalBytes, + // "refuse" never reaches a client: prepare fails with + // payload_too_large instead of returning a verdict nobody + // can act on. + verdict: + preparation.verdict === "refuse" ? ("warn" as const) : preparation.verdict, + dirtyFileCount: preparation.dirtyFileCount, + untrackedFileCount: preparation.untrackedFileCount, + })), + ), + { + "rpc.aggregate": "orchestration", + "orchestration_v2.thread_id": input.threadId, + }, + ), + [ORCHESTRATION_V2_WS_METHODS.receiveThreadHandoff]: (input) => + observeRpcEffect( + ORCHESTRATION_V2_WS_METHODS.receiveThreadHandoff, + threadHandoff + .receive({ + bundle: input.bundle, + projectId: input.projectId, + cloneWorkspaceRoot: input.cloneWorkspaceRoot, + returningThreadId: input.returningThreadId, + }) + .pipe( + Effect.map((application) => ({ + threadId: application.threadId, + projectId: application.projectId, + // A diverged or unrelated hop fails rather than returning, + // so only the two outcomes that wrote anything get here. + classification: + application.classification === "absorb" + ? ("absorb" as const) + : ("advance" as const), + })), + ), + { + "rpc.aggregate": "orchestration", + "orchestration_v2.project_id": input.projectId, + }, + ), [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: (_input) => observeRpcStreamEffect( ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 95f5461f997..3c84e52ed7d 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -59,6 +59,7 @@ interface BranchToolbarProps { onComposerFocusRequest?: () => void; availableEnvironments?: readonly EnvironmentOption[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; + onMoveThread?: (environmentId: EnvironmentId) => void; } interface MobileRunContextSelectorProps { @@ -327,6 +328,7 @@ export const BranchToolbar = memo(function BranchToolbar({ onComposerFocusRequest, availableEnvironments, onEnvironmentChange, + onMoveThread, }: BranchToolbarProps) { const threadRef = useMemo( () => scopeThreadRef(environmentId, threadId), @@ -469,6 +471,7 @@ export const BranchToolbar = memo(function BranchToolbar({ environmentId={environmentId} availableEnvironments={availableEnvironments} {...(showEnvironmentPicker && onEnvironmentChange ? { onEnvironmentChange } : {})} + {...(onMoveThread ? { onMoveThread } : {})} /> {showGitControls ? ( diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 6e7240cffcb..a2d5f0b812a 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -156,7 +156,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe {displayMode === "panel" ? ( - + // flex-1 absorbs the row's slack so the chevron and its divider + // park at the right edge, like every other panel row. + {effectiveEnvMode === "worktree" && !activeWorktreePath ? "Create" : workspaceKind} ) : null} diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 30b04345fce..503ea2cdda7 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -25,29 +25,158 @@ interface BranchToolbarEnvironmentSelectorProps { environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; + /** + * Offered once the thread is locked to its environment. Picking a device + * here moves the thread rather than changing where a draft will start, so it + * is a separate group with its own label instead of a silently different + * meaning for the same rows. + */ + onMoveThread?: (environmentId: EnvironmentId) => void; + /** Where this thread ran before it was moved here. */ + movedFromLabel?: string; displayMode?: "toolbar" | "panel"; } +const MOVE_VALUE_PREFIX = "move:"; + export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ envLocked, environmentId, availableEnvironments, onEnvironmentChange, + onMoveThread, + movedFromLabel, displayMode = "toolbar", }: BranchToolbarEnvironmentSelectorProps) { const activeEnvironment = useMemo(() => { return availableEnvironments.find((env) => env.environmentId === environmentId) ?? null; }, [availableEnvironments, environmentId]); + const moveTargets = useMemo( + () => availableEnvironments.filter((env) => env.environmentId !== environmentId), + [availableEnvironments, environmentId], + ); + const environmentItems = useMemo( - () => - availableEnvironments.map((env) => ({ + () => [ + ...availableEnvironments.map((env) => ({ value: env.environmentId, label: env.label, })), - [availableEnvironments], + ...(onMoveThread === undefined + ? [] + : moveTargets.map((env) => ({ + value: `${MOVE_VALUE_PREFIX}${env.environmentId}`, + label: env.label, + }))), + ], + [availableEnvironments, moveTargets, onMoveThread], ); + const handleValueChange = (value: EnvironmentId | null) => { + if (value === null) return; + if (value.startsWith(MOVE_VALUE_PREFIX)) { + onMoveThread?.(value.slice(MOVE_VALUE_PREFIX.length) as EnvironmentId); + return; + } + onEnvironmentChange?.(value); + }; + + // A thread that is locked to its environment can still be moved to another + // one, so the control stays interactive instead of collapsing to a label — + // it just offers a different verb. + if (envLocked && onMoveThread !== undefined) { + return ( + + ); + } + if (envLocked || onEnvironmentChange === undefined) { return ( onEnvironmentChange(value as EnvironmentId)} + onValueChange={handleValueChange} items={environmentItems} > diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cce9ef485bb..a24f52deb91 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -164,6 +164,7 @@ import { GitBranchIcon, TriangleAlertIcon, WifiOffIcon, + CloudIcon, } from "lucide-react"; import { cn } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; @@ -222,7 +223,7 @@ import { serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; -import { threadEnvironment } from "../state/threads"; +import { threadEnvironment, threadHandoff } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { @@ -231,15 +232,18 @@ import { useProjects, useThreadProjection, useThreadShell, + useThreadShells, useThreadRefs, useThreadVisibleTurnItems, waitForThreadShell, } from "../state/entities"; +import { setPendingHandoffNavigation } from "../state/handoffNavigation"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; +import { ThreadHandoffDialog } from "./ThreadHandoffDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { ChatHeader } from "./chat/ChatHeader"; @@ -1203,6 +1207,10 @@ function ChatViewContent(props: ChatViewProps) { const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false }); const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const releaseThreadHandoff = useAtomCommand(threadEnvironment.releaseHandoff, { + reportFailure: false, + }); + const moveThreadCommand = useAtomCommand(threadHandoff.move, { reportFailure: true }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -2065,6 +2073,79 @@ function ChatViewContent(props: ChatViewProps) { ); const systemComposerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; + // A departed thread is a read-only record of work running elsewhere. The + // server refuses the send either way; this says where it went so the + // refusal is not a mystery. + const awayHandoff = isServerThread ? (serverThread?.handoff ?? null) : null; + if (awayHandoff !== null && awayHandoff.presence === "away") { + items.push({ + id: `thread-handoff-away:${awayHandoff.handoffId}`, + variant: "info", + icon: , + title: `Running on ${ + awayHandoff.peerLabel ?? + environmentById.get(awayHandoff.peerEnvironmentId)?.label ?? + "another device" + }`, + description: + "The thread now lives there — keep working with it from any device, or pull it back to run it here.", + actions: ( + <> + {/* The reverse hop: the peer prepares and this side receives into + the same thread, so the conversation and the work come home. */} + {awayHandoff.peerThreadId !== null ? ( + + ) : null} + {/* Escape hatch for a hop whose transfer never landed: releasing + makes this side live again without waiting for the peer. */} + + + ), + }); + } const updateRunning = serverUpdateState.status === "running"; const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; const environmentReconnecting = @@ -2202,6 +2283,12 @@ function ChatViewContent(props: ChatViewProps) { serverUpdateEnvironmentId, versionMismatchSelfUpdate, versionMismatchServerLabel, + isServerThread, + serverThread, + releaseThreadHandoff, + moveThreadCommand, + activeEnvironment, + environmentById, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const unlockedSelectedProvider = resolveSelectableProvider( @@ -2695,6 +2782,29 @@ function ChatViewContent(props: ChatViewProps) { const envLocked = Boolean(activeThread && (activeMessageCount > 0 || activeRuntime !== null)); + // Moving is offered only for a real thread that is locked to its + // environment: a draft has nothing to move yet, and a thread already away + // is being run somewhere else. + const [moveTargetEnvironmentId, setMoveTargetEnvironmentId] = useState( + null, + ); + const activeHandoff = isServerThread ? (serverThread?.handoff ?? null) : null; + const canMoveThread = + isServerThread && + envLocked && + activeHandoff?.presence !== "away" && + logicalProjectEnvironments.length > 1; + const onMoveThread = useCallback((nextEnvironmentId: EnvironmentId) => { + setMoveTargetEnvironmentId(nextEnvironmentId); + }, []); + + const moveTargetEnvironment = + moveTargetEnvironmentId === null + ? null + : (logicalProjectEnvironments.find( + (candidate) => candidate.environmentId === moveTargetEnvironmentId, + ) ?? null); + // Handle environment change for draft threads. When the user picks a // different environment we update the draft context to point at the physical // project in that environment while keeping the same logical project. @@ -6105,6 +6215,15 @@ function ChatViewContent(props: ChatViewProps) { envLocked, availableEnvironments: logicalProjectEnvironments, onEnvironmentChange, + ...(canMoveThread ? { onMoveThread } : {}), + ...(activeHandoff?.presence === "here" + ? { + movedFromLabel: + activeHandoff.peerLabel ?? + environmentById.get(activeHandoff.peerEnvironmentId)?.label ?? + "another device", + } + : {}), onEnvModeChange, ...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {}), ...(canOverrideServerThreadEnvMode @@ -6506,6 +6625,16 @@ function ChatViewContent(props: ChatViewProps) { ? { onCheckoutPullRequestRequest: openPullRequestDialog } : {})} {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} + {...(canMoveThread ? { onMoveThread } : {})} + {...(activeHandoff?.presence === "here" + ? { + movedFromLabel: + activeHandoff.peerLabel ?? + environmentById.get(activeHandoff.peerEnvironmentId) + ?.label ?? + "another device", + } + : {})} availableEnvironments={logicalProjectEnvironments} /> @@ -6578,6 +6707,54 @@ function ChatViewContent(props: ChatViewProps) { onPrepared={handlePreparedPullRequestThread} /> ) : null} + + {moveTargetEnvironment !== null && isServerThread && serverThread ? ( + { + if (!open) { + setMoveTargetEnvironmentId(null); + } + }} + threadId={serverThread.id} + threadTitle={serverThread.title} + originEnvironmentId={serverThread.environmentId} + targetEnvironmentId={moveTargetEnvironment.environmentId} + targetLabel={moveTargetEnvironment.label} + targetProjectId={moveTargetEnvironment.projectId} + branch={serverThread.branch} + {...(activeHandoff?.presence === "here" && + activeHandoff.peerEnvironmentId === moveTargetEnvironment.environmentId && + activeHandoff.peerThreadId !== null + ? { + returnTo: { + threadId: activeHandoff.peerThreadId, + previousHandoffId: activeHandoff.handoffId, + hopCount: activeHandoff.hopCount + 1, + }, + } + : {})} + onMoved={(targetThreadId) => { + // The live thread is now on the other device; follow it + // once its shell lands so the user never sees a blank draft. + setPendingHandoffNavigation({ + environmentId: moveTargetEnvironment.environmentId, + threadId: targetThreadId, + }); + }} + isBusy={ + // Must match the server's busy guard exactly: it refuses on + // an active RUN, and the runtime summary alone stays + // non-null on an idle thread — using it here made the + // dialog wait forever for an interrupt of nothing. + serverThread.latestRun !== null && + ["preparing", "queued", "starting", "running", "waiting"].includes( + serverThread.latestRun.status, + ) + } + onInterrupt={onInterrupt} + /> + ) : null} {/* end chat column */} {inlineThreadPanelOpen ? ( diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 028df2c9c73..0fa9d17f57f 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -38,13 +38,22 @@ import { sortProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; -import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + RunId, + ThreadId, + ThreadHandoffId, +} from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Project, type Thread, } from "../types"; +import * as DateTime from "effect/DateTime"; + import { makeThreadFixture, type ThreadFixtureOverrides } from "../test-fixtures"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -290,6 +299,64 @@ describe("sidebar thread lineage helpers", () => { ).toEqual([parentId, fork.id]); }); + it("shows one row per handed-off thread: the away copy hides while its live peer is visible", () => { + const environmentId = EnvironmentId.make("environment-mac"); + const peerEnvironmentId = EnvironmentId.make("environment-staging"); + const away = makeThreadFixture({ + id: ThreadId.make("thread-away"), + environmentId, + handoff: { + handoffId: ThreadHandoffId.make("handoff-1"), + presence: "away", + peerEnvironmentId, + peerThreadId: ThreadId.make("thread-here"), + peerLabel: "calendaty-staging", + previousHandoffId: null, + hopCount: 0, + updatedAt: DateTime.makeUnsafe("2026-08-06T00:00:00.000Z"), + }, + }); + const here = makeThreadFixture({ + id: ThreadId.make("thread-here"), + environmentId: peerEnvironmentId, + handoff: { + handoffId: ThreadHandoffId.make("handoff-1"), + presence: "here", + peerEnvironmentId: environmentId, + peerThreadId: ThreadId.make("thread-away"), + peerLabel: null, + previousHandoffId: null, + hopCount: 0, + updatedAt: DateTime.makeUnsafe("2026-08-06T00:00:00.000Z"), + }, + }); + + expect(filterSidebarV2VisibleThreads([away, here], null).map((thread) => thread.id)).toEqual([ + here.id, + ]); + }); + + it("keeps the away copy visible when the owning device is offline", () => { + const away = makeThreadFixture({ + id: ThreadId.make("thread-away-alone"), + environmentId: EnvironmentId.make("environment-mac"), + handoff: { + handoffId: ThreadHandoffId.make("handoff-1"), + presence: "away", + peerEnvironmentId: EnvironmentId.make("environment-staging"), + peerThreadId: ThreadId.make("thread-here"), + peerLabel: "calendaty-staging", + previousHandoffId: null, + hopCount: 0, + updatedAt: DateTime.makeUnsafe("2026-08-06T00:00:00.000Z"), + }, + }); + + expect(filterSidebarV2VisibleThreads([away], null).map((thread) => thread.id)).toEqual([ + away.id, + ]); + }); + it("identifies subagent threads so the sidebar can hide them", () => { const parentId = ThreadId.make("thread-parent"); const subagent = makeThreadFixture({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index fcd594e67e0..2286d3c9827 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -105,12 +105,40 @@ export function filterSidebarV2VisibleThreads< T extends Pick & { environmentId: string; projectId: string; + id?: string | undefined; + handoff?: + | { + readonly presence: "away" | "here"; + readonly peerEnvironmentId: string; + readonly peerThreadId: string | null; + } + | null + | undefined; }, >(threads: readonly T[], scopedProjectKeys: ReadonlySet | null): T[] { + // A handed-off thread exists on both environments, but the sidebar shows + // one row: the live copy. The away copy hides only while its live + // counterpart is actually in the list — if the owning device is offline, + // the away row stays visible so the thread never disappears entirely. + const present = new Set( + threads.flatMap((thread) => + thread.id === undefined ? [] : [`${thread.environmentId}:${thread.id}`], + ), + ); + const awayWithVisiblePeer = (thread: T): boolean => { + const handoff = thread.handoff ?? null; + return ( + handoff !== null && + handoff.presence === "away" && + handoff.peerThreadId !== null && + present.has(`${handoff.peerEnvironmentId}:${handoff.peerThreadId}`) + ); + }; return threads.filter( (thread) => thread.archivedAt === null && !isSidebarSubagentThread(thread) && + !awayWithVisiblePeer(thread) && (scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index d1dd2b30fed..b52a308571d 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -18,6 +18,7 @@ import type { TimestampFormat, } from "@t3tools/contracts"; import { + CloudIcon, AlarmClockIcon, AlarmClockOffIcon, CheckIcon, @@ -97,6 +98,10 @@ import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; +import { + setPendingHandoffNavigation, + usePendingHandoffNavigation, +} from "../state/handoffNavigation"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; @@ -792,6 +797,26 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { #{pr.number} ) : null; + // A thread that is one side of a handoff carries where its work lives: + // `away` recedes (the work is elsewhere), `here` is quietly affirmative. + const handoffLink = thread.handoff ?? null; + const handoffBadge = handoffLink ? ( + + + + ) : null; const terminalStatusIcon = terminalStatus ? ( {title} {terminalStatusIcon} + {handoffBadge} {isRegeneratingTitle ? ( Regenerating title @@ -1059,6 +1085,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { )} {terminalStatusIcon} + {handoffBadge} {prBadge} {diff ? ( @@ -1209,6 +1236,26 @@ export default function SidebarV2() { const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const router = useRouter(); + // Follow a moved thread once its shell lands. Lives here because the + // sidebar survives the route churn a departure causes; the thread view + // that started the move does not. + const pendingHandoffNavigation = usePendingHandoffNavigation(); + useEffect(() => { + if (pendingHandoffNavigation === null) return; + const arrived = threads.some( + (thread) => + thread.environmentId === pendingHandoffNavigation.environmentId && + thread.id === pendingHandoffNavigation.threadId, + ); + if (!arrived) return; + setPendingHandoffNavigation(null); + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(pendingHandoffNavigation.environmentId, pendingHandoffNavigation.threadId), + ), + }); + }, [pendingHandoffNavigation, threads, router]); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); diff --git a/apps/web/src/components/ThreadHandoffDialog.tsx b/apps/web/src/components/ThreadHandoffDialog.tsx new file mode 100644 index 00000000000..71f61a36ad7 --- /dev/null +++ b/apps/web/src/components/ThreadHandoffDialog.tsx @@ -0,0 +1,334 @@ +import type { ThreadHandoffProgress } from "@t3tools/client-runtime/state/threadHandoffTransfer"; +import type { EnvironmentId, ProjectId, ThreadHandoffId, ThreadId } from "@t3tools/contracts"; +import { useCallback, useEffect, useState } from "react"; + +import { threadHandoff } from "../state/threads"; +import { useAtomCommand } from "../state/use-atom-command"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; + +export interface ThreadHandoffDialogProps { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly threadId: ThreadId; + readonly threadTitle: string; + readonly originEnvironmentId: EnvironmentId; + readonly targetEnvironmentId: EnvironmentId; + readonly targetLabel: string; + readonly targetProjectId: ProjectId | null; + readonly branch: string | null; + /** The thread has an active run; sending must interrupt it first. */ + readonly isBusy: boolean; + /** Interrupts the thread's current turn; resolves when the request is accepted. */ + readonly onInterrupt: () => Promise; + /** Set when this move returns the thread to the environment it came from. */ + readonly returnTo?: { + readonly threadId: ThreadId; + readonly previousHandoffId: ThreadHandoffId; + readonly hopCount: number; + }; + readonly onMoved?: (targetThreadId: ThreadId) => void; +} + +/** + * The steps, in the order they run. Split by whether the receiving repository + * has been written to: cancelling is free up to and including the upload, and + * is not offered afterwards because only the servers can undo an apply. + */ +const PHASE_LABELS: ReadonlyArray<{ + readonly phase: ThreadHandoffProgress["phase"] | "interrupt"; + readonly label: string; + readonly safeToCancel: boolean; +}> = [ + { phase: "interrupt", label: "Finish the current turn", safeToCancel: true }, + { phase: "prepare", label: "Snapshot branch, changes and untracked files", safeToCancel: true }, + { phase: "depart", label: "Pause this thread here", safeToCancel: true }, + { phase: "upload", label: "Move the bundle across", safeToCancel: true }, + { phase: "apply", label: "Apply on the other machine", safeToCancel: false }, + { phase: "settle", label: "Hand the thread over", safeToCancel: false }, +]; + +function phaseIndex(phase: ThreadHandoffProgress["phase"] | "interrupt"): number { + return PHASE_LABELS.findIndex((entry) => entry.phase === phase); +} + +/** + * Digs the human-facing message out of a failure however it is wrapped — + * a tagged error, a Cause holding one, or a defect — because the generic + * fallback tells the user nothing they can act on. + */ +function extractFailureMessage(cause: unknown, targetLabel: string): string { + const seen = new Set(); + const queue: Array = [cause]; + while (queue.length > 0) { + const current = queue.shift(); + if (current === null || typeof current !== "object" || seen.has(current)) continue; + seen.add(current); + const record = current as Record; + if (typeof record["message"] === "string" && record["message"].length > 0) { + return record["message"]; + } + for (const key of ["cause", "error", "failure", "defect", "left", "value"]) { + if (key in record) queue.push(record[key]); + } + for (const key of ["failures", "reasons", "errors"]) { + if (Array.isArray(record[key])) queue.push(...(record[key] as unknown[])); + } + } + return `Could not move this thread to ${targetLabel}. Check the console for details.`; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function ThreadHandoffDialog({ + open, + onOpenChange, + threadId, + threadTitle, + originEnvironmentId, + targetEnvironmentId, + targetLabel, + targetProjectId, + branch, + isBusy, + onInterrupt, + returnTo, + onMoved, +}: ThreadHandoffDialogProps) { + const move = useAtomCommand(threadHandoff.move, { reportFailure: false }); + const [progress, setProgress] = useState< + ThreadHandoffProgress | { readonly phase: "interrupt" } | null + >(null); + const [errorMessage, setErrorMessage] = useState(null); + const isMoving = progress !== null && errorMessage === null; + // Send pressed while the agent was working: the turn was interrupted and + // the transfer starts the moment the thread goes idle. + const [sendQueued, setSendQueued] = useState(false); + // Where the destination should clone when it does not have the repository. + const [cloneWorkspaceRoot, setCloneWorkspaceRoot] = useState(""); + + const startTransfer = useCallback(async () => { + if (targetProjectId === null && cloneWorkspaceRoot.trim().length === 0) { + setProgress(null); + setErrorMessage( + `${targetLabel} does not have this repository yet. Enter a folder to clone it into.`, + ); + return; + } + setErrorMessage(null); + setProgress({ phase: "prepare", transferredBytes: 0, totalBytes: 0 }); + const result = await move({ + threadId, + originEnvironmentId, + targetEnvironmentId, + targetLabel, + targetProjectId, + cloneWorkspaceRoot: targetProjectId === null ? cloneWorkspaceRoot.trim() : null, + returningThreadId: returnTo?.threadId ?? null, + targetBranchTip: null, + previousHandoffId: returnTo?.previousHandoffId ?? null, + hopCount: returnTo?.hopCount ?? 0, + onProgress: setProgress, + }); + if (result._tag === "Failure") { + setProgress(null); + // The server's message is written for this exact situation — a + // divergence names the ref the commits were parked at, a payload + // refusal names the size — so dig it out of however the failure is + // wrapped before falling back to something generic. + console.error("thread handoff failed", result.cause); + setErrorMessage(extractFailureMessage(result.cause, targetLabel)); + return; + } + onOpenChange(false); + setProgress(null); + onMoved?.(result.value.targetThreadId); + }, [ + move, + onMoved, + onOpenChange, + originEnvironmentId, + targetEnvironmentId, + targetLabel, + targetProjectId, + cloneWorkspaceRoot, + returnTo, + threadId, + ]); + + const handleMove = useCallback(async () => { + if (isBusy) { + // Interrupt now, send when idle: the snapshot must never be cut while + // the agent is writing the worktree. + setErrorMessage(null); + setProgress({ phase: "interrupt" }); + setSendQueued(true); + await onInterrupt(); + return; + } + await startTransfer(); + }, [isBusy, onInterrupt, startTransfer]); + + useEffect(() => { + if (sendQueued && !isBusy) { + setSendQueued(false); + void startTransfer(); + } + }, [sendQueued, isBusy, startTransfer]); + + // The interrupt wait must not hang forever: if the thread has not gone + // idle within a minute, surface it instead of showing Sending… until the + // heat death of the universe. + useEffect(() => { + if (!sendQueued) return; + const timer = setTimeout(() => { + setSendQueued(false); + setProgress(null); + setErrorMessage( + "The current turn did not finish within a minute. Stop it manually, then send again.", + ); + }, 60_000); + return () => clearTimeout(timer); + }, [sendQueued]); + + const activeIndex = progress === null ? -1 : phaseIndex(progress.phase); + const canCancel = progress === null || (PHASE_LABELS[activeIndex]?.safeToCancel ?? false); + + return ( + { + if (!isMoving || canCancel) { + onOpenChange(nextOpen); + } + }} + > + + + + {returnTo === undefined + ? `Send thread to ${targetLabel}` + : `Pull thread back to ${targetLabel}`} + + + {returnTo === undefined + ? `The thread and its work move to ${targetLabel}. You can keep chatting with it from any of your devices.` + : `This thread originally ran on ${targetLabel}. Sending it back continues the original thread there.`} + + + +
+ What travels + + {branch === null ? "Current branch" : branch} · unpushed commits · uncommitted and + untracked files + + + {threadTitle} · the whole conversation · the model keeps its context + +
+ + {targetProjectId === null ? ( + + ) : null} + + {progress === null ? null : ( +
+ {PHASE_LABELS.map((entry, index) => ( +
+ + + {entry.label} + + {entry.phase === "upload" && + index === activeIndex && + progress.phase !== "interrupt" ? ( + + {formatBytes(progress.transferredBytes)} + {progress.totalBytes > 0 ? ` / ${formatBytes(progress.totalBytes)}` : ""} + + ) : null} +
+ ))} + {canCancel ? null : ( + + The other machine is being written to; this can no longer be cancelled here. + + )} +
+ )} + + {errorMessage === null ? null : ( +

+ {errorMessage} +

+ )} +
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index f36fe664b89..0c9a559a560 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -48,6 +48,10 @@ export interface ThreadDetailsPanelProps { envLocked: boolean; availableEnvironments: readonly EnvironmentOption[]; onEnvironmentChange: (environmentId: EnvironmentId) => void; + /** Offered on a locked thread: moving it to another environment. */ + onMoveThread?: (environmentId: EnvironmentId) => void; + /** Where this thread ran before it was moved here. */ + movedFromLabel?: string; onEnvModeChange: (mode: EnvMode) => void; effectiveEnvModeOverride?: EnvMode; activeThreadBranchOverride?: string | null; @@ -186,6 +190,10 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { environmentId={props.environmentId} availableEnvironments={props.availableEnvironments} onEnvironmentChange={props.onEnvironmentChange} + {...(props.onMoveThread ? { onMoveThread: props.onMoveThread } : {})} + {...(props.movedFromLabel !== undefined + ? { movedFromLabel: props.movedFromLabel } + : {})} /> ) : null} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 085ef9db016..7ee3955801d 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -205,6 +205,24 @@ export function useThreadActions() { return resolveThreadRouteRef(currentRouteParams); }, [router]); + // A handed-off thread is one thread living as a pair of records, so an + // action taken on the visible half mirrors to the hidden peer — otherwise + // deleting (or archiving, settling…) the pair takes two attempts, because + // removing one half un-hides the other. Best effort: an unreachable peer + // keeps its state until that device is next seen. + const mirrorToHandoffPeer = useCallback( + async ( + target: ScopedThreadRef, + act: (peer: ScopedThreadRef) => Promise, + ): Promise => { + const shell = readThreadShell(target); + const link = shell?.handoff ?? null; + if (link === null || link.peerThreadId === null) return; + await act(scopeThreadRef(link.peerEnvironmentId, link.peerThreadId)).catch(() => undefined); + }, + [], + ); + const archiveThread = useCallback( async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => { const resolved = resolveThreadTarget(target); @@ -232,6 +250,12 @@ export function useThreadActions() { if (archiveResult._tag === "Failure") { return archiveResult; } + await mirrorToHandoffPeer(threadRef, (peer) => + archiveThreadMutation({ + environmentId: peer.environmentId, + input: { threadId: peer.threadId }, + }), + ); const wokeAt = threadWokeAt(thread, { now: new Date().toISOString() }); if (wokeAt !== null) { markThreadVisited(scopedThreadKey(threadRef), wokeAt); @@ -251,11 +275,23 @@ export function useThreadActions() { return archiveResult; }, - [archiveThreadMutation, getCurrentRouteThreadRef, markThreadVisited, resolveThreadTarget], + [ + archiveThreadMutation, + getCurrentRouteThreadRef, + markThreadVisited, + mirrorToHandoffPeer, + resolveThreadTarget, + ], ); const unarchiveThread = useCallback( async (target: ScopedThreadRef) => { + await mirrorToHandoffPeer(target, (peer) => + unarchiveThreadMutation({ + environmentId: peer.environmentId, + input: { threadId: peer.threadId }, + }), + ); const result = await unarchiveThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, @@ -265,11 +301,17 @@ export function useThreadActions() { } return result; }, - [unarchiveThreadMutation], + [mirrorToHandoffPeer, unarchiveThreadMutation], ); const deleteThread = useCallback( async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet } = {}) => { + await mirrorToHandoffPeer(target, (peer) => + deleteThreadMutation({ + environmentId: peer.environmentId, + input: { threadId: peer.threadId }, + }), + ); const resolved = resolveThreadTarget(target); if (!resolved) { // Thread not in main store (e.g. archived thread) — dispatch delete directly. @@ -451,6 +493,7 @@ export function useThreadActions() { return deleteResult; }, [ + mirrorToHandoffPeer, clearComposerDraftForThread, clearProjectDraftThreadById, clearTerminalUiState, diff --git a/apps/web/src/state/handoffNavigation.ts b/apps/web/src/state/handoffNavigation.ts new file mode 100644 index 00000000000..ac8bd988f6f --- /dev/null +++ b/apps/web/src/state/handoffNavigation.ts @@ -0,0 +1,37 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useSyncExternalStore } from "react"; + +export interface PendingHandoffNavigation { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +} + +/** + * Where to land once a moved thread's shell arrives. + * + * This lives outside React because the component that starts a move does not + * survive it: the departed thread drops out of the sidebar, the selection + * fallback swaps the route, and the thread view remounts — taking any + * component-held "navigate when it lands" state with it. A module store lets + * an always-mounted surface carry the follow-through instead. + */ +let pending: PendingHandoffNavigation | null = null; +const listeners = new Set<() => void>(); + +export function setPendingHandoffNavigation(next: PendingHandoffNavigation | null): void { + pending = next; + for (const listener of listeners) { + listener(); + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function usePendingHandoffNavigation(): PendingHandoffNavigation | null { + return useSyncExternalStore(subscribe, () => pending); +} diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index d64a6ac2163..75f89a6a96b 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -7,6 +7,7 @@ import { type EnvironmentThreadState, createThreadEnvironmentAtoms, } from "@t3tools/client-runtime/state/threads"; +import { createThreadHandoffAtoms } from "@t3tools/client-runtime/state/threadHandoffCommands"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -44,3 +45,9 @@ export function useEnvironmentThread( ) as EnvironmentThreadState; return state; } + +/** + * Moving a thread spans two environments, so it lives beside the per-thread + * commands rather than inside them: no single environment owns the hop. + */ +export const threadHandoff = createThreadHandoffAtoms(connectionAtomRuntime); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 0c318aca48f..d70bc56acba 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -127,6 +127,14 @@ "types": "./src/state/terminal.ts", "default": "./src/state/terminal.ts" }, + "./state/threadHandoffTransfer": { + "types": "./src/state/threadHandoffTransfer.ts", + "default": "./src/state/threadHandoffTransfer.ts" + }, + "./state/threadHandoffCommands": { + "types": "./src/state/threadHandoffCommands.ts", + "default": "./src/state/threadHandoffCommands.ts" + }, "./state/threads": { "types": "./src/state/threads.ts", "default": "./src/state/threads.ts" diff --git a/packages/client-runtime/src/operations/threadHandoff.ts b/packages/client-runtime/src/operations/threadHandoff.ts new file mode 100644 index 00000000000..ff2c2091037 --- /dev/null +++ b/packages/client-runtime/src/operations/threadHandoff.ts @@ -0,0 +1,119 @@ +import { + CommandId, + ORCHESTRATION_V2_WS_METHODS, + type EnvironmentId, + type OrchestrationV2HandoffBundleV1, + type ProjectId, + type ThreadHandoffId, + type ThreadId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; + +import { request } from "../rpc/client.ts"; + +const nextCommandId = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + return CommandId.make(yield* crypto.randomUUIDv4); +}); + +/** + * Asks the environment that owns a thread to stage a hop. Nothing the user can + * see changes: the thread is not locked until `departThread`, so a preflight + * the user then declines leaves only staged bytes behind. + */ +export const prepareThreadHandoff = Effect.fn("EnvironmentCommands.prepareThreadHandoff")( + function* (input: { + readonly threadId: ThreadId; + readonly peerEnvironmentId: EnvironmentId; + readonly peerBranchTip: string | null; + readonly fullHistory: boolean; + readonly previousHandoffId: ThreadHandoffId | null; + readonly hopCount: number; + }) { + return yield* request(ORCHESTRATION_V2_WS_METHODS.prepareThreadHandoff, { + threadId: input.threadId, + peerEnvironmentId: input.peerEnvironmentId, + peerBranchTip: input.peerBranchTip, + fullHistory: input.fullHistory, + previousHandoffId: input.previousHandoffId, + hopCount: input.hopCount, + }); + }, +); + +/** Applies a staged bundle on the environment that is taking the thread. */ +export const receiveThreadHandoff = Effect.fn("EnvironmentCommands.receiveThreadHandoff")( + function* (input: { + readonly bundle: OrchestrationV2HandoffBundleV1; + readonly projectId: ProjectId | null; + readonly cloneWorkspaceRoot: string | null; + readonly returningThreadId: ThreadId | null; + }) { + return yield* request(ORCHESTRATION_V2_WS_METHODS.receiveThreadHandoff, { + bundle: input.bundle, + projectId: input.projectId, + cloneWorkspaceRoot: input.cloneWorkspaceRoot, + returningThreadId: input.returningThreadId, + }); + }, +); + +/** + * Locks the giving side. Dispatched before any bundle is applied anywhere, so + * the two sides can never both be live; a transfer that dies after this leaves + * a locked thread that `abortThreadHandoff` releases. + */ +export const departThread = Effect.fn("EnvironmentCommands.departThread")(function* (input: { + readonly threadId: ThreadId; + readonly handoffId: ThreadHandoffId; + readonly peerEnvironmentId: EnvironmentId; + readonly peerLabel: string | null; + readonly previousHandoffId: ThreadHandoffId | null; + readonly hopCount: number; +}) { + return yield* request(ORCHESTRATION_V2_WS_METHODS.dispatchCommand, { + type: "thread.handoff.depart", + commandId: yield* nextCommandId, + threadId: input.threadId, + handoffId: input.handoffId, + peerEnvironmentId: input.peerEnvironmentId, + peerLabel: input.peerLabel, + previousHandoffId: input.previousHandoffId, + hopCount: input.hopCount, + }); +}); + +/** Records the peer's thread id on the giving side once the bundle has landed. */ +export const completeThreadHandoff = Effect.fn("EnvironmentCommands.completeThreadHandoff")( + function* (input: { + readonly threadId: ThreadId; + readonly handoffId: ThreadHandoffId; + readonly peerThreadId: ThreadId; + }) { + return yield* request(ORCHESTRATION_V2_WS_METHODS.dispatchCommand, { + type: "thread.handoff.complete", + commandId: yield* nextCommandId, + threadId: input.threadId, + handoffId: input.handoffId, + peerThreadId: input.peerThreadId, + }); + }, +); + +/** Releases a departed thread whose transfer never landed. */ +export const abortThreadHandoff = Effect.fn("EnvironmentCommands.abortThreadHandoff")( + function* (input: { + readonly threadId: ThreadId; + readonly handoffId: ThreadHandoffId; + readonly reason: string | null; + }) { + return yield* request(ORCHESTRATION_V2_WS_METHODS.dispatchCommand, { + type: "thread.handoff.abort", + commandId: yield* nextCommandId, + threadId: input.threadId, + handoffId: input.handoffId, + reason: input.reason, + }); + }, +); diff --git a/packages/client-runtime/src/state/handoffPartsHttp.ts b/packages/client-runtime/src/state/handoffPartsHttp.ts new file mode 100644 index 00000000000..7026f9096b5 --- /dev/null +++ b/packages/client-runtime/src/state/handoffPartsHttp.ts @@ -0,0 +1,80 @@ +import type { OrchestrationV2HandoffPartKind, ThreadHandoffId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import type { PreparedConnection } from "../connection/model.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; +import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; + +// Generous next to the snapshot timeout: a chunk is up to a few megabytes and +// may be crossing a home connection, and a transfer that times out mid-part is +// worse than one that takes a while. +const DEFAULT_PART_TIMEOUT_MS = 120_000; + +interface PartTarget { + readonly prepared: PreparedConnection; + readonly signer: Option.Option; + readonly handoffId: ThreadHandoffId; + readonly kind: OrchestrationV2HandoffPartKind; + readonly timeoutMs?: number; +} + +const partUrl = (target: PartTarget, suffix: string) => + environmentEndpointUrl( + target.prepared.httpBaseUrl, + `/api/orchestration/handoffs/${target.handoffId}/parts/${target.kind}${suffix}`, + ); + +/** Reads one chunk of a staged part from the environment that holds it. */ +export const readHandoffPartChunk = Effect.fn("clientRuntime.state.readHandoffPartChunk")( + function* (input: PartTarget & { readonly offset: number }) { + const requestUrl = partUrl(input, "/read"); + const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); + const headers = yield* buildEnvironmentAuthHeaders( + input.prepared.httpAuthorization, + "POST", + requestUrl, + input.signer, + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + input.timeoutMs ?? DEFAULT_PART_TIMEOUT_MS, + withEnvironmentCredentials( + input.prepared.httpAuthorization, + client.orchestration.readHandoffPart({ + params: { handoffId: input.handoffId, kind: input.kind }, + payload: { offset: input.offset }, + headers, + }), + ), + ); + }, +); + +/** Appends one chunk to a staged part on the environment that is receiving it. */ +export const writeHandoffPartChunk = Effect.fn("clientRuntime.state.writeHandoffPartChunk")( + function* (input: PartTarget & { readonly offset: number; readonly data: Uint8Array }) { + const requestUrl = partUrl(input, ""); + const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); + const headers = yield* buildEnvironmentAuthHeaders( + input.prepared.httpAuthorization, + "POST", + requestUrl, + input.signer, + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + input.timeoutMs ?? DEFAULT_PART_TIMEOUT_MS, + withEnvironmentCredentials( + input.prepared.httpAuthorization, + client.orchestration.writeHandoffPart({ + params: { handoffId: input.handoffId, kind: input.kind }, + payload: { offset: input.offset, data: input.data }, + headers, + }), + ), + ); + }, +); diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 9ac77cda10a..777471d1296 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -107,6 +107,11 @@ export interface EnvironmentThreadShell { readonly lastVisitedAt?: string | null; /** Pending title regeneration marker; null when no request is in flight. */ readonly titleRegeneration?: { readonly requestId: string; readonly startedAt: string } | null; + /** + * Set while this thread is one side of a handoff. `undefined` means the + * environment's server predates handoff, so a client offers no move. + */ + readonly handoff?: OrchestrationV2ThreadShell["handoff"]; readonly deletedAt: string | null; readonly source: OrchestrationV2ThreadShell; } @@ -220,6 +225,7 @@ export function presentThreadShell( requestId: thread.titleRegeneration.requestId, startedAt: iso(thread.titleRegeneration.startedAt), }, + ...(thread.handoff === undefined ? {} : { handoff: thread.handoff }), deletedAt: nullableIso(thread.deletedAt), source: thread, }; diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index 8ca83a590ff..b064716c184 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -119,6 +119,10 @@ export function applyOrchestrationV2ProjectionEvent( case "thread.interaction-mode-updated": case "thread.model-selection-updated": case "thread.provider-switched": + case "thread.handoff-departed": + case "thread.handoff-arrived": + case "thread.handoff-returned": + case "thread.handoff-failed": return { ...base, thread: event.payload }; // Visited tracking is read state, not activity: skip the updatedAt bump. case "thread.visited": diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 2fb303d6bcc..1f59d001d4d 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -1,3 +1,4 @@ +import type { ThreadHandoffId, ThreadId } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import { Atom } from "effect/unstable/reactivity"; @@ -58,6 +59,7 @@ import { updateThreadMetadata, visitThread, } from "../operations/commands.ts"; +import { abortThreadHandoff } from "../operations/threadHandoff.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; export type { @@ -113,6 +115,17 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + releaseHandoff: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:handoff:release", + execute: (input: { readonly threadId: ThreadId; readonly handoffId: ThreadHandoffId }) => + abortThreadHandoff({ + threadId: input.threadId, + handoffId: input.handoffId, + reason: "released by user", + }), + scheduler, + concurrency, + }), archive: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:archive", execute: (input: ArchiveThreadInput) => archiveThread(input), diff --git a/packages/client-runtime/src/state/threadHandoffCommands.ts b/packages/client-runtime/src/state/threadHandoffCommands.ts new file mode 100644 index 00000000000..f51d50c6da0 --- /dev/null +++ b/packages/client-runtime/src/state/threadHandoffCommands.ts @@ -0,0 +1,104 @@ +import type { EnvironmentId, ProjectId, ThreadHandoffId, ThreadId } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { HttpClient } from "effect/unstable/http"; +import type { Atom } from "effect/unstable/reactivity"; + +import type { PreparedConnection } from "../connection/model.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { createAtomCommandScheduler, createRuntimeCommand, runInEnvironment } from "./runtime.ts"; +import { runThreadHandoffTransfer, type ThreadHandoffProgress } from "./threadHandoffTransfer.ts"; + +export interface MoveThreadInput { + readonly threadId: ThreadId; + readonly originEnvironmentId: EnvironmentId; + readonly targetEnvironmentId: EnvironmentId; + readonly targetLabel: string | null; + readonly targetProjectId: ProjectId | null; + readonly cloneWorkspaceRoot: string | null; + readonly returningThreadId: ThreadId | null; + readonly targetBranchTip: string | null; + readonly previousHandoffId: ThreadHandoffId | null; + readonly hopCount: number; + readonly onProgress?: (progress: ThreadHandoffProgress) => void; +} + +/** + * The live connection for one environment. A hop needs both in hand before it + * starts: discovering halfway through that the destination was never reachable + * would mean locking a thread for a transfer that could not have worked. + */ +const connectionFor = Effect.fn("clientRuntime.state.handoffConnection")(function* ( + environmentId: EnvironmentId, +) { + const prepared = yield* runInEnvironment( + environmentId, + EnvironmentSupervisor.pipe( + Effect.flatMap((supervisor) => SubscriptionRef.get(supervisor.prepared)), + ), + ); + if (Option.isNone(prepared)) { + return yield* Effect.fail(new ThreadHandoffEnvironmentUnreachableError({ environmentId })); + } + return prepared.value satisfies PreparedConnection; +}); + +export class ThreadHandoffEnvironmentUnreachableError extends Schema.TaggedErrorClass()( + "ThreadHandoffEnvironmentUnreachableError", + { environmentId: Schema.String }, +) { + override get message(): string { + return `Environment ${this.environmentId} is not connected, so a thread cannot be moved to or from it.`; + } +} + +/** + * A handoff spans two environments, so it is a runtime command rather than an + * environment one: neither side owns it, and both connections have to be in + * hand before the first byte moves. + */ +export function createThreadHandoffAtoms( + runtime: Atom.AtomRuntime, +) { + const scheduler = createAtomCommandScheduler(); + return { + move: createRuntimeCommand(runtime, { + label: "environment-data:commands:thread:handoff:move", + scheduler, + // One hop at a time per thread. Two concurrent hops would each try to + // lock the same thread, and the second would fail after the first had + // already moved bytes. + concurrency: { + mode: "serial" as const, + key: (input: MoveThreadInput) => input.threadId, + }, + execute: (input: MoveThreadInput) => + Effect.gen(function* () { + const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); + const originConnection = yield* connectionFor(input.originEnvironmentId); + const targetConnection = yield* connectionFor(input.targetEnvironmentId); + return yield* runThreadHandoffTransfer({ + threadId: input.threadId, + originEnvironmentId: input.originEnvironmentId, + targetEnvironmentId: input.targetEnvironmentId, + targetLabel: input.targetLabel, + targetProjectId: input.targetProjectId, + cloneWorkspaceRoot: input.cloneWorkspaceRoot, + returningThreadId: input.returningThreadId, + targetBranchTip: input.targetBranchTip, + previousHandoffId: input.previousHandoffId, + hopCount: input.hopCount, + originConnection, + targetConnection, + signer: signer as Option.Option, + ...(input.onProgress === undefined ? {} : { onProgress: input.onProgress }), + }); + }), + }), + }; +} diff --git a/packages/client-runtime/src/state/threadHandoffTransfer.ts b/packages/client-runtime/src/state/threadHandoffTransfer.ts new file mode 100644 index 00000000000..f6724f6184e --- /dev/null +++ b/packages/client-runtime/src/state/threadHandoffTransfer.ts @@ -0,0 +1,213 @@ +import type { + EnvironmentId, + OrchestrationV2HandoffBundleV1, + OrchestrationV2HandoffPart, + ProjectId, + ThreadHandoffId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import type { PreparedConnection } from "../connection/model.ts"; +import { + abortThreadHandoff, + completeThreadHandoff, + departThread, + prepareThreadHandoff, + receiveThreadHandoff, +} from "../operations/threadHandoff.ts"; +import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { readHandoffPartChunk, writeHandoffPartChunk } from "./handoffPartsHttp.ts"; +import { runInEnvironment } from "./runtime.ts"; + +/** + * The steps of a hop, in the order they happen. + * + * The split matters for what a user is allowed to do: everything up to and + * including `upload` has changed nothing on either machine, so cancelling is + * free. From `apply` onward the receiving repository is being written to, and + * only the servers can put it back. + */ +export type ThreadHandoffPhase = "prepare" | "depart" | "upload" | "apply" | "settle"; + +export interface ThreadHandoffProgress { + readonly phase: ThreadHandoffPhase; + readonly transferredBytes: number; + readonly totalBytes: number; +} + +export interface ThreadHandoffTransferInput { + readonly threadId: ThreadId; + readonly originEnvironmentId: EnvironmentId; + readonly targetEnvironmentId: EnvironmentId; + readonly targetLabel: string | null; + /** Null when the destination lacks the repository and must clone it. */ + readonly targetProjectId: ProjectId | null; + /** Where the destination should clone when it lacks the repository. */ + readonly cloneWorkspaceRoot: string | null; + /** Set when the hop returns to a thread the target already owns. */ + readonly returningThreadId: ThreadId | null; + /** The target's tip for this branch, so the bundle carries only what it lacks. */ + readonly targetBranchTip: string | null; + readonly previousHandoffId: ThreadHandoffId | null; + readonly hopCount: number; + readonly originConnection: PreparedConnection; + readonly targetConnection: PreparedConnection; + readonly signer: Option.Option; + readonly onProgress?: (progress: ThreadHandoffProgress) => void; +} + +export interface ThreadHandoffTransferResult { + readonly handoffId: ThreadHandoffId; + readonly targetThreadId: ThreadId; +} + +/** + * Copies one part across, a chunk at a time. + * + * The receiving side rejects a chunk that does not continue exactly where the + * staged bytes end, so the offset the reader reports is the only thing that + * decides where a write lands — a retried or reordered chunk cannot punch a + * hole in the part. + */ +const copyPart = Effect.fn("clientRuntime.state.copyHandoffPart")(function* (input: { + readonly part: OrchestrationV2HandoffPart; + readonly handoffId: ThreadHandoffId; + readonly origin: ThreadHandoffTransferInput["originConnection"]; + readonly target: ThreadHandoffTransferInput["targetConnection"]; + readonly signer: ThreadHandoffTransferInput["signer"]; + readonly onChunk: (bytes: number) => void; +}) { + let offset = 0; + let complete = false; + while (!complete) { + const chunk = yield* readHandoffPartChunk({ + prepared: input.origin, + signer: input.signer, + handoffId: input.handoffId, + kind: input.part.kind, + offset, + }); + if (chunk.data.length > 0) { + yield* writeHandoffPartChunk({ + prepared: input.target, + signer: input.signer, + handoffId: input.handoffId, + kind: input.part.kind, + offset: chunk.offset, + data: chunk.data, + }); + input.onChunk(chunk.data.length); + } + offset = chunk.offset + chunk.data.length; + complete = chunk.complete; + } +}); + +/** + * Runs one hop end to end across two environments. + * + * The order is the safety model: stage, lock the giving side, move the bytes, + * apply, then record where the thread went. Locking before the bytes move is + * what guarantees the two sides can never both be live; anything that fails + * after the lock releases it, so a failed transfer leaves the thread usable + * where it started rather than stranded. + */ +export const runThreadHandoffTransfer = Effect.fn("clientRuntime.state.runThreadHandoffTransfer")( + function* (input: ThreadHandoffTransferInput) { + const report = (phase: ThreadHandoffPhase, transferredBytes: number, totalBytes: number) => { + input.onProgress?.({ phase, transferredBytes, totalBytes }); + }; + + report("prepare", 0, 0); + const preparation = yield* runInEnvironment( + input.originEnvironmentId, + prepareThreadHandoff({ + threadId: input.threadId, + peerEnvironmentId: input.targetEnvironmentId, + peerBranchTip: input.targetBranchTip, + // No project on the destination means it will clone from the bundle, + // which only works when the bundle carries the whole history. + fullHistory: input.targetProjectId === null, + previousHandoffId: input.previousHandoffId, + hopCount: input.hopCount, + }), + ); + const bundle: OrchestrationV2HandoffBundleV1 = preparation.bundle; + const totalBytes = preparation.totalBytes; + + report("depart", 0, totalBytes); + yield* runInEnvironment( + input.originEnvironmentId, + departThread({ + threadId: input.threadId, + handoffId: bundle.handoffId, + peerEnvironmentId: input.targetEnvironmentId, + peerLabel: input.targetLabel, + previousHandoffId: input.previousHandoffId, + hopCount: input.hopCount, + }), + ); + + // From here on the thread is locked, so every failure has to release it + // before surfacing — otherwise a transient network error would leave a + // thread nobody can type in on either machine. + const release = (reason: string) => + runInEnvironment( + input.originEnvironmentId, + abortThreadHandoff({ + threadId: input.threadId, + handoffId: bundle.handoffId, + reason, + }), + ).pipe(Effect.ignore); + + return yield* Effect.gen(function* () { + let transferred = 0; + report("upload", transferred, totalBytes); + for (const part of bundle.parts) { + yield* copyPart({ + part, + handoffId: bundle.handoffId, + origin: input.originConnection, + target: input.targetConnection, + signer: input.signer, + onChunk: (bytes) => { + transferred += bytes; + report("upload", transferred, totalBytes); + }, + }); + } + + report("apply", transferred, totalBytes); + const application = yield* runInEnvironment( + input.targetEnvironmentId, + receiveThreadHandoff({ + bundle, + projectId: input.targetProjectId, + cloneWorkspaceRoot: input.cloneWorkspaceRoot, + returningThreadId: input.returningThreadId, + }), + ); + + report("settle", transferred, totalBytes); + yield* runInEnvironment( + input.originEnvironmentId, + completeThreadHandoff({ + threadId: input.threadId, + handoffId: bundle.handoffId, + peerThreadId: application.threadId, + }), + ); + + return { + handoffId: bundle.handoffId, + targetThreadId: application.threadId, + } satisfies ThreadHandoffTransferResult; + }).pipe( + Effect.tapCause((cause) => release(String(cause))), + Effect.onInterrupt(() => release("transfer cancelled")), + ); + }, +); diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index f06b39b8eab..338c9506e9d 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -109,6 +109,13 @@ export const ContextHandoffId = makeEntityId("ContextHandoffId"); export type ContextHandoffId = typeof ContextHandoffId.Type; export const ContextTransferId = makeEntityId("ContextTransferId"); export type ContextTransferId = typeof ContextTransferId.Type; +/** + * Identifies one hop of a thread handoff. Both environments involved in a hop + * store the same id, so it is the idempotency key for applying a bundle and + * the link between the two sides of the lineage. + */ +export const ThreadHandoffId = makeEntityId("ThreadHandoffId"); +export type ThreadHandoffId = typeof ThreadHandoffId.Type; export const RawEventId = makeEntityId("RawEventId"); export type RawEventId = typeof RawEventId.Type; export const PlanId = makeEntityId("PlanId"); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 75fbc5f9e76..a42902439ab 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -65,6 +65,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server can stream self-update progress before acknowledging the restart. Clients fall back to server.updateServer when absent. */ serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean), + /** Server can send and receive thread handoff bundles. Same version-skew + contract as threadSettlement: absent means unsupported, so clients list + such an environment as an unavailable destination rather than starting a + transfer that would fail once the bytes are already moving. */ + threadHandoff: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 93a7aa9e66e..04ae63ace62 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -24,8 +24,15 @@ import { AuthWebSocketTicketResult, ServerAuthSessionMethod, } from "./auth.ts"; -import { AuthSessionId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + AuthSessionId, + NonNegativeInt, + ThreadHandoffId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { OrchestrationV2HandoffPartKind } from "./orchestrationV2.ts"; import { OrchestrationV2ShellSnapshot, OrchestrationV2ThreadDetailSnapshot, @@ -54,6 +61,8 @@ export const EnvironmentRequestInvalidReason = Schema.Literals([ "invalid_scope", "scope_not_granted", "invalid_command", + /** A handoff part chunk that does not continue the bytes already staged. */ + "handoff_part_offset_mismatch", ]); export type EnvironmentRequestInvalidReason = typeof EnvironmentRequestInvalidReason.Type; @@ -83,6 +92,7 @@ export const EnvironmentInternalErrorReason = Schema.Literals([ "project_mutation_failed", "orchestration_snapshot_failed", "orchestration_thread_snapshot_failed", + "orchestration_handoff_part_failed", "internal_error", ]); export type EnvironmentInternalErrorReason = typeof EnvironmentInternalErrorReason.Type; @@ -157,7 +167,10 @@ export class EnvironmentInternalError extends Schema.TaggedErrorClass()( @@ -298,6 +311,12 @@ const EnvironmentOrchestrationThreadSnapshotErrors = [ EnvironmentResourceNotFoundError, EnvironmentInternalError, ] as const; +const EnvironmentOrchestrationHandoffPartErrors = [ + EnvironmentRequestInvalidError, + EnvironmentScopeRequiredError, + EnvironmentResourceNotFoundError, + EnvironmentInternalError, +] as const; const EnvironmentProjectMutationErrors = [ EnvironmentRequestInvalidError, EnvironmentScopeRequiredError, @@ -460,6 +479,47 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ threadId: ThreadId, }); +/** + * Handoff part bytes move in chunks rather than as one body. + * + * A part can be up to the payload ceiling, so streaming it in bounded pieces + * keeps both servers from holding a whole payload in memory, and makes a + * transfer that dies partway resumable from the offset it reached instead of + * starting over. + */ +export const ENVIRONMENT_HANDOFF_PART_CHUNK_BYTES = 4 * 1024 * 1024; + +const EnvironmentHandoffPartParams = Schema.Struct({ + handoffId: ThreadHandoffId, + kind: OrchestrationV2HandoffPartKind, +}); + +export const EnvironmentHandoffPartRead = Schema.Struct({ + offset: NonNegativeInt, +}); +export type EnvironmentHandoffPartRead = typeof EnvironmentHandoffPartRead.Type; + +export const EnvironmentHandoffPartChunk = Schema.Struct({ + offset: NonNegativeInt, + totalBytes: NonNegativeInt, + data: Schema.Uint8Array, + /** True when this chunk reaches the end of the part. */ + complete: Schema.Boolean, +}); +export type EnvironmentHandoffPartChunk = typeof EnvironmentHandoffPartChunk.Type; + +export const EnvironmentHandoffPartWrite = Schema.Struct({ + /** Byte offset this chunk starts at; a write that does not continue the staged part is rejected. */ + offset: NonNegativeInt, + data: Schema.Uint8Array, +}); +export type EnvironmentHandoffPartWrite = typeof EnvironmentHandoffPartWrite.Type; + +export const EnvironmentHandoffPartWriteResult = Schema.Struct({ + receivedBytes: NonNegativeInt, +}); +export type EnvironmentHandoffPartWriteResult = typeof EnvironmentHandoffPartWriteResult.Type; + export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") .add( HttpApiEndpoint.get("shellSnapshot", "/api/orchestration/shell", { @@ -475,6 +535,31 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr success: OrchestrationV2ThreadDetailSnapshot, error: EnvironmentOrchestrationThreadSnapshotErrors, }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + // 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. + HttpApiEndpoint.post( + "readHandoffPart", + "/api/orchestration/handoffs/:handoffId/parts/:kind/read", + { + headers: OptionalBearerHeaders, + params: EnvironmentHandoffPartParams, + payload: EnvironmentHandoffPartRead, + success: EnvironmentHandoffPartChunk, + error: EnvironmentOrchestrationHandoffPartErrors, + }, + ).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("writeHandoffPart", "/api/orchestration/handoffs/:handoffId/parts/:kind", { + headers: OptionalBearerHeaders, + params: EnvironmentHandoffPartParams, + payload: EnvironmentHandoffPartWrite, + success: EnvironmentHandoffPartWriteResult, + error: EnvironmentOrchestrationHandoffPartErrors, + }).middleware(EnvironmentAuthenticatedAuth), ) {} export class EnvironmentProjectsHttpApi extends HttpApiGroup.make("projects") diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index 21ec7e75e21..f8e20110f09 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -20,10 +20,13 @@ import { TurnItemId, } from "./index.ts"; import { + ORCHESTRATION_V2_HANDOFF_PAYLOAD_MAX_BYTES, + ORCHESTRATION_V2_HANDOFF_PAYLOAD_WARN_BYTES, OrchestrationV2Checkpoint, OrchestrationV2CheckpointScope, OrchestrationV2Command, OrchestrationV2DomainEvent, + OrchestrationV2HandoffBundleV1, OrchestrationV2ProviderThread, OrchestrationV2ProviderThreadJson, OrchestrationV2ShellSnapshot, @@ -770,3 +773,124 @@ describe("orchestration V2 contracts", () => { expect(shell.pendingBackgroundTasks).toEqual([]); }); }); + +describe("thread handoff bundle", () => { + const decodeBundle = Schema.decodeUnknownSync(OrchestrationV2HandoffBundleV1); + const digest = "a".repeat(64); + + const bundle = (overrides: Record = {}) => ({ + version: 1, + handoffId: "handoff-1", + origin: { + environmentId: "environment-mac", + threadId: "thread-1", + serverVersion: "0.1.0", + }, + repository: { + canonicalKey: "github.com/pingdotgg/t3code", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.com:pingdotgg/t3code.git", + }, + }, + workspace: { + branch: "feat/thread-handoff", + headSha: "a91f2c4", + strategy: { type: "root" }, + }, + conversation: { + items: [], + coveredRunOrdinals: [0, 1], + }, + provider: { + driverKind: "claude", + modelSelection: { + instanceId: "claude-default", + model: "claude-sonnet-4-6", + }, + runtimeMode: "full-access", + interactionMode: "default", + }, + thread: { title: "Thread handoff" }, + terminals: [], + lineage: { previousHandoffId: null, hopCount: 0 }, + parts: [{ kind: "git-bundle", digest, byteLength: 4096 }], + ...overrides, + }); + + it("decodes a bundle with no previous hop", () => { + const decoded = decodeBundle(bundle()); + + expect(decoded.lineage).toEqual({ previousHandoffId: null, hopCount: 0 }); + expect(decoded.parts[0]?.kind).toBe("git-bundle"); + }); + + it("carries the previous hop so a chain can be walked back", () => { + const decoded = decodeBundle( + bundle({ lineage: { previousHandoffId: "handoff-0", hopCount: 1 } }), + ); + + expect(decoded.lineage.previousHandoffId).toBe("handoff-0"); + expect(decoded.lineage.hopCount).toBe(1); + }); + + it("keeps the workspace strategy so a worktree thread can be reprovisioned", () => { + const decoded = decodeBundle( + bundle({ + workspace: { + branch: "feat/thread-handoff", + headSha: "a91f2c4", + strategy: { type: "worktree", baseRef: "main", branch: "feat/thread-handoff" }, + }, + }), + ); + + expect(decoded.workspace.strategy).toEqual({ + type: "worktree", + baseRef: "main", + branch: "feat/thread-handoff", + }); + }); + + it("restores terminals by relative path so the receiver can rebase them", () => { + const decoded = decodeBundle( + bundle({ + terminals: [ + { + terminalId: "terminal-1", + title: "dev server", + relativeCwd: "apps/server", + shell: "/bin/zsh", + history: "$ pnpm dev\n", + }, + ], + }), + ); + + expect(decoded.terminals[0]?.relativeCwd).toBe("apps/server"); + expect(decoded.terminals[0]?.history).toBe("$ pnpm dev\n"); + }); + + it("rejects a part digest that is not a sha-256", () => { + expect(() => + decodeBundle(bundle({ parts: [{ kind: "git-bundle", digest: "nope", byteLength: 1 }] })), + ).toThrow(); + }); + + it("rejects an unknown part kind rather than moving bytes it cannot place", () => { + expect(() => + decodeBundle(bundle({ parts: [{ kind: "surprise-tar", digest, byteLength: 1 }] })), + ).toThrow(); + }); + + it("rejects a bundle version it was not written for", () => { + expect(() => decodeBundle(bundle({ version: 2 }))).toThrow(); + }); + + it("refuses at a payload ceiling above the warning threshold", () => { + expect(ORCHESTRATION_V2_HANDOFF_PAYLOAD_MAX_BYTES).toBeGreaterThan( + ORCHESTRATION_V2_HANDOFF_PAYLOAD_WARN_BYTES, + ); + }); +}); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index c4c0d018e03..4b798342a81 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -8,6 +8,7 @@ import { CommandId, ContextHandoffId, ContextTransferId, + EnvironmentId, EventId, IsoDateTime, MessageId, @@ -23,11 +24,13 @@ import { RunAttemptId, RunId, RuntimeRequestId, + ThreadHandoffId, ThreadId, TrimmedNonEmptyString, TurnItemId, } from "./baseSchemas.ts"; import { ChatAttachment } from "./chatAttachment.ts"; +import { RepositoryIdentity } from "./environment.ts"; import { OrchestrationGetFullThreadDiffInput, OrchestrationGetFullThreadDiffResult, @@ -84,6 +87,31 @@ export const OrchestrationV2AppThreadLineage = Schema.Struct({ }); export type OrchestrationV2AppThreadLineage = typeof OrchestrationV2AppThreadLineage.Type; +/** + * Where this thread sits in a handoff, from this environment's point of view. + * + * `away` means the work moved to another environment and this side is a + * read-only record until it comes back; `here` means this environment received + * the thread and owns it. Exactly one side of a hop is `here`, which is what + * keeps two live conversations — and the unanswerable merge they would need — + * from existing in the first place. + */ +export const OrchestrationV2ThreadHandoffPresence = Schema.Literals(["away", "here"]); +export type OrchestrationV2ThreadHandoffPresence = typeof OrchestrationV2ThreadHandoffPresence.Type; + +export const OrchestrationV2ThreadHandoffLink = Schema.Struct({ + handoffId: ThreadHandoffId, + presence: OrchestrationV2ThreadHandoffPresence, + peerEnvironmentId: EnvironmentId, + /** The peer's thread id, once the peer has reported it. */ + peerThreadId: Schema.NullOr(ThreadId), + peerLabel: Schema.NullOr(TrimmedNonEmptyString), + previousHandoffId: Schema.NullOr(ThreadHandoffId), + hopCount: NonNegativeInt, + updatedAt: Schema.DateTimeUtc, +}); +export type OrchestrationV2ThreadHandoffLink = typeof OrchestrationV2ThreadHandoffLink.Type; + export const OrchestrationV2ContextTransferType = Schema.Literals([ "fork", "provider_handoff", @@ -328,6 +356,12 @@ export const OrchestrationV2AppThread = Schema.Struct({ lastVisitedAt: Schema.NullOr(Schema.DateTimeUtc).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + /** + * Set while this thread is one side of a handoff. Absent on threads that + * have never left the environment they were created in, so older servers + * and older stored payloads decode unchanged. + */ + handoff: Schema.optional(Schema.NullOr(OrchestrationV2ThreadHandoffLink)), /** In-flight title regeneration marker; cleared when a new title lands. */ titleRegeneration: Schema.optional( Schema.NullOr( @@ -1105,6 +1139,10 @@ export const OrchestrationV2DomainEvent = Schema.Union([ "thread.interaction-mode-updated", "thread.model-selection-updated", "thread.provider-switched", + "thread.handoff-departed", + "thread.handoff-arrived", + "thread.handoff-returned", + "thread.handoff-failed", ]), payload: OrchestrationV2AppThread, }), @@ -1315,6 +1353,11 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ }), ), ), + /** + * Carried on the shell so a sidebar can mark a thread as away or here + * without loading its detail. Omitted by servers that predate handoff. + */ + handoff: Schema.optional(Schema.NullOr(OrchestrationV2ThreadHandoffLink)), deletedAt: Schema.NullOr(Schema.DateTimeUtc), }); export type OrchestrationV2ThreadShell = typeof OrchestrationV2ThreadShell.Type; @@ -1401,6 +1444,14 @@ export const OrchestrationV2AppThreadJson = OrchestrationV2AppThread.mapFields(( }), ), ), + handoff: Schema.optional( + Schema.NullOr( + OrchestrationV2ThreadHandoffLink.mapFields((linkFields) => ({ + ...linkFields, + updatedAt: Schema.DateTimeUtcFromString, + })), + ), + ), deletedAt: Schema.NullOr(Schema.DateTimeUtcFromString), })); export type OrchestrationV2AppThreadJson = typeof OrchestrationV2AppThreadJson.Type; @@ -1774,6 +1825,14 @@ export const OrchestrationV2ThreadShellJson = OrchestrationV2ThreadShell.mapFiel snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), lastVisitedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), + handoff: Schema.optional( + Schema.NullOr( + OrchestrationV2ThreadHandoffLink.mapFields((linkFields) => ({ + ...linkFields, + updatedAt: Schema.DateTimeUtcFromString, + })), + ), + ), deletedAt: Schema.NullOr(Schema.DateTimeUtcFromString), })); export type OrchestrationV2ThreadShellJson = typeof OrchestrationV2ThreadShellJson.Type; @@ -1825,6 +1884,10 @@ export const OrchestrationV2DomainEventJson = Schema.Union([ "thread.interaction-mode-updated", "thread.model-selection-updated", "thread.provider-switched", + "thread.handoff-departed", + "thread.handoff-arrived", + "thread.handoff-returned", + "thread.handoff-failed", ]), payload: OrchestrationV2AppThreadJson, }), @@ -2226,6 +2289,39 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, modelSelection: ModelSelection, }), + /** + * Hands this thread's work to another environment. Dispatched before any + * bundle is applied there: locking the side that is giving the thread up + * first is what guarantees the two sides can never both be live, and an + * interrupted transfer leaves a locked thread that `thread.handoff.abort` + * releases rather than a second conversation nobody can merge. + */ + Schema.Struct({ + type: Schema.Literal("thread.handoff.depart"), + commandId: CommandId, + threadId: ThreadId, + handoffId: ThreadHandoffId, + peerEnvironmentId: EnvironmentId, + peerLabel: Schema.NullOr(TrimmedNonEmptyString), + previousHandoffId: Schema.NullOr(ThreadHandoffId), + hopCount: NonNegativeInt, + }), + /** Records the peer's thread id once it has confirmed the bundle landed. */ + Schema.Struct({ + type: Schema.Literal("thread.handoff.complete"), + commandId: CommandId, + threadId: ThreadId, + handoffId: ThreadHandoffId, + peerThreadId: ThreadId, + }), + /** Releases a departed thread whose transfer never landed. */ + Schema.Struct({ + type: Schema.Literal("thread.handoff.abort"), + commandId: CommandId, + threadId: ThreadId, + handoffId: ThreadHandoffId, + reason: Schema.NullOr(TrimmedNonEmptyString), + }), ]); export type OrchestrationV2Command = typeof OrchestrationV2Command.Type; @@ -2238,6 +2334,8 @@ export const ORCHESTRATION_V2_WS_METHODS = { getThreadProjection: "orchestration.getThreadProjection", getWorkflowScript: "orchestration.getWorkflowScript", launchThread: "orchestration.launchThread", + prepareThreadHandoff: "orchestration.prepareThreadHandoff", + receiveThreadHandoff: "orchestration.receiveThreadHandoff", subscribeArchivedShell: "orchestration.subscribeArchivedShell", subscribeShell: "orchestration.subscribeShell", subscribeThread: "orchestration.subscribeThread", @@ -2319,6 +2417,207 @@ export const OrchestrationV2ThreadLaunchResult = Schema.Struct({ }); export type OrchestrationV2ThreadLaunchResult = typeof OrchestrationV2ThreadLaunchResult.Type; +/** + * Thread handoff moves one 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. + * + * The bundle below is the wire contract between the two servers. It is a + * manifest plus content-addressed parts: everything small and structural is + * inline, everything large is a part fetched separately by digest. Keeping the + * bytes out of the manifest is what allows the transport to change later — + * today the client reads from one environment and writes to the other, because + * it is the only component authenticated to both — without the manifest or the + * ids changing with it. + */ +export const OrchestrationV2HandoffPartKind = Schema.Literals([ + "git-bundle", + "tracked-patch", + "untracked-tar", + "attachments-tar", + "terminals-tar", +]); +export type OrchestrationV2HandoffPartKind = typeof OrchestrationV2HandoffPartKind.Type; + +/** Lowercase hex SHA-256, the address of a part's bytes. */ +export const OrchestrationV2HandoffPartDigest = TrimmedNonEmptyString.check( + Schema.isPattern(/^[0-9a-f]{64}$/), +); + +export const OrchestrationV2HandoffPart = Schema.Struct({ + kind: OrchestrationV2HandoffPartKind, + digest: OrchestrationV2HandoffPartDigest, + byteLength: NonNegativeInt, +}); +export type OrchestrationV2HandoffPart = typeof OrchestrationV2HandoffPart.Type; + +/** + * A terminal as it can be reconstructed elsewhere. The process itself cannot + * travel; the working directory, the shell, and the scrollback the user reads + * can. `cwd` is relative to the origin workspace root so the receiving + * environment can rebase it onto its own. + */ +export const OrchestrationV2HandoffTerminal = Schema.Struct({ + terminalId: TrimmedNonEmptyString, + title: Schema.NullOr(TrimmedNonEmptyString), + relativeCwd: Schema.String, + shell: Schema.NullOr(TrimmedNonEmptyString), + history: Schema.String, +}); +export type OrchestrationV2HandoffTerminal = typeof OrchestrationV2HandoffTerminal.Type; + +/** + * Where this hop sits in the chain. A round trip is not a special case: it is a + * hop whose destination is an environment already in the lineage, so laptop → + * server → phone → laptop is a walk rather than a pair of push/pull verbs. + */ +export const OrchestrationV2HandoffLineage = Schema.Struct({ + previousHandoffId: Schema.NullOr(ThreadHandoffId), + hopCount: NonNegativeInt, +}); +export type OrchestrationV2HandoffLineage = typeof OrchestrationV2HandoffLineage.Type; + +export const OrchestrationV2HandoffBundleV1 = Schema.Struct({ + version: Schema.Literal(1), + handoffId: ThreadHandoffId, + origin: Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, + serverVersion: TrimmedNonEmptyString, + /** The origin's human label, so the far side can say where the thread came + from without resolving an environment id it may not know. */ + label: Schema.optionalKey(TrimmedNonEmptyString), + }), + repository: RepositoryIdentity, + workspace: Schema.Struct({ + branch: Schema.NullOr(TrimmedNonEmptyString), + headSha: TrimmedNonEmptyString, + strategy: OrchestrationV2ThreadLaunchWorkspaceStrategy, + }), + conversation: Schema.Struct({ + items: Schema.Array(OrchestrationV2TurnItem), + coveredRunOrdinals: Schema.Array(NonNegativeInt), + }), + provider: Schema.Struct({ + driverKind: ProviderDriverKind, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + }), + thread: Schema.Struct({ + title: TrimmedNonEmptyString, + }), + terminals: Schema.Array(OrchestrationV2HandoffTerminal), + lineage: OrchestrationV2HandoffLineage, + parts: Schema.Array(OrchestrationV2HandoffPart), +}); +export type OrchestrationV2HandoffBundleV1 = typeof OrchestrationV2HandoffBundleV1.Type; + +/** + * Lifecycle of one hop, recorded on both sides. + * + * The origin moves preparing → departed and stays there until it observes + * `arrived` or `aborted`; the destination moves applying → arrived. `applying` + * is the only state in which the receiving repository has been written to, so + * it is also the only state that needs recovery on startup. + */ +export const OrchestrationV2HandoffState = Schema.Literals([ + "preparing", + "departed", + "applying", + "arrived", + "failed", + "aborted", +]); +export type OrchestrationV2HandoffState = typeof OrchestrationV2HandoffState.Type; + +export const OrchestrationV2HandoffErrorReason = Schema.Literals([ + "environment_unsupported", + "thread_missing", + "thread_already_away", + "thread_busy", + "repository_mismatch", + "project_missing", + "workspace_diverged", + "payload_too_large", + "part_missing", + "part_digest_mismatch", + "apply_failed", + "store_failed", +]); +export type OrchestrationV2HandoffErrorReason = typeof OrchestrationV2HandoffErrorReason.Type; + +export class OrchestrationV2HandoffError extends Schema.TaggedErrorClass()( + "OrchestrationV2HandoffError", + { + reason: OrchestrationV2HandoffErrorReason, + handoffId: Schema.optional(ThreadHandoffId), + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +/** + * Payload ceilings, in bytes, applied to the sum of every part. + * + * A dirty tree that has swallowed a build directory is the common case these + * catch, and the difference between the two is whether the user is told before + * or instead of the transfer. Both are enforced while preparing, so a refusal + * costs nothing on either machine. + */ +export const ORCHESTRATION_V2_HANDOFF_PAYLOAD_WARN_BYTES = 200 * 1024 * 1024; +export const ORCHESTRATION_V2_HANDOFF_PAYLOAD_MAX_BYTES = 1024 * 1024 * 1024; + +/** + * Asks the environment that owns a thread to stage a hop toward + * `peerEnvironmentId`. Read-only with respect to anything the user can see: + * the thread is not locked until `thread.handoff.depart` is dispatched, so a + * preflight the user then declines leaves no trace but staged bytes. + */ +export const OrchestrationV2PrepareHandoffInput = Schema.Struct({ + threadId: ThreadId, + peerEnvironmentId: EnvironmentId, + /** The destination's tip for this branch, so the bundle carries only what it lacks. */ + peerBranchTip: Schema.NullOr(TrimmedNonEmptyString), + /** + * Bundle the repository's entire history. Set when the destination does not + * have the repository at all, so the bundle is enough to clone from with no + * remote, credentials, or network on the far side. + */ + fullHistory: Schema.optionalKey(Schema.Boolean), + previousHandoffId: Schema.NullOr(ThreadHandoffId), + hopCount: NonNegativeInt, +}); +export type OrchestrationV2PrepareHandoffInput = typeof OrchestrationV2PrepareHandoffInput.Type; + +export const OrchestrationV2PrepareHandoffResult = Schema.Struct({ + bundle: OrchestrationV2HandoffBundleV1, + totalBytes: NonNegativeInt, + /** "warn" still transfers; the ceiling that refuses is enforced while preparing. */ + verdict: Schema.Literals(["ok", "warn"]), + dirtyFileCount: NonNegativeInt, + untrackedFileCount: NonNegativeInt, +}); +export type OrchestrationV2PrepareHandoffResult = typeof OrchestrationV2PrepareHandoffResult.Type; + +export const OrchestrationV2ReceiveHandoffInput = Schema.Struct({ + bundle: OrchestrationV2HandoffBundleV1, + /** Null when the repository must first be cloned from the bundle. */ + projectId: Schema.NullOr(ProjectId), + /** Where to clone when `projectId` is null; the project is created there. */ + cloneWorkspaceRoot: Schema.NullOr(TrimmedNonEmptyString), + /** Set when the hop returns to a thread this environment already owns. */ + returningThreadId: Schema.NullOr(ThreadId), +}); +export type OrchestrationV2ReceiveHandoffInput = typeof OrchestrationV2ReceiveHandoffInput.Type; + +export const OrchestrationV2ReceiveHandoffResult = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + classification: Schema.Literals(["advance", "absorb"]), +}); +export type OrchestrationV2ReceiveHandoffResult = typeof OrchestrationV2ReceiveHandoffResult.Type; + export const OrchestrationV2DispatchCommandResult = Schema.Struct({ sequence: NonNegativeInt, }); @@ -2508,6 +2807,14 @@ export const OrchestrationV2RpcSchemas = { input: OrchestrationV2ThreadLaunchInput, output: OrchestrationV2ThreadLaunchResult, }, + prepareThreadHandoff: { + input: OrchestrationV2PrepareHandoffInput, + output: OrchestrationV2PrepareHandoffResult, + }, + receiveThreadHandoff: { + input: OrchestrationV2ReceiveHandoffInput, + output: OrchestrationV2ReceiveHandoffResult, + }, subscribeArchivedShell: { input: Schema.Struct({}), output: OrchestrationV2ArchivedShellStreamItem, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0df214aaadb..4a1ee47333c 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -73,6 +73,7 @@ import { OrchestrationV2DispatchCommandError, OrchestrationV2GetShellSnapshotError, OrchestrationV2GetThreadProjectionError, + OrchestrationV2HandoffError, OrchestrationV2RpcSchemas, OrchestrationV2ThreadLaunchError, } from "./orchestrationV2.ts"; @@ -798,6 +799,24 @@ export const WsOrchestrationV2LaunchThreadRpc = Rpc.make(ORCHESTRATION_V2_WS_MET error: Schema.Union([OrchestrationV2ThreadLaunchError, EnvironmentAuthorizationError]), }); +export const WsOrchestrationV2PrepareThreadHandoffRpc = Rpc.make( + ORCHESTRATION_V2_WS_METHODS.prepareThreadHandoff, + { + payload: OrchestrationV2RpcSchemas.prepareThreadHandoff.input, + success: OrchestrationV2RpcSchemas.prepareThreadHandoff.output, + error: Schema.Union([OrchestrationV2HandoffError, EnvironmentAuthorizationError]), + }, +); + +export const WsOrchestrationV2ReceiveThreadHandoffRpc = Rpc.make( + ORCHESTRATION_V2_WS_METHODS.receiveThreadHandoff, + { + payload: OrchestrationV2RpcSchemas.receiveThreadHandoff.input, + success: OrchestrationV2RpcSchemas.receiveThreadHandoff.output, + error: Schema.Union([OrchestrationV2HandoffError, EnvironmentAuthorizationError]), + }, +); + export const WsOrchestrationV2SubscribeArchivedShellRpc = Rpc.make( ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell, { @@ -1005,6 +1024,8 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationV2GetArchivedShellSnapshotRpc, WsOrchestrationV2GetThreadProjectionRpc, WsOrchestrationV2LaunchThreadRpc, + WsOrchestrationV2PrepareThreadHandoffRpc, + WsOrchestrationV2ReceiveThreadHandoffRpc, WsOrchestrationV2SubscribeArchivedShellRpc, WsOrchestrationV2SubscribeShellRpc, WsOrchestrationV2SubscribeThreadRpc,