diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 9b4c90db159a..978622b6d9c3 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -85,6 +85,10 @@ export class GitWorkflowService extends Context.Service< { readonly commitSha: string; readonly remoteRefName: string }, GitCommandError >; + readonly resolveCommit: (input: { + readonly cwd: string; + readonly revision: string; + }) => Effect.Effect<{ readonly commitSha: string }, GitCommandError>; readonly removeWorktree: ( input: VcsRemoveWorktreeInput, ) => Effect.Effect; @@ -333,6 +337,10 @@ export const make = Effect.gen(function* () { ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe( Effect.andThen(git.resolveRemoteTrackingCommit(input)), ), + resolveCommit: (input) => + ensureGitCommand("GitWorkflowService.resolveCommit", input.cwd).pipe( + Effect.andThen(git.resolveCommit(input)), + ), removeWorktree: (input) => ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( Effect.andThen(git.removeWorktree(input)), diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index c116f21f9736..fb93b3973f61 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -22,6 +22,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as GitManager from "../git/GitManager.ts"; @@ -146,12 +147,21 @@ interface HarnessOptions { readonly newWorktreesStartFromOrigin?: boolean; readonly setupScript?: "started" | "no-script" | "fails" | "dies"; readonly dispatchFails?: boolean; + readonly threadAfterFailedDispatch?: { + readonly branch: string | null; + readonly worktreePath: string | null; + }; readonly dispatchDies?: boolean; readonly dispatchInterrupts?: boolean; readonly dispatchGate?: Effect.Effect; readonly threadAttachedOnRecheck?: boolean; + readonly threadAttachedOnCall?: number; readonly threadArchivedOnRecheck?: boolean; + readonly threadArchivedOnCall?: number; + readonly threadDeletedOnCall?: number; readonly threadReadFailsOnRecheck?: boolean; + readonly threadReadFailsAfterDispatch?: boolean; + readonly threadReadFailsOnCall?: number; readonly continuation?: "queued" | "fails" | "dies"; readonly projectMissing?: boolean; readonly projectReadFails?: boolean; @@ -160,13 +170,15 @@ interface HarnessOptions { readonly createWorktreeFails?: boolean; readonly fetchRemoteFails?: boolean; readonly resolveRemoteFails?: boolean; + readonly resolvedCommits?: ReadonlyArray; + readonly resolveCommitGate?: Effect.Effect; readonly removeWorktreeFails?: boolean; - readonly deleteLocalBranchFails?: boolean; readonly createWorktreeGate?: Effect.Effect; readonly refs?: ReadonlyArray<{ readonly name: string; readonly current: boolean; readonly isDefault: boolean; + readonly isRemote?: boolean; readonly worktreePath: string | null; }>; readonly worktrees?: ReadonlyArray<{ @@ -187,6 +199,7 @@ interface HarnessOptions { > >; readonly projectWorktreeRoot?: string; + readonly workspaceAliases?: Readonly>; readonly projectWorkspaceRoot?: string; readonly useRealNonRepositoryWorkflow?: boolean; readonly workspaceStatuses?: Readonly< @@ -194,25 +207,46 @@ interface HarnessOptions { >; readonly worktreeInventoryFailsFor?: ReadonlySet; readonly localStatusFailsOnCall?: number; + readonly dirtyOnLocalStatusCall?: number; readonly projectThreads?: ReadonlyArray<{ readonly id: ThreadId; readonly title: string; readonly branch: string | null; readonly worktreePath: string | null; + readonly status?: "idle" | "running"; readonly active?: boolean; }>; + readonly otherProjectThread?: { + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly id: ThreadId; + readonly title: string; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly active?: boolean; + }; readonly archivedProjectThread?: { readonly id: ThreadId; readonly title: string; readonly branch: string | null; readonly worktreePath: string | null; }; + readonly switchRefFails?: boolean; + readonly switchRefFailsAfterMutation?: boolean; + readonly switchRefFailureBranch?: string | null; + readonly switchRefRollbackFails?: boolean; + readonly switchRefGate?: Effect.Effect; + readonly switchRefResultBranch?: string | null; + readonly refChangeAfterSwitch?: string | null; + readonly createRefFails?: boolean; + readonly recordDispatchedBindings?: boolean; } const makeHarness = (options: HarnessOptions = {}) => { const thread = options.thread === undefined ? {} : options.thread; const scope = makeScope(options.capabilities ?? new Set(["preview", "worktree"])); - const dispatch = vi.fn((_: unknown) => + const dispatchedWorktreePaths = new Map(); + const dispatch = vi.fn((command: Parameters[0]) => (options.dispatchGate ?? Effect.void).pipe( Effect.andThen( options.dispatchInterrupts @@ -221,7 +255,16 @@ const makeHarness = (options: HarnessOptions = {}) => { ? Effect.die(new Error("dispatch defect")) : options.dispatchFails ? (Effect.fail("simulated dispatch failure") as never) - : Effect.succeed({ sequence: 1, storedEvents: [] }), + : Effect.sync(() => { + if ( + options.recordDispatchedBindings === true && + command.type === "thread.metadata.update" && + command.threadId !== undefined + ) { + dispatchedWorktreePaths.set(command.threadId, command.worktreePath ?? null); + } + return { sequence: 1, storedEvents: [] }; + }), ), ), ); @@ -241,6 +284,14 @@ const makeHarness = (options: HarnessOptions = {}) => { }), ) as never; } + if (options.threadReadFailsOnCall === getThreadProjection.mock.calls.length) { + return Effect.fail( + new OrchestratorDispatchError({ + commandId: CommandId.make("command:test:targeted-read"), + commandType: "thread.metadata.update", + }), + ) as never; + } if (options.threadReadFailsOnRecheck === true && getThreadProjection.mock.calls.length > 1) { return Effect.fail( new OrchestratorDispatchError({ @@ -249,6 +300,37 @@ const makeHarness = (options: HarnessOptions = {}) => { }), ) as never; } + if (options.threadReadFailsAfterDispatch === true && dispatch.mock.calls.length > 0) { + return Effect.fail( + new OrchestratorDispatchError({ + commandId: CommandId.make("command:test:post-dispatch-read"), + commandType: "thread.metadata.update", + }), + ) as never; + } + if ( + options.threadArchivedOnCall !== undefined && + getThreadProjection.mock.calls.length >= options.threadArchivedOnCall && + thread !== null + ) { + return Effect.succeed(makeProjection({ ...thread, archivedAt: "2026-01-02T00:00:00.000Z" })); + } + if ( + options.threadDeletedOnCall !== undefined && + getThreadProjection.mock.calls.length >= options.threadDeletedOnCall && + thread !== null + ) { + return Effect.succeed(makeProjection({ ...thread, deletedAt: "2026-01-02T00:00:00.000Z" })); + } + if ( + options.threadAttachedOnCall !== undefined && + getThreadProjection.mock.calls.length >= options.threadAttachedOnCall && + thread !== null + ) { + return Effect.succeed( + makeProjection({ ...thread, worktreePath: "/worktrees/project/raced" }), + ); + } if ( options.threadAttachedOnRecheck === true && getThreadProjection.mock.calls.length > 1 && @@ -265,9 +347,39 @@ const makeHarness = (options: HarnessOptions = {}) => { ) { return Effect.succeed(makeProjection({ ...thread, archivedAt: "2026-01-02T00:00:00.000Z" })); } - return id === threadId && thread !== null - ? Effect.succeed(makeProjection(thread)) - : Effect.fail(new OrchestratorProjectionError({ threadId: id })); + if ( + options.threadAfterFailedDispatch !== undefined && + dispatch.mock.calls.length > 0 && + thread !== null + ) { + return Effect.succeed(makeProjection({ ...thread, ...options.threadAfterFailedDispatch })); + } + if (id === threadId && thread !== null) { + return Effect.succeed( + makeProjection( + dispatchedWorktreePaths.has(id) + ? { ...thread, worktreePath: dispatchedWorktreePaths.get(id) ?? null } + : thread, + ), + ); + } + const projectThread = options.projectThreads?.find((item) => item.id === id); + if (projectThread !== undefined) { + const projection = makeProjection({ + branch: projectThread.branch, + worktreePath: dispatchedWorktreePaths.get(id) ?? projectThread.worktreePath, + }); + return Effect.succeed({ + ...projection, + thread: { + ...projection.thread, + id: projectThread.id, + title: projectThread.title, + activeRunId: projectThread.active === true ? "run-active" : null, + }, + }); + } + return Effect.fail(new OrchestratorProjectionError({ threadId: id })); }); const sendToThread = vi.fn((_: unknown) => { switch (options.continuation ?? "queued") { @@ -293,10 +405,16 @@ const makeHarness = (options: HarnessOptions = {}) => { : Effect.succeed( id === projectId && options.projectMissing !== true ? Option.some(configuredProject) - : Option.none(), + : id === options.otherProjectThread?.projectId + ? Option.some({ + ...project, + id, + workspaceRoot: options.otherProjectThread.workspaceRoot, + }) + : Option.none(), ), ); - const projectThreadShells = ( + const projectThreadShells: Array = ( options.projectThreads ?? [ { id: threadId, @@ -312,7 +430,7 @@ const makeHarness = (options: HarnessOptions = {}) => { title: item.title, branch: item.branch, worktreePath: item.worktreePath, - status: item.active === true ? "running" : "idle", + status: item.status ?? (item.active === true ? "running" : "idle"), activeRunId: item.active === true ? RunId.make("run-active") : null, lineage: { parentThreadId: null, @@ -321,6 +439,24 @@ const makeHarness = (options: HarnessOptions = {}) => { }, }), ); + if (options.otherProjectThread !== undefined) { + projectThreadShells.push( + shellFixture({ + id: options.otherProjectThread.id, + projectId: options.otherProjectThread.projectId, + title: options.otherProjectThread.title, + branch: options.otherProjectThread.branch, + worktreePath: options.otherProjectThread.worktreePath, + activeRunId: options.otherProjectThread.active === true ? RunId.make("run-active") : null, + status: options.otherProjectThread.active === true ? "running" : "idle", + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: options.otherProjectThread.id, + }, + }), + ); + } const archivedThreadShells = options.archivedProjectThread === undefined ? [] @@ -340,12 +476,17 @@ const makeHarness = (options: HarnessOptions = {}) => { }, }), ]; - const listProjectThreads = vi.fn(() => Effect.succeed(projectThreadShells)); + const listProjectThreads = vi.fn((input: { readonly projectId: ProjectId }) => + Effect.succeed(projectThreadShells.filter((item) => item.projectId === input.projectId)), + ); const getShellSnapshot = vi.fn(() => Effect.succeed({ schemaVersion: 1, snapshotSequence: 1, - threads: projectThreadShells, + threads: projectThreadShells.map((thread) => ({ + ...thread, + worktreePath: dispatchedWorktreePaths.get(thread.id) ?? thread.worktreePath, + })), archivedThreads: archivedThreadShells, } as never), ); @@ -354,11 +495,7 @@ const makeHarness = (options: HarnessOptions = {}) => { ? (Effect.fail("simulated worktree removal failure") as never) : Effect.void, ); - const deleteLocalBranch = vi.fn((_: unknown) => - options.deleteLocalBranchFails - ? (Effect.fail("simulated local branch deletion failure") as never) - : Effect.void, - ); + const deleteLocalBranch = vi.fn((_: unknown) => Effect.void); const fetchRemote = vi.fn((_: unknown) => options.fetchRemoteFails ? (Effect.fail("simulated fetch failure") as never) : Effect.void, ); @@ -367,17 +504,40 @@ const makeHarness = (options: HarnessOptions = {}) => { ? (Effect.fail("simulated remote resolve failure") as never) : Effect.succeed({ commitSha: "abc123", remoteRefName: "origin/dev" }), ); + let resolveCommitCallCount = 0; + const resolveCommit = vi.fn((_: unknown) => { + const commitSha = + options.resolvedCommits?.[ + Math.min(resolveCommitCallCount, (options.resolvedCommits?.length ?? 1) - 1) + ] ?? "commit-test"; + resolveCommitCallCount += 1; + return (options.resolveCommitGate ?? Effect.void).pipe(Effect.as({ commitSha })); + }); + const workspaceStatuses = new Map( + Object.entries( + options.workspaceStatuses ?? { + [workspaceRoot]: { + branch: options.currentBranch === undefined ? "dev" : options.currentBranch, + }, + }, + ), + ); const createWorktree = vi.fn( (input: { readonly newRefName?: string | undefined; readonly path: string | null }) => options.createWorktreeFails ? (Effect.fail("simulated worktree creation failure") as never) : (options.createWorktreeGate ?? Effect.void).pipe( Effect.andThen( - Effect.succeed({ - worktree: { - path: input.path ?? `/worktrees/project/${input.newRefName}`, - refName: input.newRefName ?? "detached", - }, + Effect.sync(() => { + const worktreePath = input.path ?? `/worktrees/project/${input.newRefName}`; + const refName = input.newRefName ?? "detached"; + workspaceStatuses.set(worktreePath, { branch: refName, dirty: false }); + return { + worktree: { + path: worktreePath, + refName, + }, + }; }), ), ), @@ -431,21 +591,13 @@ const makeHarness = (options: HarnessOptions = {}) => { options.worktreeInventories?.[cwd] ?? { repositoryCommonDir: "/repo/.git", currentWorktreeRoot: + options.workspaceAliases?.[cwd] ?? listedWorktrees.find((worktree) => worktree.path === cwd)?.path ?? - projectWorktreeRoot, + (cwd.startsWith(`${projectWorktreeRoot}/`) ? projectWorktreeRoot : cwd), worktrees: listedWorktrees, }, ), ); - const workspaceStatuses = new Map( - Object.entries( - options.workspaceStatuses ?? { - [workspaceRoot]: { - branch: options.currentBranch === undefined ? "dev" : options.currentBranch, - }, - }, - ), - ); let localStatusCallCount = 0; const localStatus = vi.fn((input: { readonly cwd: string }) => { localStatusCallCount += 1; @@ -463,10 +615,54 @@ const makeHarness = (options: HarnessOptions = {}) => { ? "dev" : options.currentBranch : current.branch, - hasWorkingTreeChanges: current?.dirty ?? false, + hasWorkingTreeChanges: + options.dirtyOnLocalStatusCall === localStatusCallCount ? true : (current?.dirty ?? false), workingTree: { files: [], insertions: 0, deletions: 0 }, }); }); + let switchCallCount = 0; + const switchRef = vi.fn((input: { readonly cwd: string; readonly refName: string }) => + (options.switchRefGate ?? Effect.void).pipe( + Effect.andThen( + Effect.suspend(() => { + switchCallCount += 1; + if (options.switchRefFailsAfterMutation === true && switchCallCount === 1) { + workspaceStatuses.set(input.cwd, { + branch: + options.switchRefFailureBranch === undefined + ? input.refName + : options.switchRefFailureBranch, + dirty: false, + }); + return Effect.fail("simulated switch failure after mutation") as never; + } + if ( + options.switchRefFails === true || + (options.switchRefRollbackFails === true && switchCallCount > 1) + ) { + return Effect.fail("simulated switch failure") as never; + } + const resolvedBranch = + options.switchRefResultBranch === undefined + ? input.refName + : options.switchRefResultBranch; + workspaceStatuses.set(input.cwd, { + branch: + options.refChangeAfterSwitch === undefined + ? resolvedBranch + : options.refChangeAfterSwitch, + dirty: false, + }); + return Effect.succeed({ refName: resolvedBranch }); + }), + ), + ), + ); + const createRef = vi.fn((_: unknown) => + options.createRefFails === true + ? (Effect.fail("simulated create ref failure") as never) + : Effect.succeed({ refName: "created" }), + ); const invalidateLocalStatus = vi.fn((_: string) => Effect.void); const refreshStatus = vi.fn((_: string) => Effect.die("refreshStatus stub")); const runForThread = vi.fn((input: { readonly worktreePath: string }) => { @@ -496,8 +692,8 @@ const makeHarness = (options: HarnessOptions = {}) => { // Optional deterministic Path semantics: providing this BEFORE the general // mocks means the service resolves Path here rather than from NodeServices, // so absolute-path validation is testable independently of the host OS. The - // service only calls isAbsolute; the minimal per-platform semantics are - // inlined so the test does not depend on the host's path module. + // The minimal per-platform semantics are inlined so the test does not + // depend on the host's path module. const win32IsAbsolute = (value: string) => /^(?:[a-zA-Z]:[\\/]|[\\/])/.test(value); const posixIsAbsolute = (value: string) => value.startsWith("/"); const serviceLayer = @@ -507,6 +703,8 @@ const makeHarness = (options: HarnessOptions = {}) => { Layer.provide( Layer.succeed(Path.Path, { isAbsolute: options.pathSemantics === "win32" ? win32IsAbsolute : posixIsAbsolute, + normalize: (value: string) => value, + resolve: (value: string) => value, } as unknown as Path.Path), ), ); @@ -537,9 +735,12 @@ const makeHarness = (options: HarnessOptions = {}) => { invalidateLocalStatus, fetchRemote, resolveRemoteTrackingCommit, + resolveCommit, createWorktree, removeWorktree, deleteLocalBranch, + switchRef, + createRef, } satisfies Partial); const layer = serviceLayer.pipe( Layer.provide( @@ -576,6 +777,7 @@ const makeHarness = (options: HarnessOptions = {}) => { sendToThread, fetchRemote, resolveRemoteTrackingCommit, + resolveCommit, createWorktree, removeWorktree, deleteLocalBranch, @@ -583,6 +785,9 @@ const makeHarness = (options: HarnessOptions = {}) => { listRefs, listWorktrees, listProjectThreads, + switchRef, + createRef, + invalidateLocalStatus, runForThread, }; }; @@ -630,6 +835,15 @@ const runList = ( return yield* service.listWorktrees(harness.scope, input); }).pipe(Effect.provide(harness.layer)); +const runCheckout = ( + harness: ReturnType, + input: Parameters[1], +) => + Effect.gen(function* () { + const service = yield* WorktreeMcpService; + return yield* service.checkout(harness.scope, input); + }).pipe(Effect.provide(harness.layer)); + describe("t3_worktree_handoff", () => { it.effect("creates a worktree from the current branch and re-points the thread", () => { const harness = makeHarness(); @@ -789,17 +1003,34 @@ describe("t3_worktree_handoff", () => { }); }); - it.effect("fails when the thread is already attached to a worktree", () => { + it.effect("moves an attached thread into a newly created worktree", () => { const harness = makeHarness({ thread: { branch: "feature/existing", worktreePath: "/worktrees/project/existing" }, + refs: [ + { + name: "feature/existing", + current: true, + isDefault: false, + worktreePath: "/worktrees/project/existing", + }, + ], + workspaceStatuses: { + "/worktrees/project/existing": { branch: "feature/existing" }, + }, }); return Effect.gen(function* () { - const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/second" })); - expectTypedFailure(exit, { - _tag: "WorktreeMcpFailure", - code: "already_in_worktree", + const result = yield* runHandoff(harness, { branch: "feature/second" }); + expect(result.worktreePath).toBe("/worktrees/project/feature/second"); + expect(harness.dispatch).toHaveBeenCalledWith({ + type: "thread.metadata.update", + commandId: expect.any(String), + threadId, + branch: "feature/second", + worktreePath: "/worktrees/project/feature/second", + expectedBranch: "feature/existing", + expectedWorktreePath: "/worktrees/project/existing", + expectedArchived: false, }); - expect(harness.createWorktree).not.toHaveBeenCalled(); }); }); @@ -966,37 +1197,83 @@ describe("t3_worktree_handoff", () => { }); }); - it.effect("re-checks attachment after creating the worktree and backs out on a race", () => { + it.effect("keeps a worktree whose binding committed before dispatch failed", () => { + const worktreePath = "/worktrees/project/feature/committed-dispatch"; + const harness = makeHarness({ + dispatchFails: true, + threadAfterFailedDispatch: { + branch: "feature/committed-dispatch", + worktreePath, + }, + }); + return Effect.gen(function* () { + const result = yield* runHandoff(harness, { branch: "feature/committed-dispatch" }); + expect(result.worktreePath).toBe(worktreePath); + expect(harness.removeWorktree).not.toHaveBeenCalled(); + }); + }); + + it.effect("preserves a new worktree when a failed binding outcome cannot be verified", () => { + const harness = makeHarness({ dispatchFails: true, threadReadFailsOnCall: 3 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/unknown-dispatch" })); + expectTypedFailure(exit, { + _tag: "WorktreeMcpFailure", + code: "partial_failure", + partial: { + workspacePath: "/worktrees/project/feature/unknown-dispatch", + actualBranch: "feature/unknown-dispatch", + rollback: "not_possible", + }, + }); + expect(harness.removeWorktree).not.toHaveBeenCalled(); + }); + }); + + it.effect("retains a created worktree whose HEAD changed before failed-binding cleanup", () => { + const harness = makeHarness({ + dispatchFails: true, + resolvedCommits: ["creation-commit", "concurrent-clean-commit"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runHandoff(harness, { branch: "feature/concurrent-clean-commit" }), + ); + expectTypedFailure(exit, { + _tag: "WorktreeMcpFailure", + code: "partial_failure", + partial: { rollback: "not_possible" }, + }); + expect(harness.removeWorktree).not.toHaveBeenCalled(); + }); + }); + + it.effect("retains the created worktree when the caller binding changes during creation", () => { const harness = makeHarness({ threadAttachedOnRecheck: true }); return Effect.gen(function* () { const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/raced" })); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "already_in_worktree" }); - expect(harness.createWorktree).toHaveBeenCalledTimes(1); - // The freshly created worktree must not be left orphaned. - expect(harness.removeWorktree).toHaveBeenCalledWith({ - cwd: workspaceRoot, - path: "/worktrees/project/feature/raced", - force: true, - }); - expect(harness.deleteLocalBranch).toHaveBeenCalledWith({ - cwd: workspaceRoot, - refName: "feature/raced", - force: true, + expectTypedFailure(exit, { + _tag: "WorktreeMcpFailure", + code: "partial_failure", + partial: { rollback: "not_possible" }, }); + expect(harness.createWorktree).toHaveBeenCalledTimes(1); + expect(harness.removeWorktree).not.toHaveBeenCalled(); + expect(harness.deleteLocalBranch).not.toHaveBeenCalled(); expect(harness.dispatch).not.toHaveBeenCalled(); }); }); - it.effect("removes the created worktree when the recheck read fails", () => { + it.effect("retains the created worktree when recheck ownership is unavailable", () => { const harness = makeHarness({ threadReadFailsOnRecheck: true }); return Effect.gen(function* () { const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/recheck-fails" })); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); - expect(harness.removeWorktree).toHaveBeenCalledWith({ - cwd: workspaceRoot, - path: "/worktrees/project/feature/recheck-fails", - force: true, + expectTypedFailure(exit, { + _tag: "WorktreeMcpFailure", + code: "partial_failure", + partial: { rollback: "not_possible" }, }); + expect(harness.removeWorktree).not.toHaveBeenCalled(); expect(harness.dispatch).not.toHaveBeenCalled(); }); }); @@ -1028,7 +1305,12 @@ describe("t3_worktree_handoff", () => { it.effect("queues the continuation even when interrupted during the binding dispatch", () => Effect.gen(function* () { const gate = yield* Deferred.make(); - const harness = makeHarness({ dispatchGate: Deferred.await(gate) }); + const dispatchStarted = yield* Deferred.make(); + const harness = makeHarness({ + dispatchGate: Deferred.succeed(dispatchStarted, undefined).pipe( + Effect.andThen(Deferred.await(gate)), + ), + }); // Interrupt arrives while the metadata dispatch is in flight; the // binding-plus-continuation section must run to completion anyway so the @@ -1039,7 +1321,7 @@ describe("t3_worktree_handoff", () => { continuationPrompt: "Keep going in the worktree.", }), ); - yield* Effect.yieldNow; + yield* Deferred.await(dispatchStarted); const interruption = yield* Effect.forkChild(Fiber.interrupt(fiber)); yield* Effect.yieldNow; yield* Deferred.succeed(gate, undefined); @@ -1058,25 +1340,25 @@ describe("t3_worktree_handoff", () => { const harness = makeHarness({ dispatchFails: true, removeWorktreeFails: true }); return Effect.gen(function* () { const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/rollback-fails" })); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); + expectTypedFailure(exit, { + _tag: "WorktreeMcpFailure", + code: "partial_failure", + partial: { rollback: "failed" }, + }); expect(harness.removeWorktree).toHaveBeenCalledTimes(1); expect(harness.deleteLocalBranch).not.toHaveBeenCalled(); }); }); - it.effect("preserves the typed failure when rollback branch deletion also fails", () => { - const harness = makeHarness({ dispatchFails: true, deleteLocalBranchFails: true }); + it.effect("retains the created branch after removing a failed handoff worktree", () => { + const harness = makeHarness({ dispatchFails: true }); return Effect.gen(function* () { const exit = yield* Effect.exit( runHandoff(harness, { branch: "feature/rollback-branch-fails" }), ); expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); expect(harness.removeWorktree).toHaveBeenCalledTimes(1); - expect(harness.deleteLocalBranch).toHaveBeenCalledWith({ - cwd: workspaceRoot, - refName: "feature/rollback-branch-fails", - force: true, - }); + expect(harness.deleteLocalBranch).not.toHaveBeenCalled(); }); }); @@ -1108,24 +1390,92 @@ describe("t3_worktree_handoff", () => { }); it.effect("releases the per-thread guard after a failed handoff", () => { - const harness = makeHarness({ - thread: { worktreePath: "/worktrees/project/existing" }, - }); + const harness = makeHarness({ dispatchFails: true }); return Effect.gen(function* () { const service = yield* resolveService(harness); const first = yield* Effect.exit( service.handoff(harness.scope, { branch: "feature/guard-1" }), ); - expectTypedFailure(first, { _tag: "WorktreeMcpFailure", code: "already_in_worktree" }); + expectTypedFailure(first, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); // A leaked guard would surface as handoff_in_progress here. const second = yield* Effect.exit( service.handoff(harness.scope, { branch: "feature/guard-2" }), ); - expectTypedFailure(second, { _tag: "WorktreeMcpFailure", code: "already_in_worktree" }); + expectTypedFailure(second, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); + expect(harness.createWorktree).toHaveBeenCalledTimes(2); }); }); + it.effect("retains a created worktree when checkout binds it before handoff admission", () => + Effect.gen(function* () { + const targetPath = "/worktrees/project/handoff-checkout-race"; + const otherThreadId = ThreadId.make("thread-handoff-checkout-race"); + const createEntered = yield* Deferred.make(); + const releaseCreate = yield* Deferred.make(); + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: [ + { + name: "dev", + current: true, + isDefault: true, + worktreePath: workspaceRoot, + }, + { + name: "feature/handoff-checkout-race", + current: false, + isDefault: false, + worktreePath: targetPath, + }, + ], + worktrees: [ + { path: workspaceRoot, refName: "dev" }, + { path: targetPath, refName: "feature/handoff-checkout-race" }, + ], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [targetPath]: { branch: "feature/handoff-checkout-race" }, + }, + projectThreads: [ + { id: threadId, title: "Handoff caller", branch: "dev", worktreePath: null }, + { id: otherThreadId, title: "Checkout caller", branch: "dev", worktreePath: null }, + ], + recordDispatchedBindings: true, + createWorktreeGate: Deferred.succeed(createEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseCreate)), + ), + }); + const service = yield* resolveService(harness); + + return yield* Effect.gen(function* () { + const handoffFiber = yield* Effect.forkChild( + service.handoff(harness.scope, { + branch: "feature/handoff-checkout-race", + path: targetPath, + }), + { startImmediately: true }, + ); + yield* Deferred.await(createEntered); + const checkout = yield* service.checkout( + { ...harness.scope, threadId: otherThreadId }, + { target: { type: "worktree", path: targetPath } }, + ); + expect(checkout.current.workspacePath).toBe(targetPath); + + yield* Deferred.succeed(releaseCreate, undefined); + const handoffExit = yield* Fiber.await(handoffFiber); + expectTypedFailure(handoffExit, { + _tag: "WorktreeMcpFailure", + code: "partial_failure", + partial: { workspacePath: targetPath, rollback: "not_possible" }, + }); + expect(harness.removeWorktree).not.toHaveBeenCalled(); + expect(harness.dispatch).toHaveBeenCalledTimes(1); + }).pipe(Effect.ensuring(Deferred.succeed(releaseCreate, undefined))); + }), + ); + it.effect("fails when the worktree capability is missing", () => { const harness = makeHarness({ capabilities: new Set(["preview"]) }); return Effect.gen(function* () { @@ -1256,7 +1606,6 @@ describe("t3_worktree_status", () => { actualWorkspace: { workspacePath: workspaceRoot, branch: "dev", - isRepo: true, hasWorkingTreeChanges: false, }, agreement: "branch_mismatch", @@ -1294,6 +1643,22 @@ describe("t3_worktree_status", () => { }); }); + it.effect("reports a recorded worktree that is no longer registered", () => { + const worktreePath = "/worktrees/project/missing"; + const harness = makeHarness({ + thread: { worktreePath, branch: "feature/missing" }, + workspaceStatuses: { [worktreePath]: { branch: null } }, + }); + return Effect.gen(function* () { + const result = yield* runStatus(harness); + expect(result.agreement).toBe("workspace_missing"); + expect(result.recordedWorkspace).toEqual({ + branch: "feature/missing", + worktreePath, + }); + }); + }); + it.effect("reports a missing saved worktree even when inventory discovery fails", () => { const missingPath = "/worktrees/project/deleted"; const harness = makeHarness({ @@ -1320,6 +1685,26 @@ describe("t3_worktree_status", () => { }); }); + it.effect("reports a recorded workspace that is not a Git repository", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: [ + { + name: "dev", + current: true, + isDefault: true, + worktreePath: workspaceRoot, + }, + ], + notARepo: true, + }); + return Effect.gen(function* () { + const result = yield* runStatus(harness); + expect(result.agreement).toBe("not_repository"); + expect(result.actualWorkspace.isRepo).toBe(false); + }); + }); + it.effect("fails when the worktree capability is missing", () => { const harness = makeHarness({ capabilities: new Set(["preview"]) }); return Effect.gen(function* () { @@ -1346,13 +1731,17 @@ describe("t3_worktree_status", () => { }); describe("t3_worktree_list", () => { - it.effect("reports actual checkout state and durable thread bindings", () => { + it.effect("reports actual checkout state and thread bindings for root and worktrees", () => { const worktreePath = "/worktrees/project/feature-list"; const otherThreadId = ThreadId.make("thread-worktree-other"); const harness = makeHarness({ - thread: { branch: "dev", worktreePath: null }, refs: [ - { name: "dev", current: true, isDefault: true, worktreePath: workspaceRoot }, + { + name: "dev", + current: true, + isDefault: true, + worktreePath: workspaceRoot, + }, { name: "feature/list", current: false, @@ -1365,12 +1754,18 @@ describe("t3_worktree_list", () => { [worktreePath]: { branch: "feature/list", dirty: true }, }, projectThreads: [ - { id: threadId, title: "Caller", branch: "dev", worktreePath: null }, + { + id: threadId, + title: "Worktree test thread", + branch: "dev", + worktreePath: null, + }, { id: otherThreadId, title: "Other thread", branch: "feature/list", worktreePath, + status: "running", active: true, }, ], @@ -1395,7 +1790,7 @@ describe("t3_worktree_list", () => { bindings: [ { threadId, - title: "Caller", + title: "Worktree test thread", status: "idle", recordedBranch: "dev", recordedWorktreePath: null, @@ -1530,42 +1925,134 @@ describe("t3_worktree_list", () => { }); }); - it.effect("bounds nested binding identity reads by the requested page", () => { - const nestedOne = `${workspaceRoot}/packages/one`; - const nestedTwo = `${workspaceRoot}/packages/two`; - const nestedThree = `${workspaceRoot}/packages/three`; + it.effect("includes archived thread bindings retained on a physical checkout", () => { + const archivedThreadId = ThreadId.make("thread-archived-list-owner"); const harness = makeHarness({ worktrees: [{ path: workspaceRoot, refName: "dev" }], - projectThreads: [ - { id: threadId, title: "Caller", branch: "dev", worktreePath: null }, - { - id: ThreadId.make("thread-nested-binding-one"), - title: "Nested one", - branch: "dev", - worktreePath: nestedOne, - }, - { - id: ThreadId.make("thread-nested-binding-two"), - title: "Nested two", - branch: "dev", - worktreePath: nestedTwo, - }, - { - id: ThreadId.make("thread-nested-binding-three"), - title: "Nested three", - branch: "dev", - worktreePath: nestedThree, - }, - ], + archivedProjectThread: { + id: archivedThreadId, + title: "Archived checkout owner", + branch: "dev", + worktreePath: workspaceRoot, + }, }); return Effect.gen(function* () { - const result = yield* runList(harness, { limit: 1, bindingLimit: 1 }); + const result = yield* runList(harness, { limit: 1 }); - expect(result.bindingPathResolution).toEqual({ - totalCandidates: 3, - attemptedCandidates: 1, - truncated: true, - complete: false, + expect(result.worktrees[0]).toMatchObject({ + path: workspaceRoot, + bindingCount: 2, + bindings: expect.arrayContaining([ + expect.objectContaining({ + threadId: archivedThreadId, + recordedWorktreePath: workspaceRoot, + active: false, + }), + ]), + }); + }); + }); + + it.effect("attributes a nested recorded cwd to its physical worktree root", () => { + const nestedPath = `${workspaceRoot}/packages/app`; + const harness = makeHarness({ + worktrees: [{ path: workspaceRoot, refName: "dev" }], + projectThreads: [ + { + id: threadId, + title: "Nested caller", + branch: "dev", + worktreePath: nestedPath, + }, + ], + }); + return Effect.gen(function* () { + const result = yield* runList(harness, { limit: 1 }); + + expect(result.worktrees[0]).toMatchObject({ + path: workspaceRoot, + bindingCount: 1, + bindings: [ + { + threadId, + recordedWorktreePath: nestedPath, + callingThread: true, + }, + ], + }); + expect(harness.localStatus).toHaveBeenCalledTimes(1); + expect(harness.listWorktrees).toHaveBeenCalledTimes(2); + }); + }); + + it.effect("does not attribute a nested independent repository to the project worktree", () => { + const nestedPath = `${workspaceRoot}/vendor/independent`; + const harness = makeHarness({ + worktrees: [{ path: workspaceRoot, refName: "dev" }], + projectThreads: [ + { + id: threadId, + title: "Nested independent repository", + branch: "main", + worktreePath: nestedPath, + }, + ], + worktreeInventories: { + [nestedPath]: { + repositoryCommonDir: `${nestedPath}/.git`, + currentWorktreeRoot: nestedPath, + worktrees: [{ path: nestedPath, refName: "main" }], + }, + }, + }); + return Effect.gen(function* () { + const result = yield* runList(harness, { limit: 1 }); + + expect(result.worktrees[0]).toMatchObject({ + path: workspaceRoot, + bindingCount: 0, + bindings: [], + }); + expect(harness.localStatus).toHaveBeenCalledTimes(1); + expect(harness.listWorktrees).toHaveBeenCalledTimes(2); + }); + }); + it.effect("bounds nested binding identity reads by the requested page", () => { + const nestedOne = `${workspaceRoot}/packages/one`; + const nestedTwo = `${workspaceRoot}/packages/two`; + const nestedThree = `${workspaceRoot}/packages/three`; + const harness = makeHarness({ + worktrees: [{ path: workspaceRoot, refName: "dev" }], + projectThreads: [ + { id: threadId, title: "Caller", branch: "dev", worktreePath: null }, + { + id: ThreadId.make("thread-nested-binding-one"), + title: "Nested one", + branch: "dev", + worktreePath: nestedOne, + }, + { + id: ThreadId.make("thread-nested-binding-two"), + title: "Nested two", + branch: "dev", + worktreePath: nestedTwo, + }, + { + id: ThreadId.make("thread-nested-binding-three"), + title: "Nested three", + branch: "dev", + worktreePath: nestedThree, + }, + ], + }); + return Effect.gen(function* () { + const result = yield* runList(harness, { limit: 1, bindingLimit: 1 }); + + expect(result.bindingPathResolution).toEqual({ + totalCandidates: 3, + attemptedCandidates: 1, + truncated: true, + complete: false, }); expect(result.worktrees[0]?.bindingCount).toBe(2); expect(harness.listWorktrees).toHaveBeenCalledTimes(2); @@ -1661,99 +2148,1287 @@ describe("t3_worktree_list", () => { }); }); }); +}); - it.effect("includes archived thread bindings retained on a physical checkout", () => { - const archivedThreadId = ThreadId.make("thread-archived-list-owner"); +describe("t3_thread_checkout", () => { + const rootRefs = [ + { + name: "dev", + current: true, + isDefault: true, + worktreePath: workspaceRoot, + }, + { + name: "feature/checkout", + current: false, + isDefault: false, + worktreePath: null, + }, + ] as const; + + it.effect("switches the actual branch before updating the durable binding", () => { const harness = makeHarness({ - worktrees: [{ path: workspaceRoot, refName: "dev" }], - archivedProjectThread: { - id: archivedThreadId, - title: "Archived checkout owner", - branch: "dev", - worktreePath: workspaceRoot, - }, + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, }); return Effect.gen(function* () { - const result = yield* runList(harness, { limit: 1 }); - - expect(result.worktrees[0]).toMatchObject({ - path: workspaceRoot, - bindingCount: 2, - bindings: expect.arrayContaining([ - expect.objectContaining({ - threadId: archivedThreadId, - recordedWorktreePath: workspaceRoot, - active: false, - }), - ]), + const result = yield* runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }); + expect(result.checkoutAction).toBe("switched"); + expect(result.current).toMatchObject({ + workspacePath: workspaceRoot, + recordedBranch: "feature/checkout", + actualBranch: "feature/checkout", + }); + expect(harness.switchRef).toHaveBeenCalledWith({ + cwd: workspaceRoot, + refName: "feature/checkout", }); + expect(harness.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: "thread.metadata.update", + branch: "feature/checkout", + worktreePath: null, + expectedBranch: "dev", + expectedWorktreePath: null, + }), + ); + expect(harness.switchRef.mock.invocationCallOrder[0]).toBeLessThan( + harness.dispatch.mock.invocationCallOrder[0]!, + ); }); }); - it.effect("attributes a nested recorded cwd to its physical worktree root", () => { - const nestedPath = `${workspaceRoot}/packages/app`; + it.effect( + "asks Git to resolve a remote ref even when a same-named local branch is current", + () => { + const harness = makeHarness({ + thread: { branch: "feature", worktreePath: null }, + refs: [ + { + name: "feature", + current: true, + isDefault: false, + worktreePath: workspaceRoot, + }, + { + name: "origin/feature", + current: false, + isDefault: false, + isRemote: true, + worktreePath: null, + }, + ], + workspaceStatuses: { [workspaceRoot]: { branch: "feature" } }, + switchRefResultBranch: "feature", + }); + return Effect.gen(function* () { + const result = yield* runCheckout(harness, { + target: { type: "branch", branch: "origin/feature" }, + }); + expect(result.checkoutAction).toBe("switched"); + expect(result.current.actualBranch).toBe("feature"); + expect(harness.switchRef).toHaveBeenCalledWith({ + cwd: workspaceRoot, + refName: "origin/feature", + }); + }); + }, + ); + + it.effect("records a verified detached checkout of an explicit remote ref", () => { + const remoteCommit = "remote-feature-commit"; const harness = makeHarness({ - worktrees: [{ path: workspaceRoot, refName: "dev" }], - projectThreads: [ + thread: { branch: "feature", worktreePath: null }, + refs: [ { - id: threadId, - title: "Nested caller", - branch: "dev", - worktreePath: nestedPath, + name: "feature", + current: true, + isDefault: false, + worktreePath: workspaceRoot, + }, + { + name: "origin/feature", + current: false, + isDefault: false, + isRemote: true, + worktreePath: null, }, ], + workspaceStatuses: { [workspaceRoot]: { branch: "feature" } }, + switchRefResultBranch: null, + resolvedCommits: ["local-feature-commit", remoteCommit, remoteCommit], }); return Effect.gen(function* () { - const result = yield* runList(harness, { limit: 1 }); - - expect(result.worktrees[0]).toMatchObject({ - path: workspaceRoot, - bindingCount: 1, - bindings: [ - { - threadId, - recordedWorktreePath: nestedPath, - callingThread: true, - }, - ], + const result = yield* runCheckout(harness, { + target: { type: "branch", branch: "origin/feature" }, }); - expect(harness.localStatus).toHaveBeenCalledTimes(1); - expect(harness.listWorktrees).toHaveBeenCalledTimes(2); + + expect(result.checkoutAction).toBe("switched"); + expect(result.current).toMatchObject({ recordedBranch: null, actualBranch: null }); + expect(harness.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: "thread.metadata.update", + branch: null, + expectedArchived: false, + }), + ); }); }); - it.effect("does not attribute a nested independent repository to the project worktree", () => { - const nestedPath = `${workspaceRoot}/vendor/independent`; + it.effect("refuses to bind when Git changes again after resolving the requested ref", () => { const harness = makeHarness({ - worktrees: [{ path: workspaceRoot, refName: "dev" }], - projectThreads: [ - { - id: threadId, - title: "Nested independent repository", - branch: "main", - worktreePath: nestedPath, - }, - ], - worktreeInventories: { - [nestedPath]: { - repositoryCommonDir: `${nestedPath}/.git`, - currentWorktreeRoot: nestedPath, - worktrees: [{ path: nestedPath, refName: "main" }], + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + switchRefResultBranch: "feature/checkout", + refChangeAfterSwitch: "feature/intervening", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + expectTypedFailure(exit, { + _tag: "WorktreeMcpFailure", + code: "partial_failure", + partial: { + actualBranch: "feature/intervening", + rollback: "not_possible", }, - }, + }); + expect(harness.dispatch).not.toHaveBeenCalled(); + expect(harness.switchRef).toHaveBeenCalledTimes(1); + }); + }); + + it.effect("rechecks the caller binding after commit resolution and before Git mutation", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + threadAttachedOnCall: 3, }); return Effect.gen(function* () { - const result = yield* runList(harness, { limit: 1 }); + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); - expect(result.worktrees[0]).toMatchObject({ - path: workspaceRoot, - bindingCount: 0, - bindings: [], + expectTypedFailure(exit, { + _tag: "WorktreeMcpFailure", + code: "checkout_in_progress", }); - expect(harness.localStatus).toHaveBeenCalledTimes(1); - expect(harness.listWorktrees).toHaveBeenCalledTimes(2); + expect(harness.resolveCommit).toHaveBeenCalledTimes(2); + expect(harness.switchRef).not.toHaveBeenCalled(); + expect(harness.dispatch).not.toHaveBeenCalled(); }); }); + + for (const [state, option] of [ + ["archived", { threadArchivedOnCall: 4 }], + ["deleted", { threadDeletedOnCall: 4 }], + ] as const) { + it.effect(`preserves Git state when the thread is ${state} before binding`, () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + ...option, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + expectTypedFailure(exit, { + _tag: "WorktreeMcpFailure", + code: "partial_failure", + partial: { actualBranch: "feature/checkout", rollback: "not_possible" }, + }); + expect(harness.switchRef).toHaveBeenCalledTimes(1); + expect(harness.dispatch).not.toHaveBeenCalled(); + expect(harness.sendToThread).not.toHaveBeenCalled(); + }); + }); + } + + it.effect("creates and checks out a new branch in the current workspace", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + }); + return Effect.gen(function* () { + const result = yield* runCheckout(harness, { + target: { type: "branch", branch: "feature/created", create: true }, + }); + expect(result.checkoutAction).toBe("created"); + expect(result.current).toMatchObject({ + recordedBranch: "feature/created", + actualBranch: "feature/created", + }); + expect(harness.createRef).toHaveBeenCalledWith({ + cwd: workspaceRoot, + refName: "feature/created", + switchRef: false, + }); + expect(harness.switchRef).toHaveBeenCalledWith({ + cwd: workspaceRoot, + refName: "feature/created", + }); + }); + }); + + it.effect("retains a created branch when a later binding operation fails", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + dispatchFails: true, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/created-rollback", create: true }, + }), + ); + + expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); + expect(harness.switchRef).toHaveBeenNthCalledWith(2, { + cwd: workspaceRoot, + refName: "dev", + }); + expect(harness.deleteLocalBranch).not.toHaveBeenCalled(); + }); + }); + + it.effect("reuses an existing worktree and queues continuation after binding", () => { + const worktreePath = "/worktrees/project/feature-checkout"; + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: [ + rootRefs[0], + { + name: "feature/checkout", + current: false, + isDefault: false, + worktreePath, + }, + ], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [worktreePath]: { branch: "feature/checkout" }, + }, + }); + return Effect.gen(function* () { + const result = yield* runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + continuationPrompt: "Continue in the reused worktree.", + }); + expect(result.checkoutAction).toBe("reused"); + expect(result.workspaceChanged).toBe(true); + expect(result.callerTurnEnds).toBe(true); + expect(result.continuation).toEqual({ status: "scheduled", delivery: "queued" }); + expect(harness.switchRef).not.toHaveBeenCalled(); + expect(harness.dispatch.mock.invocationCallOrder[0]).toBeLessThan( + harness.sendToThread.mock.invocationCallOrder[0]!, + ); + }); + }); + + it.effect("returns an attached thread to the project root", () => { + const worktreePath = "/worktrees/project/feature-checkout"; + const harness = makeHarness({ + thread: { branch: "feature/checkout", worktreePath }, + refs: [ + rootRefs[0], + { + name: "feature/checkout", + current: true, + isDefault: false, + worktreePath, + }, + ], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [worktreePath]: { branch: "feature/checkout" }, + }, + }); + return Effect.gen(function* () { + const result = yield* runCheckout(harness, { target: { type: "project_root" } }); + expect(result.current).toMatchObject({ + workspacePath: workspaceRoot, + recordedBranch: "dev", + recordedWorktreePath: null, + actualBranch: "dev", + }); + expect(result.workspaceChanged).toBe(true); + expect(harness.switchRef).not.toHaveBeenCalled(); + }); + }); + + it.effect("repairs a missing saved worktree by returning to the project root", () => { + const missingPath = "/worktrees/project/deleted"; + const harness = makeHarness({ + thread: { branch: "feature/deleted", worktreePath: missingPath }, + refs: rootRefs, + worktrees: [{ path: workspaceRoot, refName: "dev" }], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [missingPath]: { branch: null, isRepo: false }, + }, + worktreeInventoryFailsFor: new Set([missingPath]), + }); + return Effect.gen(function* () { + const result = yield* runCheckout(harness, { target: { type: "project_root" } }); + + expect(result.previous).toMatchObject({ + workspacePath: missingPath, + recordedBranch: "feature/deleted", + actualBranch: null, + }); + expect(result.current).toMatchObject({ + workspacePath: workspaceRoot, + recordedBranch: "dev", + recordedWorktreePath: null, + actualBranch: "dev", + }); + expect(harness.switchRef).not.toHaveBeenCalled(); + expect(harness.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + expectedBranch: "feature/deleted", + expectedWorktreePath: missingPath, + branch: "dev", + worktreePath: null, + }), + ); + }); + }); + + it.effect("repairs a missing saved worktree by reusing a listed checkout", () => { + const missingPath = "/worktrees/project/deleted"; + const targetPath = "/worktrees/project/existing"; + const harness = makeHarness({ + thread: { branch: "feature/deleted", worktreePath: missingPath }, + refs: [ + rootRefs[0], + { + name: "feature/existing", + current: true, + isDefault: false, + worktreePath: targetPath, + }, + ], + worktrees: [ + { path: workspaceRoot, refName: "dev" }, + { path: targetPath, refName: "feature/existing" }, + ], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [missingPath]: { branch: null, isRepo: false }, + [targetPath]: { branch: "feature/existing" }, + }, + worktreeInventoryFailsFor: new Set([missingPath]), + }); + return Effect.gen(function* () { + const result = yield* runCheckout(harness, { + target: { type: "worktree", path: targetPath }, + }); + + expect(result.checkoutAction).toBe("reused"); + expect(result.previous.workspacePath).toBe(missingPath); + expect(result.current).toMatchObject({ + workspacePath: targetPath, + recordedBranch: "feature/existing", + recordedWorktreePath: targetPath, + }); + expect(harness.switchRef).not.toHaveBeenCalled(); + }); + }); + + it.effect("repairs a missing saved worktree by creating from the healthy project root", () => { + const missingPath = "/worktrees/project/deleted"; + const harness = makeHarness({ + thread: { branch: "feature/deleted", worktreePath: missingPath }, + refs: rootRefs, + worktrees: [{ path: workspaceRoot, refName: "dev" }], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [missingPath]: { branch: null, isRepo: false }, + }, + worktreeInventoryFailsFor: new Set([missingPath]), + }); + return Effect.gen(function* () { + const result = yield* runCheckout(harness, { + target: { type: "new_worktree", branch: "feature/recovered" }, + }); + + expect(result.previous.actualBranch).toBeNull(); + expect(result.current.recordedWorktreePath).toBe("/worktrees/project/feature/recovered"); + expect(harness.createWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: workspaceRoot, + refName: "dev", + newRefName: "feature/recovered", + }), + ); + expect(harness.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + expectedBranch: "feature/deleted", + expectedWorktreePath: missingPath, + }), + ); + }); + }); + + it.effect("fails closed when the recorded checkout inventory succeeds but status fails", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + worktrees: [{ path: workspaceRoot, refName: "dev" }], + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + localStatusFailsOnCall: 1, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + + expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); + expect(harness.switchRef).not.toHaveBeenCalled(); + expect(harness.createRef).not.toHaveBeenCalled(); + expect(harness.dispatch).not.toHaveBeenCalled(); + }); + }); + + it.effect("applies branch existence checks to project-root targets", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + }); + return Effect.gen(function* () { + const existingExit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "project_root", branch: "feature/checkout", create: true }, + }), + ); + expectTypedFailure(existingExit, { + _tag: "WorktreeMcpFailure", + code: "invalid_request", + message: + "Local branch 'feature/checkout' already exists. Omit target.create to check it out.", + }); + + const missingExit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "project_root", branch: "feature/missing" }, + }), + ); + expectTypedFailure(missingExit, { + _tag: "WorktreeMcpFailure", + code: "invalid_request", + message: + "Branch or remote ref 'feature/missing' does not exist. Pass target.create=true to create a local branch from the project root.", + }); + expect(harness.switchRef).not.toHaveBeenCalled(); + expect(harness.createRef).not.toHaveBeenCalled(); + }); + }); + + it.effect("creates a new worktree for an already attached thread", () => { + const sourcePath = "/worktrees/project/source"; + const harness = makeHarness({ + thread: { branch: "feature/source", worktreePath: sourcePath }, + refs: [ + { + name: "dev", + current: true, + isDefault: true, + worktreePath: workspaceRoot, + }, + { + name: "feature/source", + current: true, + isDefault: false, + worktreePath: sourcePath, + }, + ], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [sourcePath]: { branch: "feature/source" }, + }, + }); + return Effect.gen(function* () { + const result = yield* runCheckout(harness, { + target: { type: "new_worktree", branch: "feature/new-checkout" }, + continuationPrompt: "Continue in the new worktree.", + }); + expect(result.checkoutAction).toBe("created"); + expect(result.current.recordedWorktreePath).toBe("/worktrees/project/feature/new-checkout"); + expect(result.current.actualBranch).toBe("feature/new-checkout"); + expect(result.continuation).toEqual({ status: "scheduled", delivery: "queued" }); + expect(harness.createWorktree).toHaveBeenCalledWith( + expect.objectContaining({ refName: "feature/source" }), + ); + expect(harness.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + expectedBranch: "feature/source", + expectedWorktreePath: sourcePath, + }), + ); + }); + }); + + it.effect("does not turn a completed new-worktree handoff into a status-read failure", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + localStatusFailsOnCall: 3, + }); + return Effect.gen(function* () { + const result = yield* runCheckout(harness, { + target: { type: "new_worktree", branch: "feature/completed-handoff" }, + }); + expect(result.current.actualBranch).toBe("feature/completed-handoff"); + expect(harness.localStatus).toHaveBeenCalledTimes(2); + }); + }); + + it.effect("reuses a detached worktree without inventing a branch", () => { + const detachedPath = "/worktrees/project/detached"; + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: [rootRefs[0]], + worktrees: [ + { path: workspaceRoot, refName: "dev" }, + { path: detachedPath, refName: null }, + ], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [detachedPath]: { branch: null }, + }, + }); + return Effect.gen(function* () { + const result = yield* runCheckout(harness, { + target: { type: "worktree", path: detachedPath }, + }); + expect(result.current).toMatchObject({ + workspacePath: detachedPath, + recordedBranch: null, + recordedWorktreePath: detachedPath, + actualBranch: null, + }); + expect(harness.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ branch: null, worktreePath: detachedPath }), + ); + }); + }); + + it.effect("rejects dirty files before switching branches", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev", dirty: true } }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "dirty_workspace" }); + expect(harness.switchRef).not.toHaveBeenCalled(); + expect(harness.dispatch).not.toHaveBeenCalled(); + }); + }); + + it.effect("rejects a worktree bound to another idle thread", () => { + const worktreePath = "/worktrees/project/shared"; + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: [ + rootRefs[0], + { + name: "feature/shared", + current: false, + isDefault: false, + worktreePath, + }, + ], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [worktreePath]: { branch: "feature/shared" }, + }, + projectThreads: [ + { id: threadId, title: "Caller", branch: "dev", worktreePath: null }, + { + id: ThreadId.make("thread-worktree-shared"), + title: "Shared owner", + branch: "feature/shared", + worktreePath, + }, + ], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { target: { type: "worktree", path: worktreePath } }), + ); + expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "workspace_shared" }); + }); + }); + + it.effect("rejects a physical worktree bound through another project alias", () => { + const targetPath = "/worktrees/project/cross-project"; + const otherProjectRoot = "/aliases/other-project"; + const otherProjectId = ProjectId.make("project-other"); + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: [ + rootRefs[0], + { + name: "feature/cross-project", + current: false, + isDefault: false, + worktreePath: targetPath, + }, + ], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [targetPath]: { branch: "feature/cross-project" }, + }, + workspaceAliases: { [otherProjectRoot]: targetPath }, + otherProjectThread: { + projectId: otherProjectId, + workspaceRoot: otherProjectRoot, + id: ThreadId.make("thread-other-project-owner"), + title: "Other project owner", + branch: "feature/cross-project", + worktreePath: null, + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { target: { type: "worktree", path: targetPath } }), + ); + expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "workspace_shared" }); + expect(harness.dispatch).not.toHaveBeenCalled(); + }); + }); + + it.effect("ignores an unrelated plain-directory project while checking workspace owners", () => { + const plainProjectRoot = "/plain/unrelated-project"; + const otherProjectId = ProjectId.make("project-plain-directory"); + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [plainProjectRoot]: { branch: null, isRepo: false }, + }, + worktreeInventoryFailsFor: new Set([plainProjectRoot]), + otherProjectThread: { + projectId: otherProjectId, + workspaceRoot: plainProjectRoot, + id: ThreadId.make("thread-plain-directory"), + title: "Plain directory thread", + branch: null, + worktreePath: null, + }, + }); + return Effect.gen(function* () { + const result = yield* runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }); + + expect(result.current.actualBranch).toBe("feature/checkout"); + expect(harness.dispatch).toHaveBeenCalledTimes(1); + }); + }); + + it.effect("fails closed when a nested checkout owner's Git identity cannot be resolved", () => { + const nestedProjectRoot = "/repo/packages/server"; + const otherProjectId = ProjectId.make("project-unresolved-nested-owner"); + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [nestedProjectRoot]: { branch: "dev", isRepo: true }, + }, + worktreeInventoryFailsFor: new Set([nestedProjectRoot]), + otherProjectThread: { + projectId: otherProjectId, + workspaceRoot: nestedProjectRoot, + id: ThreadId.make("thread-unresolved-nested-owner"), + title: "Unresolved nested owner", + branch: "dev", + worktreePath: null, + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + + expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); + expect(harness.switchRef).not.toHaveBeenCalled(); + expect(harness.dispatch).not.toHaveBeenCalled(); + }); + }); + + it.effect("fails closed when a same-repository owner has no physical checkout identity", () => { + const nestedProjectRoot = "/repo/packages/server"; + const otherProjectId = ProjectId.make("project-null-checkout-owner"); + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [nestedProjectRoot]: { branch: "dev", isRepo: true }, + }, + worktreeInventories: { + [nestedProjectRoot]: { + repositoryCommonDir: "/repo/.git", + currentWorktreeRoot: null, + worktrees: [{ path: workspaceRoot, refName: "dev" }], + }, + }, + otherProjectThread: { + projectId: otherProjectId, + workspaceRoot: nestedProjectRoot, + id: ThreadId.make("thread-null-checkout-owner"), + title: "Unresolved checkout owner", + branch: "dev", + worktreePath: null, + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + + expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); + expect(harness.switchRef).not.toHaveBeenCalled(); + expect(harness.dispatch).not.toHaveBeenCalled(); + }); + }); + + it.effect("rejects a physical worktree retained by an archived thread", () => { + const targetPath = "/worktrees/project/archived-owner"; + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: [ + rootRefs[0], + { + name: "feature/archived-owner", + current: false, + isDefault: false, + worktreePath: targetPath, + }, + ], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [targetPath]: { branch: "feature/archived-owner" }, + }, + archivedProjectThread: { + id: ThreadId.make("thread-archived-worktree-owner"), + title: "Archived owner", + branch: "feature/archived-owner", + worktreePath: targetPath, + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { target: { type: "worktree", path: targetPath } }), + ); + expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "workspace_shared" }); + expect(harness.dispatch).not.toHaveBeenCalled(); + }); + }); + + it.effect("rejects switching the shared project root while another thread is active", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + projectThreads: [ + { id: threadId, title: "Caller", branch: "dev", worktreePath: null }, + { + id: ThreadId.make("thread-root-active"), + title: "Root owner", + branch: "dev", + worktreePath: null, + status: "running", + active: true, + }, + ], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "workspace_in_use" }); + }); + }); + + it.effect("rejects switching a project root shared with another idle thread", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + projectThreads: [ + { id: threadId, title: "Caller", branch: "dev", worktreePath: null }, + { + id: ThreadId.make("thread-root-idle"), + title: "Idle root owner", + branch: "dev", + worktreePath: null, + }, + ], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "workspace_shared" }); + expect(harness.switchRef).not.toHaveBeenCalled(); + }); + }); + + it.effect("rejects paths outside the project worktree list", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "worktree", path: "/other/repository" }, + }), + ); + expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "scope_mismatch" }); + }); + }); + + it.effect("rechecks the durable binding before changing Git", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + threadAttachedOnRecheck: true, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "checkout_in_progress" }); + expect(harness.switchRef).not.toHaveBeenCalled(); + expect(harness.dispatch).not.toHaveBeenCalled(); + }); + }); + + it.effect("cancels guarded checkout reads before mutation and releases both guards", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const gateArmed = yield* Ref.make(true); + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + resolveCommitGate: Effect.gen(function* () { + if (yield* Ref.getAndSet(gateArmed, false)) { + yield* Deferred.succeed(entered, undefined); + yield* Deferred.await(release); + } + }), + }); + const service = yield* resolveService(harness); + const checkoutInput = { + target: { type: "branch", branch: "feature/checkout" }, + } as const; + return yield* Effect.gen(function* () { + const first = yield* Effect.forkChild(service.checkout(harness.scope, checkoutInput), { + startImmediately: true, + }); + yield* Deferred.await(entered); + first.interruptUnsafe(); + yield* Deferred.succeed(release, undefined); + const interrupted = yield* Fiber.await(first); + expect(Exit.isFailure(interrupted)).toBe(true); + expect(harness.createRef).not.toHaveBeenCalled(); + expect(harness.switchRef).not.toHaveBeenCalled(); + expect(harness.dispatch).not.toHaveBeenCalled(); + + const retry = yield* service.checkout(harness.scope, checkoutInput); + expect(retry.checkoutAction).toBe("switched"); + expect(harness.switchRef).toHaveBeenCalledTimes(1); + expect(harness.dispatch).toHaveBeenCalledTimes(1); + }).pipe(Effect.ensuring(Deferred.succeed(release, undefined))); + }), + ); + + it.effect("rolls the git branch back when the durable binding fails", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + dispatchFails: true, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); + expect(harness.switchRef).toHaveBeenNthCalledWith(2, { + cwd: workspaceRoot, + refName: "dev", + }); + }); + }); + + it.effect("keeps a checkout whose durable binding committed before dispatch failed", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + dispatchFails: true, + threadAfterFailedDispatch: { branch: "feature/checkout", worktreePath: null }, + }); + return Effect.gen(function* () { + const result = yield* runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }); + expect(result.checkoutAction).toBe("switched"); + expect(result.current).toMatchObject({ + recordedBranch: "feature/checkout", + actualBranch: "feature/checkout", + }); + expect(harness.switchRef).toHaveBeenCalledTimes(1); + }); + }); + + it.effect("does not roll Git back when a failed binding outcome cannot be verified", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + dispatchFails: true, + threadReadFailsAfterDispatch: true, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + expectTypedFailure(exit, { + _tag: "WorktreeMcpFailure", + code: "partial_failure", + partial: { + workspacePath: workspaceRoot, + recordedBranch: "dev", + actualBranch: "feature/checkout", + rollback: "not_possible", + }, + }); + expect(harness.switchRef).toHaveBeenCalledTimes(1); + }); + }); + + it.effect("does not roll Git back over another actor's durable binding", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + dispatchFails: true, + threadAfterFailedDispatch: { branch: "feature/other-actor", worktreePath: null }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + expectTypedFailure(exit, { + _tag: "WorktreeMcpFailure", + code: "partial_failure", + partial: { + recordedBranch: "feature/other-actor", + rollback: "not_possible", + }, + }); + expect(harness.switchRef).toHaveBeenCalledTimes(1); + }); + }); + + it.effect("does not claim a local switch changed before its first commit capture", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + dispatchFails: true, + resolvedCommits: ["before", "requested", "intervening", "intervening"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + expectTypedFailure(exit, { + _tag: "WorktreeMcpFailure", + code: "partial_failure", + partial: { + actualBranch: "feature/checkout", + rollback: "not_possible", + }, + }); + expect(harness.switchRef).toHaveBeenCalledTimes(1); + expect(harness.dispatch).not.toHaveBeenCalled(); + }); + }); + + for (const [change, options] of [ + ["a new commit", { resolvedCommits: ["before", "selected", "selected", "intervening"] }], + ["new dirty files", { dirtyOnLocalStatusCall: 5 }], + ] as const) { + it.effect(`does not roll Git back over ${change}`, () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + dispatchFails: true, + ...options, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + expectTypedFailure(exit, { + _tag: "WorktreeMcpFailure", + code: "partial_failure", + partial: { actualBranch: "feature/checkout", rollback: "not_possible" }, + }); + expect(harness.switchRef).toHaveBeenCalledTimes(1); + }); + }); + } + + it.effect("rolls back when checkout reports failure after changing the branch", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + switchRefFailsAfterMutation: true, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); + expect(harness.switchRef).toHaveBeenNthCalledWith(2, { + cwd: workspaceRoot, + refName: "dev", + }); + expect(harness.dispatch).not.toHaveBeenCalled(); + }); + }); + + it.effect("preserves unrelated Git state observed immediately after a failed switch", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + switchRefFailsAfterMutation: true, + switchRefFailureBranch: "feature/other-actor", + resolvedCommits: ["before", "requested", "other-actor"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + expectTypedFailure(exit, { + _tag: "WorktreeMcpFailure", + code: "partial_failure", + partial: { + actualBranch: "feature/other-actor", + rollback: "not_possible", + }, + }); + expect(harness.switchRef).toHaveBeenCalledTimes(1); + expect(harness.dispatch).not.toHaveBeenCalled(); + }); + }); + + it.effect("preserves the switched branch when its resulting state cannot be verified", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + localStatusFailsOnCall: 4, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + expectTypedFailure(exit, { + _tag: "WorktreeMcpFailure", + code: "partial_failure", + partial: { actualBranch: null, rollback: "not_possible" }, + }); + expect(harness.switchRef).toHaveBeenCalledTimes(1); + expect(harness.dispatch).not.toHaveBeenCalled(); + }); + }); + + it.effect("reports partial state when binding and rollback both fail", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + dispatchFails: true, + switchRefRollbackFails: true, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + expectTypedFailure(exit, { + _tag: "WorktreeMcpFailure", + code: "partial_failure", + partial: { + workspacePath: workspaceRoot, + recordedBranch: "dev", + actualBranch: "feature/checkout", + rollback: "failed", + }, + }); + }); + }); + + it.effect("treats a retry already on the recorded checkout as unchanged", () => { + const harness = makeHarness({ + thread: { branch: "feature/checkout", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "feature/checkout" } }, + }); + return Effect.gen(function* () { + const result = yield* runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }); + expect(result.checkoutAction).toBe("unchanged"); + expect(harness.switchRef).not.toHaveBeenCalled(); + expect(harness.dispatch).not.toHaveBeenCalled(); + }); + }); + + it.effect("serializes concurrent checkout and handoff requests per thread", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + const harness = makeHarness({ createWorktreeGate: Deferred.await(gate) }); + const service = yield* resolveService(harness); + const first = yield* Effect.forkChild( + service.checkout(harness.scope, { + target: { type: "new_worktree", branch: "feature/guard-checkout" }, + }), + ); + yield* Effect.yieldNow; + const second = yield* Effect.exit( + service.handoff(harness.scope, { branch: "feature/guard-handoff" }), + ); + expectTypedFailure(second, { + _tag: "WorktreeMcpFailure", + code: "handoff_in_progress", + }); + yield* Deferred.succeed(gate, undefined); + yield* Fiber.join(first); + }), + ); + + it.effect("serializes two threads targeting alias paths for the same physical worktree", () => + Effect.gen(function* () { + const targetPath = "/worktrees/project/shared-target"; + const aliasPath = "/aliases/shared-target"; + const otherThreadId = ThreadId.make("thread-worktree-concurrent"); + const dispatchGate = yield* Deferred.make(); + const dispatchStarted = yield* Deferred.make(); + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: [ + rootRefs[0], + { + name: "feature/shared-target", + current: false, + isDefault: false, + worktreePath: targetPath, + }, + ], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [targetPath]: { branch: "feature/shared-target" }, + }, + workspaceAliases: { [aliasPath]: targetPath }, + projectThreads: [ + { id: threadId, title: "Caller", branch: "dev", worktreePath: null }, + { id: otherThreadId, title: "Other", branch: "dev", worktreePath: null }, + ], + dispatchGate: Deferred.succeed(dispatchStarted, undefined).pipe( + Effect.andThen(Deferred.await(dispatchGate)), + ), + }); + const service = yield* resolveService(harness); + const first = yield* Effect.forkChild( + service.checkout(harness.scope, { + target: { type: "worktree", path: aliasPath }, + }), + ); + yield* Deferred.await(dispatchStarted); + const second = yield* Effect.exit( + service.checkout( + { ...harness.scope, threadId: otherThreadId }, + { target: { type: "worktree", path: targetPath } }, + ), + ); + expectTypedFailure(second, { + _tag: "WorktreeMcpFailure", + code: "checkout_in_progress", + }); + yield* Deferred.succeed(dispatchGate, undefined); + yield* Fiber.join(first); + }), + ); }); describe("WorktreeMcpHandoffInput schema", () => { diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 38d10b58902c..023099553f30 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -1,8 +1,12 @@ import { CommandId, MessageId, + type OrchestrationV2ThreadProjection, type OrchestrationV2ThreadShell, type ProjectId, + type VcsRef, + type WorktreeMcpCheckoutInput, + type WorktreeMcpCheckoutResult, WorktreeMcpFailure, type WorktreeMcpContinuationStatus, type WorktreeMcpHandoffInput, @@ -44,11 +48,19 @@ export class WorktreeMcpService extends Context.Service< scope: McpInvocationScope, input: WorktreeMcpListInput, ) => Effect.Effect; + readonly checkout: ( + scope: McpInvocationScope, + input: WorktreeMcpCheckoutInput, + ) => Effect.Effect; } >()("t3/mcp/WorktreeMcpService") {} -function failure(code: WorktreeMcpFailure["code"], message: string): WorktreeMcpFailure { - return new WorktreeMcpFailure({ code, message }); +function failure( + code: WorktreeMcpFailure["code"], + message: string, + partial?: WorktreeMcpFailure["partial"], +): WorktreeMcpFailure { + return new WorktreeMcpFailure({ code, message, ...(partial === undefined ? {} : { partial }) }); } function errorMessage(error: unknown): string { @@ -75,10 +87,11 @@ const make = Effect.gen(function* () { const setupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - // Serializes handoffs per thread: two concurrent calls could otherwise both - // pass the worktreePath === null check and each create a worktree, leaving - // one untracked on disk. - const handoffThreadsInFlight = new Set(); + // Serializes workspace transitions per thread so two calls cannot both + // mutate Git and then race to write different durable bindings. + const workspaceTransitionsInFlight = new Set(); + const physicalWorkspaceGuardKey = (repositoryCommonDir: string, workspacePath: string) => + `workspace:${repositoryCommonDir}:${workspacePath}`; const requireCapability = (scope: McpInvocationScope) => scope.capabilities.has("worktree") @@ -139,6 +152,35 @@ const make = Effect.gen(function* () { return yield* canonicalizePath(thread.worktreePath ?? projectWorkspaceRoot); }); + const loadRefs = Effect.fn("WorktreeMcpService.loadRefs")(function* ( + projectWorkspaceRoot: string, + refKind: "all" | "local" = "all", + ) { + const refs: Array = []; + let cursor: number | undefined; + do { + const page = yield* gitWorkflow + .listRefs({ + cwd: projectWorkspaceRoot, + refKind, + includeMatchingRemoteRefs: refKind === "all", + refresh: cursor === undefined, + limit: 200, + ...(cursor === undefined ? {} : { cursor }), + }) + .pipe(asOperationFailed("Unable to list project worktrees and branches")); + if (!page.isRepo) { + return yield* failure( + "invalid_request", + `Project workspace '${projectWorkspaceRoot}' is not a git repository.`, + ); + } + refs.push(...page.refs); + cursor = page.nextCursor ?? undefined; + } while (cursor !== undefined); + return refs; + }); + const loadWorktrees = Effect.fn("WorktreeMcpService.loadWorktrees")(function* ( projectWorkspaceRoot: string, ) { @@ -167,34 +209,146 @@ const make = Effect.gen(function* () { asOperationFailed(`Unable to read git status in '${workspacePath}'`), ); - const handoffIds = (scope: McpInvocationScope) => + const loadWorkspaceBindingInventory = Effect.fn( + "WorktreeMcpService.loadWorkspaceBindingInventory", + )(function* (workspacePath: string) { + const inventoryExit = yield* Effect.exit(loadWorktrees(workspacePath)); + if (Exit.isSuccess(inventoryExit)) { + return Option.some(inventoryExit.value); + } + const statusExit = yield* Effect.exit(readWorkspaceStatus(workspacePath)); + if (Exit.isSuccess(statusExit) && !statusExit.value.isRepo) { + return Option.none(); + } + return yield* Effect.failCause(inventoryExit.cause); + }); + + const loadActiveWorkspaceBindings = Effect.fn("WorktreeMcpService.loadActiveWorkspaceBindings")( + function* (repositoryCommonDir: string) { + const snapshot = yield* threadManagement + .getShellSnapshot() + .pipe(asOperationFailed("Unable to inspect thread workspace bindings")); + const byProject = new Map>(); + for (const thread of [...snapshot.threads, ...snapshot.archivedThreads]) { + const projectThreads = byProject.get(thread.projectId) ?? []; + projectThreads.push(thread); + byProject.set(thread.projectId, projectThreads); + } + + return yield* Effect.forEach( + [...byProject.entries()], + ([projectId, projectThreads]) => + projects.getById(projectId).pipe( + asOperationFailed( + `Unable to read project ${projectId} while checking workspace owners`, + ), + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed([]), + onSome: (project) => + Effect.gen(function* () { + const projectInventory = yield* loadWorkspaceBindingInventory( + project.workspaceRoot, + ); + return yield* Effect.forEach(projectThreads, (thread) => + Effect.gen(function* () { + const inventory = + thread.worktreePath === null + ? projectInventory + : yield* loadWorkspaceBindingInventory(thread.worktreePath); + if ( + Option.isNone(inventory) || + inventory.value.repositoryCommonDir !== repositoryCommonDir + ) { + return []; + } + if (inventory.value.currentWorktreeRoot === null) { + return yield* failure( + "operation_failed", + `Unable to resolve the physical checkout for possible owner thread '${thread.id}'.`, + ); + } + return [[thread, inventory.value.currentWorktreeRoot] as const]; + }), + ).pipe(Effect.map((bindings) => bindings.flat())); + }), + }), + ), + ), + { concurrency: 4 }, + ).pipe(Effect.map((bindings) => bindings.flat())); + }, + ); + + const readWorkspaceBranchOrNull = (workspacePath: string) => + readWorkspaceStatus(workspacePath).pipe( + Effect.map((status) => status.refName), + Effect.orElseSucceed(() => null), + ); + + const transitionIds = (scope: McpInvocationScope, operation: "worktree-handoff" | "checkout") => crypto.randomUUIDv4.pipe( Effect.map((uuid) => { - const part = (kind: string, operation: string) => - [kind, "mcp", encodeURIComponent(scope.providerSessionId), operation, uuid].join(":"); + const part = (kind: string, suffix: string) => + [kind, "mcp", encodeURIComponent(scope.providerSessionId), operation, suffix, uuid].join( + ":", + ); return { - commandId: CommandId.make(part("command", "worktree-handoff")), - continuationCommandId: CommandId.make(part("command", "worktree-continuation")), - continuationMessageId: MessageId.make(part("message", "worktree-continuation")), + commandId: CommandId.make(part("command", "binding")), + continuationCommandId: CommandId.make(part("command", "continuation")), + continuationMessageId: MessageId.make(part("message", "continuation")), }; }), Effect.orDie, ); + const queueContinuation = Effect.fn("WorktreeMcpService.queueContinuation")(function* (input: { + readonly scope: McpInvocationScope; + readonly projection: OrchestrationV2ThreadProjection; + readonly prompt: string | undefined; + readonly commandId: CommandId; + readonly messageId: MessageId; + readonly workspacePath: string; + }): Effect.fn.Return { + if (input.prompt === undefined) { + return { status: "skipped" }; + } + return yield* threadManagement + .sendToThread({ + projectId: input.projection.thread.projectId, + commandId: input.commandId, + threadId: input.scope.threadId, + messageId: input.messageId, + text: input.prompt, + attachments: [], + mode: "queue", + createdBy: "agent", + creationSource: "mcp", + }) + .pipe( + Effect.map( + (sendResult): WorktreeMcpContinuationStatus => ({ + status: "scheduled", + delivery: sendResult.delivery, + }), + ), + Effect.catchCause((cause) => { + const detail = errorMessage(Cause.squash(cause)); + return Effect.logWarning("workspace transition continuation failed to queue", { + threadId: input.scope.threadId, + workspacePath: input.workspacePath, + detail, + }).pipe(Effect.as({ status: "failed", detail } as const)); + }), + ); + }); + const performHandoff = Effect.fn("WorktreeMcpService.performHandoff")(function* ( scope: McpInvocationScope, input: WorktreeMcpHandoffInput, + initialProjection?: OrchestrationV2ThreadProjection, ) { - const alreadyInWorktree = (worktreePath: string) => - failure( - "already_in_worktree", - `Thread '${scope.threadId}' is already attached to worktree '${worktreePath}'.`, - ); - - const projection = yield* loadThread(scope); - if (projection.thread.worktreePath !== null) { - return yield* alreadyInWorktree(projection.thread.worktreePath); - } + const projection = initialProjection ?? (yield* loadThread(scope)); // An archived thread would accept the binding but refuse the continuation // message (and any other follow-up), so reject the handoff outright. if (projection.thread.archivedAt !== null) { @@ -205,7 +359,46 @@ const make = Effect.gen(function* () { } const project = yield* loadProject(scope, projection.thread.projectId); - const projectCwd = project.workspaceRoot; + const projectCwd = yield* canonicalizePath(project.workspaceRoot); + const projectInventory = yield* loadWorktrees(projectCwd); + const projectWorktreeRoot = projectInventory.currentWorktreeRoot; + if (projectWorktreeRoot === null) { + return yield* failure( + "invalid_request", + `Git could not resolve the physical checkout for project '${projection.thread.projectId}'.`, + ); + } + const recordedSourceCwd = yield* canonicalizePath( + projection.thread.worktreePath ?? projectWorktreeRoot, + ); + const sourceInventory = + projection.thread.worktreePath === null + ? Option.some(projectInventory) + : yield* loadWorkspaceBindingInventory(recordedSourceCwd); + const sourceCwd = Option.match(sourceInventory, { + // A stale binding must not make recovery impossible. New worktree + // creation can safely resolve its base from the healthy project root; + // nothing reads from or mutates the missing old checkout. + onNone: () => projectWorktreeRoot, + onSome: (inventory) => inventory.currentWorktreeRoot, + }); + if ( + Option.isSome(sourceInventory) && + (sourceInventory.value.repositoryCommonDir !== projectInventory.repositoryCommonDir || + sourceCwd === null || + !projectInventory.worktrees.some((worktree) => worktree.path === sourceCwd)) + ) { + return yield* failure( + "scope_mismatch", + `Thread worktree '${projection.thread.worktreePath}' is not registered in project '${projection.thread.projectId}'.`, + ); + } + if (sourceCwd === null) { + return yield* failure( + "invalid_request", + `Git could not resolve the physical checkout for thread '${scope.threadId}'.`, + ); + } if (input.path !== undefined && !path.isAbsolute(input.path)) { return yield* failure( @@ -217,13 +410,11 @@ const make = Effect.gen(function* () { // The repo check runs regardless of whether baseRef was supplied, so a // non-repository workspace fails with an actionable error instead of an // opaque git failure further down. - const localStatus = yield* gitWorkflow - .localStatus({ cwd: projectCwd }) - .pipe(asOperationFailed("Unable to read git status")); + const localStatus = yield* readWorkspaceStatus(sourceCwd); if (!localStatus.isRepo) { return yield* failure( "invalid_request", - `Project workspace '${projectCwd}' is not a git repository.`, + `Thread workspace '${sourceCwd}' is not a git repository.`, ); } @@ -284,7 +475,12 @@ const make = Effect.gen(function* () { worktreeBaseRef = resolvedRemoteBase.commitSha; } - const ids = yield* handoffIds(scope); + const expectedCreationCommit = yield* gitWorkflow + .resolveCommit({ cwd: projectCwd, revision: worktreeBaseRef }) + .pipe(asOperationFailed(`Unable to resolve worktree base '${worktreeBaseRef}'`)); + + const ids = yield* transitionIds(scope, "worktree-handoff"); + let acquiredPhysicalWorkspaceGuard: string | null = null; // uninterruptibleMask: only the potentially slow worktree creation itself // stays interruptible (restore). From the moment it succeeds, through the @@ -307,7 +503,86 @@ const make = Effect.gen(function* () { }) .pipe(asOperationFailed("Unable to create the worktree")), ); - const worktreePath = worktree.worktree.path; + const createdInventoryExit = yield* Effect.exit(loadWorktrees(worktree.worktree.path)); + if (Exit.isFailure(createdInventoryExit)) { + return yield* failure( + "partial_failure", + `The worktree was created, but Git could not resolve its physical checkout identity: ${errorMessage(Cause.squash(createdInventoryExit.cause))}`, + { + workspacePath: worktree.worktree.path, + recordedBranch: projection.thread.branch, + actualBranch: worktree.worktree.refName, + rollback: "not_possible", + }, + ); + } + const createdInventory = createdInventoryExit.value; + const worktreePath = createdInventory.currentWorktreeRoot; + if ( + worktreePath === null || + createdInventory.repositoryCommonDir !== projectInventory.repositoryCommonDir + ) { + return yield* failure( + "partial_failure", + "The worktree was created, but Git resolved it outside the calling thread's repository. The durable binding was not changed and the checkout was retained.", + { + workspacePath: worktree.worktree.path, + recordedBranch: projection.thread.branch, + actualBranch: worktree.worktree.refName, + rollback: "not_possible", + }, + ); + } + const workspaceGuardKey = physicalWorkspaceGuardKey( + createdInventory.repositoryCommonDir, + worktreePath, + ); + if (workspaceTransitionsInFlight.has(workspaceGuardKey)) { + return yield* failure( + "partial_failure", + `The worktree was created, but another workspace transition acquired '${worktreePath}' before the handoff could reserve it. The durable thread binding was not changed and the worktree was retained.`, + { + workspacePath: worktreePath, + recordedBranch: projection.thread.branch, + actualBranch: worktree.worktree.refName, + rollback: "not_possible", + }, + ); + } + workspaceTransitionsInFlight.add(workspaceGuardKey); + acquiredPhysicalWorkspaceGuard = workspaceGuardKey; + + const ownerBindingsExit = yield* Effect.exit( + loadActiveWorkspaceBindings(projectInventory.repositoryCommonDir), + ); + if (Exit.isFailure(ownerBindingsExit)) { + return yield* failure( + "partial_failure", + `The worktree was created, but its ownership could not be verified before binding: ${errorMessage(Cause.squash(ownerBindingsExit.cause))}`, + { + workspacePath: worktreePath, + recordedBranch: projection.thread.branch, + actualBranch: worktree.worktree.refName, + rollback: "not_possible", + }, + ); + } + const competingOwner = ownerBindingsExit.value.find( + ([thread, workspacePath]) => + thread.id !== scope.threadId && workspacePath === worktreePath, + ); + if (competingOwner !== undefined) { + return yield* failure( + "partial_failure", + `The created worktree '${worktreePath}' became bound to thread '${competingOwner[0].id}' before this handoff could commit. The calling thread binding was not changed and the worktree was retained.`, + { + workspacePath: worktreePath, + recordedBranch: projection.thread.branch, + actualBranch: worktree.worktree.refName, + rollback: "not_possible", + }, + ); + } // Shared shape for "the handoff already succeeded, so report the failure // in the result instead of failing the call" (continuation, setup script). @@ -321,77 +596,164 @@ const make = Effect.gen(function* () { }).pipe(Effect.as({ status: "failed", detail } as const)); }); - // suspend: build the rollback only if cleanup actually runs. Removing - // the worktree must succeed before deleting its freshly created branch; - // otherwise the branch may still be checked out there. - const removeCreatedWorktree = Effect.suspend(() => - gitWorkflow.removeWorktree({ cwd: projectCwd, path: worktreePath, force: true }).pipe( - Effect.andThen( - Effect.suspend(() => - gitWorkflow.deleteLocalBranch({ - cwd: projectCwd, - refName: worktree.worktree.refName, - force: true, - }), - ), + let createdWorktreeRemoved = false; + const removeCreatedWorktreeIfOwned = Effect.fn( + "WorktreeMcpService.removeCreatedWorktreeIfOwned", + )(function* () { + const verificationExit = yield* Effect.exit( + Effect.all( + [ + loadActiveWorkspaceBindings(projectInventory.repositoryCommonDir), + threadManagement.getThreadProjection(scope.threadId), + loadWorktrees(worktreePath), + readWorkspaceStatus(worktreePath), + gitWorkflow.resolveCommit({ cwd: worktreePath, revision: "HEAD" }), + ], + { concurrency: 5 }, ), - ), - ).pipe(Effect.ignoreCause({ log: true })); - - const recheckAndBind = Effect.gen(function* () { - // The projection was read before the potentially slow git work - // above; a concurrent binding (for example from the UI) could have - // attached the thread in the meantime. Re-check before committing so - // the race cannot leave a second, untracked worktree. - const recheck = yield* loadThread(scope); - if (recheck.thread.worktreePath !== null) { - return yield* alreadyInWorktree(recheck.thread.worktreePath); + ); + if (Exit.isFailure(verificationExit)) { + return "not_possible" as const; } - // Mirror the up-front archived check: the thread may have been - // archived during the slow git work, and an archived thread must - // not be bound to a fresh worktree it can never use. - if (recheck.thread.archivedAt !== null) { + const [bindings, callerProjection, worktreeInventory, worktreeStatus, currentCommit] = + verificationExit.value; + const competingBinding = bindings.some( + ([thread, workspacePath]) => + thread.id !== scope.threadId && workspacePath === worktreePath, + ); + const callerStillUnbound = + callerProjection.thread.branch === projection.thread.branch && + callerProjection.thread.worktreePath === projection.thread.worktreePath; + const checkoutStillCreatedByThisCall = + worktreeInventory.repositoryCommonDir === projectInventory.repositoryCommonDir && + worktreeInventory.currentWorktreeRoot === worktreePath && + worktreeStatus.isRepo && + !worktreeStatus.hasWorkingTreeChanges && + worktreeStatus.refName === worktree.worktree.refName && + currentCommit.commitSha === expectedCreationCommit.commitSha; + if (competingBinding || !callerStillUnbound || !checkoutStillCreatedByThisCall) { + return "not_possible" as const; + } + const removalExit = yield* Effect.exit( + gitWorkflow.removeWorktree({ cwd: projectCwd, path: worktreePath, force: true }), + ); + if (Exit.isFailure(removalExit)) { + return "failed" as const; + } + createdWorktreeRemoved = true; + return "removed" as const; + }); + + const recheckExit = yield* Effect.exit( + Effect.gen(function* () { + // The projection was read before the potentially slow git work + // above; a concurrent binding (for example from the UI) could have + // attached the thread in the meantime. Re-check before committing so + // the race cannot leave a second, untracked worktree. + const recheck = yield* loadThread(scope); + if ( + recheck.thread.worktreePath !== projection.thread.worktreePath || + recheck.thread.branch !== projection.thread.branch + ) { + return yield* failure( + "operation_failed", + `Thread '${scope.threadId}' changed workspace while the new worktree was being created; the handoff was rolled back.`, + ); + } + // Mirror the up-front archived check: the thread may have been + // archived during the slow git work, and an archived thread must + // not be bound to a fresh worktree it can never use. + if (recheck.thread.archivedAt !== null) { + return yield* failure( + "invalid_request", + `Thread '${scope.threadId}' was archived while the worktree was being created; the handoff was rolled back.`, + ); + } + }), + ); + if (Exit.isFailure(recheckExit)) { + if (Cause.hasInterruptsOnly(recheckExit.cause)) { + return yield* Effect.failCause(recheckExit.cause as Cause.Cause); + } + const cleanup = yield* removeCreatedWorktreeIfOwned(); + if (cleanup !== "removed") { return yield* failure( - "invalid_request", - `Thread '${scope.threadId}' was archived while the worktree was being created; the handoff was rolled back.`, + "partial_failure", + `The handoff failed before binding and the created worktree was ${cleanup === "failed" ? "not removed" : "retained because exclusive ownership could not be proven"}: ${errorMessage(Cause.squash(recheckExit.cause))}`, + { + workspacePath: worktreePath, + recordedBranch: projection.thread.branch, + actualBranch: createdWorktreeRemoved ? null : worktree.worktree.refName, + rollback: cleanup === "failed" ? "failed" : "not_possible", + }, ); } - yield* threadManagement - .dispatch({ - type: "thread.metadata.update", - commandId: ids.commandId, - threadId: scope.threadId, - branch: worktree.worktree.refName, - worktreePath, - expectedWorktreePath: null, - }) - .pipe( - Effect.catchCause((cause) => - // Interrupt-only causes propagate unchanged: whether the - // dispatch committed is unknown, so neither a typed failure - // nor a rollback would be correct. Failures and defects - // (including mixed causes) map to a typed operation_failed. - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause as Cause.Cause) - : Effect.fail( - failure( - "operation_failed", - `Unable to re-point the thread at the worktree: ${errorMessage(Cause.squash(cause))}`, - ), - ), - ), - ); - }).pipe( - // onError: the worktree was already created, so any failure between - // here and the committed binding (recheck read, recheck race, - // dispatch typed failure or defect) must remove it again so a failed - // handoff leaves nothing behind on disk. Interrupt-only causes skip - // the removal: the binding may have committed, and force-deleting a - // worktree the thread now points at would be worse than leaking one. - Effect.onError((cause) => - Cause.hasInterruptsOnly(cause) ? Effect.void : removeCreatedWorktree, - ), + return yield* Effect.failCause(recheckExit.cause); + } + + const dispatchExit = yield* Effect.exit( + threadManagement.dispatch({ + type: "thread.metadata.update", + commandId: ids.commandId, + threadId: scope.threadId, + branch: worktree.worktree.refName, + worktreePath, + expectedBranch: projection.thread.branch, + expectedWorktreePath: projection.thread.worktreePath, + expectedArchived: false, + }), ); + if (Exit.isFailure(dispatchExit)) { + if (Cause.hasInterruptsOnly(dispatchExit.cause)) { + return yield* Effect.failCause(dispatchExit.cause as Cause.Cause); + } + const dispatchDetail = errorMessage(Cause.squash(dispatchExit.cause)); + const bindingAfterDispatchExit = yield* Effect.exit(loadThread(scope)); + if (Exit.isFailure(bindingAfterDispatchExit)) { + return yield* failure( + "partial_failure", + `The worktree binding reported a failure and its durable outcome could not be verified: ${dispatchDetail}`, + { + workspacePath: worktreePath, + recordedBranch: projection.thread.branch, + actualBranch: worktree.worktree.refName, + rollback: "not_possible", + }, + ); + } + const bindingAfterDispatch = bindingAfterDispatchExit.value.thread; + const bindingCommitted = + bindingAfterDispatch.branch === worktree.worktree.refName && + bindingAfterDispatch.worktreePath === worktreePath; + if (bindingCommitted) { + yield* Effect.logWarning( + "worktree binding dispatch reported failure after the binding committed", + { + threadId: scope.threadId, + worktreePath, + detail: dispatchDetail, + }, + ); + } else { + const cleanup = yield* removeCreatedWorktreeIfOwned(); + if (cleanup !== "removed") { + return yield* failure( + "partial_failure", + `The worktree binding failed and the created worktree was ${cleanup === "failed" ? "not removed" : "retained because exclusive ownership could not be proven"}: ${dispatchDetail}`, + { + workspacePath: worktreePath, + recordedBranch: bindingAfterDispatch.branch, + actualBranch: createdWorktreeRemoved ? null : worktree.worktree.refName, + rollback: cleanup === "failed" ? "failed" : "not_possible", + }, + ); + } + return yield* failure( + "operation_failed", + `Unable to re-point the thread at the worktree: ${dispatchDetail}`, + ); + } + } // Queue the continuation right after the binding commits: the detach // that the metadata update schedules will terminate the calling @@ -401,36 +763,18 @@ const make = Effect.gen(function* () { // derives its cwd from the updated projection. // suspend: build the send effect only when the binding has succeeded, // so a failed dispatch never even constructs the continuation call. - const queueContinuation: Effect.Effect = - Effect.suspend(() => - input.continuationPrompt === undefined - ? Effect.succeed({ status: "skipped" }) - : threadManagement - .sendToThread({ - projectId: projection.thread.projectId, - commandId: ids.continuationCommandId, - threadId: scope.threadId, - messageId: ids.continuationMessageId, - text: input.continuationPrompt, - attachments: [], - mode: "queue", - createdBy: "agent", - creationSource: "mcp", - }) - .pipe( - Effect.map( - (sendResult): WorktreeMcpContinuationStatus => ({ - status: "scheduled", - delivery: sendResult.delivery, - }), - ), - // catchCause via reportFailed: the binding is already recorded, - // so a failed continuation must be reported, not fail the handoff. - reportFailed("worktree handoff continuation failed to queue"), - ), - ); + const queueHandoffContinuation = Effect.suspend(() => + queueContinuation({ + scope, + projection, + prompt: input.continuationPrompt, + commandId: ids.continuationCommandId, + messageId: ids.continuationMessageId, + workspacePath: worktreePath, + }), + ); - const continuation = yield* recheckAndBind.pipe(Effect.andThen(queueContinuation)); + const continuation = yield* queueHandoffContinuation; yield* vcsStatusBroadcaster .refreshStatus(worktreePath) @@ -480,6 +824,14 @@ const make = Effect.gen(function* () { }; return result; }), + ).pipe( + Effect.ensuring( + Effect.sync(() => { + if (acquiredPhysicalWorkspaceGuard !== null) { + workspaceTransitionsInFlight.delete(acquiredPhysicalWorkspaceGuard); + } + }), + ), ); }); @@ -493,7 +845,7 @@ const make = Effect.gen(function* () { // handoff for this thread until restart. return yield* Effect.uninterruptibleMask((restore) => Effect.suspend(() => { - if (handoffThreadsInFlight.has(scope.threadId)) { + if (workspaceTransitionsInFlight.has(scope.threadId)) { return Effect.fail( failure( "handoff_in_progress", @@ -501,9 +853,9 @@ const make = Effect.gen(function* () { ), ); } - handoffThreadsInFlight.add(scope.threadId); + workspaceTransitionsInFlight.add(scope.threadId); return restore(performHandoff(scope, input)).pipe( - Effect.ensuring(Effect.sync(() => handoffThreadsInFlight.delete(scope.threadId))), + Effect.ensuring(Effect.sync(() => workspaceTransitionsInFlight.delete(scope.threadId))), ); }), ); @@ -538,7 +890,7 @@ const make = Effect.gen(function* () { ? workspaceInventory.value.currentWorktreeRoot : null; const agreement = - !actual.isRepo && !workspaceExists + !actual.isRepo && !workspaceExists && Option.isNone(workspaceInventory) ? "workspace_missing" : !actual.isRepo ? "not_repository" @@ -759,7 +1111,862 @@ const make = Effect.gen(function* () { } satisfies WorktreeMcpListResult; }); - return WorktreeMcpService.of({ handoff, status, listWorktrees }); + const performCheckout = Effect.fn("WorktreeMcpService.performCheckout")(function* ( + scope: McpInvocationScope, + input: WorktreeMcpCheckoutInput, + ) { + const projection = yield* loadThread(scope); + if (projection.thread.archivedAt !== null) { + return yield* failure( + "invalid_request", + `Thread '${scope.threadId}' is archived and cannot change workspace.`, + ); + } + + const project = yield* loadProject(scope, projection.thread.projectId); + const projectWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot); + const recordedWorkspacePath = yield* canonicalizePath( + projection.thread.worktreePath ?? projectWorkspaceRoot, + ); + if (input.target.type === "new_worktree") { + const previousActualBranch = yield* readWorkspaceBranchOrNull(recordedWorkspacePath); + const handoff = yield* performHandoff( + scope, + { + branch: input.target.branch, + ...(input.target.baseRef === undefined ? {} : { baseRef: input.target.baseRef }), + ...(input.target.startFromOrigin === undefined + ? {} + : { startFromOrigin: input.target.startFromOrigin }), + ...(input.target.path === undefined ? {} : { path: input.target.path }), + ...(input.target.runSetupScript === undefined + ? {} + : { runSetupScript: input.target.runSetupScript }), + ...(input.continuationPrompt === undefined + ? {} + : { continuationPrompt: input.continuationPrompt }), + }, + projection, + ); + return { + previous: { + workspacePath: recordedWorkspacePath, + recordedBranch: projection.thread.branch, + recordedWorktreePath: projection.thread.worktreePath, + actualBranch: previousActualBranch, + }, + current: { + workspacePath: handoff.worktreePath, + recordedBranch: handoff.branch, + recordedWorktreePath: handoff.worktreePath, + actualBranch: handoff.branch, + }, + checkoutAction: "created", + workspaceChanged: true, + branchChanged: previousActualBranch !== handoff.branch, + continuation: handoff.continuation, + setupScript: handoff.setupScript, + callerTurnEnds: true, + note: handoff.note, + } satisfies WorktreeMcpCheckoutResult; + } + const [inventory, currentInventory] = yield* Effect.all( + [loadWorktrees(projectWorkspaceRoot), loadWorkspaceBindingInventory(recordedWorkspacePath)], + { concurrency: 2 }, + ); + if ( + Option.isSome(currentInventory) && + inventory.repositoryCommonDir !== currentInventory.value.repositoryCommonDir + ) { + return yield* failure( + "scope_mismatch", + `Thread workspace '${recordedWorkspacePath}' does not belong to the calling thread's project repository.`, + ); + } + const projectWorktreeRoot = inventory.currentWorktreeRoot; + const currentWorkspacePath = Option.isSome(currentInventory) + ? currentInventory.value.currentWorktreeRoot + : null; + if (projectWorktreeRoot === null) { + return yield* failure( + "invalid_request", + "Git could not resolve the physical project checkout.", + ); + } + if (Option.isSome(currentInventory) && currentWorkspacePath === null) { + return yield* failure( + "invalid_request", + "Git could not resolve the physical thread checkout.", + ); + } + const [refs, threads, previousActual] = yield* Effect.all( + [ + loadRefs(projectWorkspaceRoot), + loadProjectThreads(projection.thread.projectId), + Option.isNone(currentInventory) + ? Effect.succeed({ + isRepo: false, + refName: null, + hasWorkingTreeChanges: false, + }) + : readWorkspaceStatus(recordedWorkspacePath), + ], + { concurrency: 3 }, + ); + + const localRefs = refs.filter((ref) => ref.isRemote !== true); + const workspacePaths = new Set(inventory.worktrees.map((worktree) => worktree.path)); + const localRefByName = new Map(localRefs.map((ref) => [ref.name, ref])); + const remoteRefByName = new Map( + refs.filter((ref) => ref.isRemote === true).map((ref) => [ref.name, ref]), + ); + + let targetWorkspacePath: string; + let requestedBranch: string | undefined; + let createBranch = false; + let selectedRef: VcsRef | undefined; + + switch (input.target.type) { + case "worktree": { + const targetInventory = yield* loadWorktrees(input.target.path); + targetWorkspacePath = + targetInventory.currentWorktreeRoot ?? (yield* canonicalizePath(input.target.path)); + if ( + targetInventory.repositoryCommonDir !== inventory.repositoryCommonDir || + targetWorkspacePath === projectWorktreeRoot || + !workspacePaths.has(targetWorkspacePath) + ) { + return yield* failure( + "scope_mismatch", + targetWorkspacePath === projectWorktreeRoot + ? "Use target.type='project_root' to return to the project's main checkout." + : `Worktree '${input.target.path}' does not belong to project '${projection.thread.projectId}'. Call t3_worktree_list and choose one of its paths.`, + ); + } + break; + } + case "project_root": { + targetWorkspacePath = projectWorktreeRoot; + requestedBranch = input.target.branch; + createBranch = input.target.create ?? false; + if (createBranch && requestedBranch === undefined) { + return yield* failure( + "invalid_request", + "target.create requires target.branch when checking out the project root.", + ); + } + if (requestedBranch !== undefined) { + selectedRef = localRefByName.get(requestedBranch) ?? remoteRefByName.get(requestedBranch); + if (createBranch && localRefByName.has(requestedBranch)) { + return yield* failure( + "invalid_request", + `Local branch '${requestedBranch}' already exists. Omit target.create to check it out.`, + ); + } + if (!createBranch && selectedRef === undefined) { + return yield* failure( + "invalid_request", + `Branch or remote ref '${requestedBranch}' does not exist. Pass target.create=true to create a local branch from the project root.`, + ); + } + } + break; + } + case "branch": { + requestedBranch = input.target.branch; + createBranch = input.target.create ?? false; + selectedRef = localRefByName.get(requestedBranch) ?? remoteRefByName.get(requestedBranch); + if (createBranch && localRefByName.has(requestedBranch)) { + return yield* failure( + "invalid_request", + `Local branch '${requestedBranch}' already exists. Omit target.create to check it out.`, + ); + } + if (!createBranch && selectedRef === undefined) { + return yield* failure( + "invalid_request", + `Branch or remote ref '${requestedBranch}' does not exist. Pass target.create=true to create a local branch from the current checkout.`, + ); + } + const workspace = input.target.workspace ?? "auto"; + const selectedWorktreePath = + selectedRef?.isRemote === true || selectedRef?.worktreePath == null + ? null + : selectedRef.worktreePath; + targetWorkspacePath = + workspace === "project_root" + ? projectWorktreeRoot + : workspace === "current" + ? (currentWorkspacePath ?? recordedWorkspacePath) + : (selectedWorktreePath ?? + (currentWorkspacePath === null || + (projection.thread.worktreePath !== null && selectedRef?.isDefault === true) + ? projectWorktreeRoot + : currentWorkspacePath)); + break; + } + } + + if (!workspacePaths.has(targetWorkspacePath)) { + return yield* failure( + "scope_mismatch", + `Checkout target '${targetWorkspacePath}' is outside the calling thread's project worktrees.`, + ); + } + + const targetBefore = + targetWorkspacePath === currentWorkspacePath + ? previousActual + : yield* readWorkspaceStatus(targetWorkspacePath); + if (!targetBefore.isRepo) { + return yield* failure( + "invalid_request", + `Checkout target '${targetWorkspacePath}' is not a git repository.`, + ); + } + + if (input.target.type === "worktree") { + requestedBranch = targetBefore.refName ?? undefined; + selectedRef = + targetBefore.refName === null ? undefined : localRefByName.get(targetBefore.refName); + } else if (requestedBranch !== undefined) { + selectedRef = localRefByName.get(requestedBranch) ?? remoteRefByName.get(requestedBranch); + } + + const selectedWorktreePath = + selectedRef?.isRemote === true || selectedRef?.worktreePath == null + ? null + : selectedRef.worktreePath; + if ( + !createBranch && + requestedBranch !== undefined && + selectedWorktreePath !== null && + selectedWorktreePath !== targetWorkspacePath + ) { + return yield* failure( + "workspace_in_use", + `Branch '${requestedBranch}' is checked out at '${selectedWorktreePath}'. Use target.type='worktree' with that path or target.workspace='auto' to reuse it.`, + ); + } + + const shouldMutateCheckout = + requestedBranch !== undefined && + (createBranch || selectedRef?.isRemote === true || targetBefore.refName !== requestedBranch); + const threadWorkspaces = yield* Effect.forEach(threads, (thread) => + Effect.gen(function* () { + const recordedPath = yield* threadWorkspacePath(thread, projectWorktreeRoot); + const recordedInventory = yield* loadWorkspaceBindingInventory(recordedPath); + const workspacePath = Option.match(recordedInventory, { + onNone: () => recordedPath, + onSome: (candidateInventory) => + candidateInventory.repositoryCommonDir === inventory.repositoryCommonDir && + candidateInventory.currentWorktreeRoot !== null + ? candidateInventory.currentWorktreeRoot + : recordedPath, + }); + return [thread, workspacePath] as const; + }), + ); + const otherBindings = threadWorkspaces + .filter( + ([thread, workspacePath]) => + thread.id !== scope.threadId && workspacePath === targetWorkspacePath, + ) + .map(([thread]) => thread); + const activeBinding = otherBindings.find((thread) => thread.activeRunId !== null); + if ((targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) && activeBinding) { + return yield* failure( + "workspace_in_use", + `Checkout '${targetWorkspacePath}' is in use by active thread '${activeBinding.id}' (${activeBinding.title}).`, + ); + } + if ( + targetWorkspacePath !== projectWorktreeRoot && + (targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) && + otherBindings.length > 0 + ) { + return yield* failure( + "workspace_shared", + `Worktree '${targetWorkspacePath}' is already bound to thread '${otherBindings[0]!.id}'. Reusing it would make two threads share one mutable checkout.`, + ); + } + if ( + targetWorkspacePath === projectWorktreeRoot && + shouldMutateCheckout && + otherBindings.length > 0 + ) { + return yield* failure( + "workspace_shared", + `The project root is also bound to thread '${otherBindings[0]!.id}'. Switching its branch would make that thread's recorded branch disagree with Git.`, + ); + } + if (shouldMutateCheckout && targetBefore.hasWorkingTreeChanges) { + return yield* failure( + "dirty_workspace", + `Checkout '${targetWorkspacePath}' has uncommitted files. Commit or discard them before switching branches.`, + ); + } + + const ids = yield* transitionIds(scope, "checkout"); + const workspaceGuardKey = physicalWorkspaceGuardKey( + inventory.repositoryCommonDir, + targetWorkspacePath, + ); + return yield* Effect.uninterruptibleMask((restore) => + Effect.suspend(() => { + if (workspaceTransitionsInFlight.has(workspaceGuardKey)) { + return Effect.fail( + failure( + "checkout_in_progress", + `Another workspace transition is already in progress for '${targetWorkspacePath}'.`, + ), + ); + } + workspaceTransitionsInFlight.add(workspaceGuardKey); + return Effect.gen(function* () { + const [latestProjection, latestInventory, latestBindings, latestTargetBefore] = + yield* restore( + Effect.all( + [ + loadThread(scope), + loadWorktrees(projectWorkspaceRoot), + loadActiveWorkspaceBindings(inventory.repositoryCommonDir), + readWorkspaceStatus(targetWorkspacePath), + ], + { concurrency: 4 }, + ), + ); + if ( + latestProjection.thread.branch !== projection.thread.branch || + latestProjection.thread.worktreePath !== projection.thread.worktreePath || + latestProjection.thread.archivedAt !== projection.thread.archivedAt + ) { + return yield* failure( + "checkout_in_progress", + `Thread '${scope.threadId}' changed workspace state while checkout was being prepared. Retry from its current binding.`, + ); + } + if ( + latestInventory.repositoryCommonDir !== inventory.repositoryCommonDir || + !latestInventory.worktrees.some((worktree) => worktree.path === targetWorkspacePath) + ) { + return yield* failure( + "scope_mismatch", + `Checkout target '${targetWorkspacePath}' is no longer registered in the calling thread's Git repository.`, + ); + } + if ( + latestTargetBefore.refName !== targetBefore.refName || + latestTargetBefore.hasWorkingTreeChanges !== targetBefore.hasWorkingTreeChanges + ) { + return yield* failure( + "checkout_in_progress", + `Checkout '${targetWorkspacePath}' changed while the transition was being prepared. Retry from its current Git state.`, + ); + } + if (shouldMutateCheckout && latestTargetBefore.hasWorkingTreeChanges) { + return yield* failure( + "dirty_workspace", + `Checkout '${targetWorkspacePath}' has uncommitted files. Commit or discard them before switching branches.`, + ); + } + const latestOtherBindings = latestBindings + .filter( + ([thread, workspacePath]) => + thread.id !== scope.threadId && workspacePath === targetWorkspacePath, + ) + .map(([thread]) => thread); + const latestActiveBinding = latestOtherBindings.find( + (thread) => thread.activeRunId !== null, + ); + if ( + (targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) && + latestActiveBinding + ) { + return yield* failure( + "workspace_in_use", + `Checkout '${targetWorkspacePath}' is in use by active thread '${latestActiveBinding.id}' (${latestActiveBinding.title}).`, + ); + } + if ( + targetWorkspacePath !== projectWorktreeRoot && + (targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) && + latestOtherBindings.length > 0 + ) { + return yield* failure( + "workspace_shared", + `Worktree '${targetWorkspacePath}' is already bound to thread '${latestOtherBindings[0]!.id}'. Reusing it would make two threads share one mutable checkout.`, + ); + } + if ( + targetWorkspacePath === projectWorktreeRoot && + shouldMutateCheckout && + latestOtherBindings.length > 0 + ) { + return yield* failure( + "workspace_shared", + `The project root is also bound to thread '${latestOtherBindings[0]!.id}'. Switching its branch would make that thread's recorded branch disagree with Git.`, + ); + } + + let checkoutAction: WorktreeMcpCheckoutResult["checkoutAction"] = + targetWorkspacePath === currentWorkspacePath ? "unchanged" : "reused"; + let resolvedBranch: string | null = targetBefore.refName; + const targetBeforeCommit = shouldMutateCheckout + ? yield* restore( + gitWorkflow + .resolveCommit({ cwd: targetWorkspacePath, revision: "HEAD" }) + .pipe(asOperationFailed("Unable to record the checkout's current commit")), + ) + : null; + const requestedTransitionCommit = + shouldMutateCheckout && requestedBranch !== undefined + ? createBranch + ? targetBeforeCommit + : yield* restore( + gitWorkflow + .resolveCommit({ cwd: targetWorkspacePath, revision: requestedBranch }) + .pipe(asOperationFailed(`Unable to resolve ref '${requestedBranch}'`)), + ) + : null; + let ownedCheckoutState: { + readonly refName: string | null; + readonly hasWorkingTreeChanges: boolean; + readonly commitSha: string; + } | null = null; + + const captureCheckoutState = Effect.fn("WorktreeMcpService.captureCheckoutState")( + function* () { + const status = yield* readWorkspaceStatus(targetWorkspacePath); + const commit = yield* gitWorkflow + .resolveCommit({ cwd: targetWorkspacePath, revision: "HEAD" }) + .pipe(asOperationFailed("Unable to identify the checkout's current commit")); + return { + refName: status.refName, + hasWorkingTreeChanges: status.hasWorkingTreeChanges, + commitSha: commit.commitSha, + }; + }, + ); + + const rollbackOwnedCheckout = Effect.fn("WorktreeMcpService.rollbackOwnedCheckout")( + function* () { + if (!shouldMutateCheckout) { + return "not_needed" as const; + } + if (ownedCheckoutState === null || targetBeforeCommit === null) { + return "not_possible" as const; + } + const [latestStateExit, latestBindings, callerProjectionExit] = yield* Effect.all( + [ + Effect.exit(captureCheckoutState()), + loadActiveWorkspaceBindings(inventory.repositoryCommonDir), + Effect.exit(threadManagement.getThreadProjection(scope.threadId)), + ], + { concurrency: 3 }, + ); + const anotherOwner = latestBindings.some( + ([thread, workspacePath]) => + thread.id !== scope.threadId && workspacePath === targetWorkspacePath, + ); + const callerStillInitial = + Exit.isSuccess(callerProjectionExit) && + callerProjectionExit.value.thread.branch === projection.thread.branch && + callerProjectionExit.value.thread.worktreePath === projection.thread.worktreePath && + callerProjectionExit.value.thread.archivedAt === projection.thread.archivedAt && + callerProjectionExit.value.thread.deletedAt === projection.thread.deletedAt; + if ( + Exit.isFailure(latestStateExit) || + latestStateExit.value.refName !== ownedCheckoutState.refName || + latestStateExit.value.hasWorkingTreeChanges || + latestStateExit.value.hasWorkingTreeChanges !== + ownedCheckoutState.hasWorkingTreeChanges || + latestStateExit.value.commitSha !== ownedCheckoutState.commitSha || + anotherOwner || + !callerStillInitial + ) { + return "not_possible" as const; + } + const checkoutChanged = + latestStateExit.value.refName !== targetBefore.refName || + latestStateExit.value.commitSha !== targetBeforeCommit.commitSha; + if (checkoutChanged && targetBefore.refName === null) { + return "not_possible" as const; + } + const rollbackExit = yield* Effect.exit( + checkoutChanged + ? gitWorkflow.switchRef({ + cwd: targetWorkspacePath, + refName: targetBefore.refName!, + }) + : Effect.void, + ); + return Exit.isSuccess(rollbackExit) ? ("rolled_back" as const) : ("failed" as const); + }, + ); + + if (shouldMutateCheckout && requestedBranch !== undefined) { + const [mutationProjection, mutationBindings, mutationTargetBefore] = yield* restore( + Effect.all( + [ + loadThread(scope), + loadActiveWorkspaceBindings(inventory.repositoryCommonDir), + readWorkspaceStatus(targetWorkspacePath), + ], + { concurrency: 3 }, + ), + ); + if ( + mutationProjection.thread.branch !== projection.thread.branch || + mutationProjection.thread.worktreePath !== projection.thread.worktreePath || + mutationProjection.thread.archivedAt !== projection.thread.archivedAt || + mutationProjection.thread.deletedAt !== projection.thread.deletedAt || + mutationTargetBefore.refName !== targetBefore.refName || + mutationTargetBefore.hasWorkingTreeChanges !== targetBefore.hasWorkingTreeChanges + ) { + return yield* failure( + "checkout_in_progress", + `Thread or checkout state changed immediately before Git mutation. Retry from the current workspace state.`, + ); + } + const mutationOtherBindings = mutationBindings + .filter( + ([thread, workspacePath]) => + thread.id !== scope.threadId && workspacePath === targetWorkspacePath, + ) + .map(([thread]) => thread); + const mutationActiveBinding = mutationOtherBindings.find( + (thread) => thread.activeRunId !== null, + ); + if (mutationActiveBinding !== undefined) { + return yield* failure( + "workspace_in_use", + `Checkout '${targetWorkspacePath}' became active for thread '${mutationActiveBinding.id}' (${mutationActiveBinding.title}) before Git mutation.`, + ); + } + if (mutationOtherBindings.length > 0) { + return yield* failure( + "workspace_shared", + `Checkout '${targetWorkspacePath}' became bound to thread '${mutationOtherBindings[0]!.id}' before Git mutation.`, + ); + } + if (createBranch) { + yield* gitWorkflow + .createRef({ + cwd: targetWorkspacePath, + refName: requestedBranch, + switchRef: false, + }) + .pipe(asOperationFailed(`Unable to create branch '${requestedBranch}'`)); + } + const switchExit = yield* Effect.exit( + gitWorkflow.switchRef({ cwd: targetWorkspacePath, refName: requestedBranch }), + ); + if (Exit.isFailure(switchExit)) { + const afterFailedSwitchExit = yield* Effect.exit(captureCheckoutState()); + if (Exit.isSuccess(afterFailedSwitchExit)) { + const observed = afterFailedSwitchExit.value; + const unchanged = + observed.refName === targetBefore.refName && + observed.commitSha === targetBeforeCommit?.commitSha; + const requestedState = + !observed.hasWorkingTreeChanges && + requestedTransitionCommit !== null && + observed.commitSha === requestedTransitionCommit.commitSha && + (selectedRef?.isRemote === true + ? observed.refName === null + : observed.refName === requestedBranch); + if (unchanged || requestedState) { + ownedCheckoutState = observed; + } + } + const rollback = yield* rollbackOwnedCheckout(); + if (rollback === "not_possible" || rollback === "failed") { + return yield* failure( + "partial_failure", + `Branch checkout failed and rollback was ${rollback === "failed" ? "unsuccessful" : "unsafe"}: ${errorMessage(Cause.squash(switchExit.cause))}`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch: Exit.isSuccess(afterFailedSwitchExit) + ? afterFailedSwitchExit.value.refName + : null, + rollback: rollback === "failed" ? "failed" : "not_possible", + }, + ); + } + return yield* failure( + "operation_failed", + `Unable to check out '${requestedBranch}': ${errorMessage(Cause.squash(switchExit.cause))}`, + ); + } + resolvedBranch = switchExit.value.refName; + if (resolvedBranch === null && selectedRef?.isRemote !== true) { + return yield* failure( + "partial_failure", + `Git reported a detached checkout after selecting '${requestedBranch}'. The durable thread binding was not changed.`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch: null, + rollback: "not_possible", + }, + ); + } + checkoutAction = createBranch ? "created" : "switched"; + } + + const actualExit = yield* Effect.exit(readWorkspaceStatus(targetWorkspacePath)); + if (Exit.isFailure(actualExit)) { + const detail = errorMessage(Cause.squash(actualExit.cause)); + if (checkoutAction === "switched" || checkoutAction === "created") { + return yield* failure( + "partial_failure", + `Git checkout completed but its resulting state could not be verified, so rollback was not attempted: ${detail}`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch: null, + rollback: "not_possible", + }, + ); + } + return yield* failure( + "operation_failed", + `Unable to verify the selected checkout '${targetWorkspacePath}': ${detail}`, + ); + } + const actual = actualExit.value; + if ( + (checkoutAction === "switched" || checkoutAction === "created") && + actual.refName !== resolvedBranch + ) { + return yield* failure( + "partial_failure", + `Git resolved '${requestedBranch}' to '${resolvedBranch}' but the checkout now reports '${actual.refName ?? "detached HEAD"}'. The durable thread binding was not changed.`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch: actual.refName, + rollback: "not_possible", + }, + ); + } + if (checkoutAction === "switched" || checkoutAction === "created") { + const actualCommitExit = yield* Effect.exit( + gitWorkflow + .resolveCommit({ cwd: targetWorkspacePath, revision: "HEAD" }) + .pipe(asOperationFailed("Unable to identify the selected checkout commit")), + ); + if (Exit.isFailure(actualCommitExit)) { + return yield* failure( + "partial_failure", + "Git checkout completed but its commit identity could not be verified, so the durable binding was not changed and rollback was not attempted.", + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch: actual.refName, + rollback: "not_possible", + }, + ); + } + if ( + requestedTransitionCommit !== null && + actualCommitExit.value.commitSha !== requestedTransitionCommit.commitSha + ) { + return yield* failure( + "partial_failure", + `Git selected '${requestedBranch}', but HEAD no longer matches the resolved target commit. The durable binding was not changed.`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch: actual.refName, + rollback: "not_possible", + }, + ); + } + ownedCheckoutState = { + refName: actual.refName, + hasWorkingTreeChanges: actual.hasWorkingTreeChanges, + commitSha: actualCommitExit.value.commitSha, + }; + } + const nextBranch = actual.refName; + const workspaceChanged = targetWorkspacePath !== currentWorkspacePath; + const nextWorktreePath = workspaceChanged + ? targetWorkspacePath === projectWorktreeRoot + ? null + : targetWorkspacePath + : projection.thread.worktreePath; + const bindingChanged = + nextBranch !== projection.thread.branch || + nextWorktreePath !== projection.thread.worktreePath; + + if (bindingChanged) { + const preCommitProjectionExit = yield* Effect.exit(loadThread(scope)); + const preCommitProjection = Exit.isSuccess(preCommitProjectionExit) + ? preCommitProjectionExit.value + : null; + const metadataChanged = + preCommitProjection === null || + preCommitProjection.thread.branch !== projection.thread.branch || + preCommitProjection.thread.worktreePath !== projection.thread.worktreePath || + preCommitProjection.thread.archivedAt !== projection.thread.archivedAt; + if (metadataChanged) { + const rollback = yield* rollbackOwnedCheckout(); + if (rollback === "failed" || rollback === "not_possible") { + return yield* failure( + "partial_failure", + `The thread changed or disappeared after Git checkout, so its durable binding was not updated and Git rollback was ${rollback === "failed" ? "unsuccessful" : "unsafe"}.`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch: yield* readWorkspaceBranchOrNull(targetWorkspacePath), + rollback: rollback === "failed" ? "failed" : "not_possible", + }, + ); + } + return yield* failure( + "checkout_in_progress", + `Thread '${scope.threadId}' changed or disappeared before the workspace binding committed. Git state was left unchanged.`, + ); + } + const dispatchExit = yield* Effect.exit( + threadManagement.dispatch({ + type: "thread.metadata.update", + commandId: ids.commandId, + threadId: scope.threadId, + branch: nextBranch, + worktreePath: nextWorktreePath, + expectedBranch: projection.thread.branch, + expectedWorktreePath: projection.thread.worktreePath, + expectedArchived: false, + }), + ); + if (Exit.isFailure(dispatchExit)) { + const dispatchDetail = errorMessage(Cause.squash(dispatchExit.cause)); + const bindingAfterDispatchExit = yield* Effect.exit(loadThread(scope)); + if (Exit.isFailure(bindingAfterDispatchExit)) { + return yield* failure( + "partial_failure", + `The durable binding update reported a failure and its outcome could not be verified: ${dispatchDetail}`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch: actual.refName, + rollback: "not_possible", + }, + ); + } + const bindingAfterDispatch = bindingAfterDispatchExit.value.thread; + const bindingCommitted = + bindingAfterDispatch.branch === nextBranch && + bindingAfterDispatch.worktreePath === nextWorktreePath; + if (bindingCommitted) { + yield* Effect.logWarning( + "workspace binding dispatch reported failure after the binding committed", + { + threadId: scope.threadId, + workspacePath: targetWorkspacePath, + detail: dispatchDetail, + }, + ); + } else { + const bindingStillInitial = + bindingAfterDispatch.branch === projection.thread.branch && + bindingAfterDispatch.worktreePath === projection.thread.worktreePath; + const rollback = bindingStillInitial + ? yield* rollbackOwnedCheckout() + : ("not_possible" as const); + if (rollback === "failed" || rollback === "not_possible") { + return yield* failure( + "partial_failure", + `Git checkout completed but the durable binding failed, and rollback was ${rollback === "failed" ? "unsuccessful" : "unsafe"}: ${dispatchDetail}`, + { + workspacePath: targetWorkspacePath, + recordedBranch: bindingAfterDispatch.branch, + actualBranch: yield* readWorkspaceBranchOrNull(targetWorkspacePath), + rollback: rollback === "failed" ? "failed" : "not_possible", + }, + ); + } + return yield* failure( + "operation_failed", + `Unable to update the durable thread workspace: ${dispatchDetail}`, + ); + } + } + } + + const continuation = workspaceChanged + ? yield* queueContinuation({ + scope, + projection, + prompt: input.continuationPrompt, + commandId: ids.continuationCommandId, + messageId: ids.continuationMessageId, + workspacePath: targetWorkspacePath, + }) + : ({ status: "skipped" } as const); + const previous = { + workspacePath: currentWorkspacePath ?? recordedWorkspacePath, + recordedBranch: projection.thread.branch, + recordedWorktreePath: projection.thread.worktreePath, + actualBranch: previousActual.refName, + }; + const current = { + workspacePath: targetWorkspacePath, + recordedBranch: nextBranch, + recordedWorktreePath: nextWorktreePath, + actualBranch: actual.refName, + }; + return { + previous, + current, + checkoutAction, + workspaceChanged, + branchChanged: previousActual.refName !== actual.refName, + continuation, + setupScript: { status: "skipped" }, + callerTurnEnds: workspaceChanged, + note: workspaceChanged + ? continuation.status === "scheduled" + ? "Checkout and durable thread binding completed. The workspace change detaches this provider session; the queued continuation starts the next turn in the selected checkout." + : "Checkout and durable thread binding completed. The workspace change detaches this provider session, so this turn ends after the call. Send another message to continue in the selected checkout." + : "Checkout and durable thread binding completed without changing the provider session workspace.", + } satisfies WorktreeMcpCheckoutResult; + }).pipe( + Effect.ensuring( + Effect.sync(() => workspaceTransitionsInFlight.delete(workspaceGuardKey)), + ), + ); + }), + ); + }); + + const checkout: WorktreeMcpService["Service"]["checkout"] = Effect.fn( + "WorktreeMcpService.checkout", + )(function* (scope, input) { + yield* requireCapability(scope); + return yield* Effect.uninterruptibleMask((restore) => + Effect.suspend(() => { + if (workspaceTransitionsInFlight.has(scope.threadId)) { + return Effect.fail( + failure( + "checkout_in_progress", + `A workspace transition is already in progress for thread '${scope.threadId}'.`, + ), + ); + } + workspaceTransitionsInFlight.add(scope.threadId); + return restore(performCheckout(scope, input)).pipe( + Effect.ensuring(Effect.sync(() => workspaceTransitionsInFlight.delete(scope.threadId))), + ); + }), + ); + }); + + return WorktreeMcpService.of({ handoff, status, listWorktrees, checkout }); }); export const layer: Layer.Layer< diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index 8d2bc64988dd..a448534d3930 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -23,6 +23,12 @@ const handlers = { const service = yield* WorktreeMcpService; return yield* service.listWorktrees(scope, input); }), + t3_thread_checkout: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* WorktreeMcpService; + return yield* service.checkout(scope, input); + }), } satisfies Parameters[0]; export const WorktreeToolkitHandlersLive = WorktreeToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index fd10d3f589a0..396a42b8f34a 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -111,6 +111,7 @@ it.effect("production mcp layer lists worktree tools over http", () => expect(toolNames).toContain("t3_worktree_handoff"); expect(toolNames).toContain("t3_worktree_status"); expect(toolNames).toContain("t3_worktree_list"); + expect(toolNames).toContain("t3_thread_checkout"); // The worktree registration merges alongside the other toolkits rather // than replacing them. expect(toolNames).toContain("preview_status"); @@ -129,6 +130,10 @@ it.effect("production mcp layer lists worktree tools over http", () => const list = tools.find((tool) => tool.name === "t3_worktree_list"); expect(list?.annotations?.readOnlyHint).toBe(true); expect(list?.annotations?.destructiveHint).toBe(false); + const checkout = tools.find((tool) => tool.name === "t3_thread_checkout"); + expect(checkout?.annotations?.readOnlyHint).toBe(false); + expect(checkout?.annotations?.destructiveHint).toBe(true); + expect(checkout?.annotations?.openWorldHint).toBe(true); // MCP requires every tool input schema to be a top-level object schema. // A non-object schema (e.g. the anyOf produced by an empty diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index 092c88ab6c6b..6b0f91e1b3d4 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -1,5 +1,7 @@ import { WorktreeMcpFailure, + WorktreeMcpCheckoutInput, + WorktreeMcpCheckoutResult, WorktreeMcpHandoffInput, WorktreeMcpHandoffResult, WorktreeMcpListInput, @@ -15,7 +17,7 @@ const dependencies = [McpInvocationContext.McpInvocationContext, WorktreeMcpServ export const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", { description: - "Move this agent thread into a new git worktree. Creates the worktree branch (optionally from origin), re-points the thread at the worktree, and by default runs the project's setup script there. Changing the workspace detaches the live provider session, so the current turn ends shortly after the handoff is recorded; call this as the last action of the turn. To keep working after the handoff, pass continuationPrompt with the remaining work: it is queued as the thread's next message and starts a new turn inside the worktree with the conversation preserved. Without it the thread stays idle until the next message. The worktree is not removed automatically when the thread is deleted. Fails if the thread is already attached to a worktree.", + "Move this agent thread into a new git worktree. Creates the worktree branch (optionally from origin), re-points the thread at the worktree, and by default runs the project's setup script there. Changing the workspace detaches the live provider session, so the current turn ends shortly after the handoff is recorded; call this as the last action of the turn. To keep working after the handoff, pass continuationPrompt with the remaining work: it is queued as the thread's next message and starts a new turn inside the worktree with the conversation preserved. Without it the thread stays idle until the next message. The source worktree and the new worktree are not removed automatically.", parameters: WorktreeMcpHandoffInput, success: WorktreeMcpHandoffResult, failure: WorktreeMcpFailure, @@ -61,8 +63,24 @@ export const WorktreeListTool = Tool.make("t3_worktree_list", { .annotate(Tool.Idempotent, true) .annotate(Tool.OpenWorld, false); +export const ThreadCheckoutTool = Tool.make("t3_thread_checkout", { + description: + "Change this existing T3 thread to a branch, project root, listed worktree, or a newly created worktree. This performs the git checkout when needed and updates the durable thread binding only after verifying the actual ref. It never stashes or discards files, deletes existing worktrees, or interrupts other threads. A workspace change detaches the calling provider session; pass continuationPrompt to queue the next turn in the selected checkout. Use t3_worktree_list and t3_worktree_status first.", + parameters: WorktreeMcpCheckoutInput, + success: WorktreeMcpCheckoutResult, + failure: WorktreeMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Check out a branch or worktree for this thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, true); + export const WorktreeToolkit = Toolkit.make( WorktreeHandoffTool, WorktreeStatusTool, WorktreeListTool, + ThreadCheckoutTool, ); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 66378584fd21..e0c4ad0924e7 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1427,6 +1427,28 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} worktree changed before the metadata update could be applied.`, }); } + if ( + command.type === "thread.metadata.update" && + command.expectedBranch !== undefined && + command.expectedBranch !== thread.branch + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} branch changed before the metadata update could be applied.`, + }); + } + if ( + command.type === "thread.metadata.update" && + command.expectedArchived !== undefined && + command.expectedArchived !== (thread.archivedAt !== null) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} archive state changed before the metadata update could be applied.`, + }); + } if (command.type === "thread.archive" && thread.archivedAt !== null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts index 9d9a5f6a24be..c578a86085ff 100644 --- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts @@ -15,6 +15,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -84,6 +85,14 @@ interface RestartAdapterState { function makeRestartAdapter( state: Ref.Ref, sessionCapabilities: OrchestrationV2ProviderCapabilities = pooledCapabilities, + observeTurnStart?: (input: { + readonly model: string; + readonly cwd: string | null; + }) => Effect.Effect, + interruptGate?: { + readonly requested: Queue.Queue; + readonly release: Deferred.Deferred; + }, ): ProviderAdapterV2Shape { return { instanceId: providerInstanceId, @@ -224,6 +233,12 @@ function makeRestartAdapter( }, ], })); + if (observeTurnStart !== undefined) { + yield* observeTurnStart({ + model: input.modelSelection.model, + cwd: input.runtimePolicy.cwd, + }); + } const active = { input, providerTurnId: ProviderTurnId.make(`provider-turn:${input.attemptId}`), @@ -259,6 +274,10 @@ function makeRestartAdapter( Effect.gen(function* () { const active = (yield* Ref.get(state)).activeTurn; if (active !== null) { + if (interruptGate !== undefined) { + yield* Queue.offer(interruptGate.requested, undefined); + yield* Deferred.await(interruptGate.release); + } const updatedAt = yield* DateTime.now; yield* Queue.offer(events, { type: "provider_thread.updated", @@ -529,6 +548,151 @@ it.live("restarts selection as a new attempt and retries after old-session clean ), ); +it.live("queues a workspace continuation before detaching and starts it in the new cwd", () => + Effect.scoped( + Effect.gen(function* () { + const initialCwd = yield* checkpointWorkspace("workspace-continuation-initial"); + const targetCwd = yield* checkpointWorkspace("workspace-continuation-target"); + const threadId = ThreadId.make("thread:workspace-continuation"); + const projectId = ProjectId.make("project:workspace-continuation"); + const state = yield* Ref.make({ + activeTurn: null, + opened: [], + started: [], + closedSessionCount: 0, + failedReplacementOpen: false, + }); + const initialTurnStarted = yield* Deferred.make<{ + readonly model: string; + readonly cwd: string | null; + }>(); + const continuationTurnStarted = yield* Deferred.make<{ + readonly model: string; + readonly cwd: string | null; + }>(); + const interruptRequested = yield* Queue.unbounded(); + const interruptRelease = yield* Deferred.make(); + const registry = makeSingleProviderAdapterRegistryLayer( + makeRestartAdapter( + state, + pooledCapabilities, + (started) => + Deferred.succeed( + started.cwd === initialCwd ? initialTurnStarted : continuationTurnStarted, + started, + ).pipe(Effect.asVoid), + { + requested: interruptRequested, + release: interruptRelease, + }, + ), + ); + const orchestratorLayer = makeOrchestratorV2ReplayLayerWithRegistry( + { name: "workspace-continuation" }, + registry, + ); + + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:workspace-continuation:create"), + threadId, + projectId, + title: "Workspace continuation", + modelSelection: initialSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: initialCwd, + }); + const providerTurnProjected = yield* Deferred.make(); + const afterCreate = yield* orchestrator.getThreadEventSequence(threadId); + yield* orchestrator.streamStoredEventsFrom({ threadId, afterSequence: afterCreate }).pipe( + Stream.runForEach((stored) => + stored.event.type === "provider-turn.updated" && + stored.event.payload.status === "running" + ? Deferred.succeed(providerTurnProjected, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.forkScoped, + ); + const initialSendReceipt = yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("command:workspace-continuation:first"), + threadId, + messageId: MessageId.make("message:workspace-continuation:first"), + text: "Start in the original checkout.", + attachments: [], + modelSelection: initialSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + assert.isTrue(initialSendReceipt.storedEvents.length > 0); + assert.deepEqual(yield* Deferred.await(initialTurnStarted), { + model: initialSelection.model, + cwd: initialCwd, + }); + yield* Deferred.await(providerTurnProjected); + + const bindingReceipt = yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("command:workspace-continuation:binding"), + threadId, + branch: "feature/workspace-continuation", + worktreePath: targetCwd, + expectedBranch: "main", + expectedWorktreePath: initialCwd, + }); + assert.isTrue( + bindingReceipt.storedEvents.some( + (stored) => stored.event.type === "provider-session.detached", + ), + ); + + const continuationReceipt = yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("command:workspace-continuation:queued"), + threadId, + messageId: MessageId.make("message:workspace-continuation:queued"), + text: "Continue after the workspace handoff.", + attachments: [], + modelSelection: initialSelection, + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "mcp", + }); + assert.isTrue(continuationReceipt.storedEvents.length > 0); + const queued = yield* orchestrator.getThreadProjection(threadId); + const continuationRun = queued.runs.find( + (run) => run.userMessageId === MessageId.make("message:workspace-continuation:queued"), + ); + assert.isDefined(continuationRun); + assert.equal(continuationRun.status, "queued"); + yield* Queue.take(interruptRequested); + yield* Deferred.succeed(interruptRelease, undefined); + + assert.deepEqual(yield* Deferred.await(continuationTurnStarted), { + model: initialSelection.model, + cwd: targetCwd, + }); + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.equal(projection.thread.branch, "feature/workspace-continuation"); + assert.equal(projection.thread.worktreePath, targetCwd); + assert.equal( + projection.messages.find( + (message) => message.id === MessageId.make("message:workspace-continuation:queued"), + )?.text, + "Continue after the workspace handoff.", + ); + }).pipe(Effect.provide(orchestratorLayer)); + }), + ), +); + it.live("detaches the old provider session after an active provider handoff", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 009b8dded789..726d77a4042e 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -616,6 +616,17 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { }) .pipe(Effect.flip); assert.instanceOf(staleWorkspaceUpdate, OrchestratorDispatchError); + const staleBranchUpdate = yield* orchestrator + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("runtime-layer-lifecycle-stale-branch"), + threadId, + branch: "feature/stale", + expectedBranch: null, + expectedWorktreePath: "/tmp/t3-v2-worktree", + }) + .pipe(Effect.flip); + assert.instanceOf(staleBranchUpdate, OrchestratorDispatchError); const projectionAfterStaleWorkspaceUpdate = yield* orchestrator.getThreadProjection(threadId); assert.equal(projectionAfterStaleWorkspaceUpdate.thread.branch, "feature/v2"); assert.equal(projectionAfterStaleWorkspaceUpdate.thread.worktreePath, "/tmp/t3-v2-worktree"); @@ -701,6 +712,27 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { }, ); + const workspaceUpdateAfterArchive = yield* orchestrator + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("runtime-layer-lifecycle-workspace-after-archive"), + threadId, + branch: "feature/should-not-bind", + worktreePath: "/tmp/should-not-bind", + expectedBranch: "feature/v2", + expectedWorktreePath: "/tmp/t3-v2-worktree", + expectedArchived: false, + }) + .pipe(Effect.flip); + assert.instanceOf(workspaceUpdateAfterArchive, OrchestratorDispatchError); + const projectionAfterArchivedWorkspaceUpdate = + yield* orchestrator.getThreadProjection(threadId); + assert.equal(projectionAfterArchivedWorkspaceUpdate.thread.branch, "feature/v2"); + assert.equal( + projectionAfterArchivedWorkspaceUpdate.thread.worktreePath, + "/tmp/t3-v2-worktree", + ); + const remove = yield* orchestrator.dispatch({ type: "thread.delete", commandId: CommandId.make("runtime-layer-lifecycle-delete"), diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 3a7a1f1fa27b..48980d14a7f7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1349,6 +1349,33 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(result.branch, current); }), ); + + it.effect( + "resolves an explicit remote ref instead of a same-named untracked local branch", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "origin", "HEAD:refs/heads/feature"]); + const remoteFeatureCommit = yield* git(cwd, ["rev-parse", "origin/feature"]); + yield* git(cwd, ["checkout", "-b", "feature"]); + yield* writeTextFile(cwd, "local-only.txt", "local\n"); + yield* git(cwd, ["add", "local-only.txt"]); + yield* git(cwd, ["commit", "-m", "local feature"]); + const localFeatureCommit = yield* git(cwd, ["rev-parse", "feature"]); + yield* git(cwd, ["checkout", initialBranch]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const switched = yield* driver.switchRef({ cwd, refName: "origin/feature" }); + + assert.equal(switched.refName, null); + assert.notEqual(localFeatureCommit, remoteFeatureCommit); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), remoteFeatureCommit); + }), + ); }); describe("worktree operations", () => { diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 9e39bd5579e4..31919c3ac4c6 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -44,10 +44,11 @@ Before `ProviderSessionManager` opens a new V2 provider session, it asks - the concrete provider instance; and - the provider session. -The credential grants `preview` and `orchestration` capabilities. Credentials -expire after a maximum lifetime, expire when idle, and are revoked when the -provider session is released. The raw token is not persisted in orchestration -state. +The credential grants `orchestration` and `worktree` capabilities. It grants `preview` only when +the provider session reports `browserToolsAvailable`; disabling agent browser access removes that +capability. Credentials expire after the liveness window without MCP traffic or provider-turn +activity, and provider-session release revokes them eagerly. The raw token is not persisted in +orchestration state. The MCP HTTP server resolves the bearer token and supplies the resulting `McpInvocationScope` to tool handlers. Orchestration handlers additionally @@ -328,6 +329,37 @@ complete. A missing or unreadable checkout remains in the page with an availabil instead of failing discovery of the other worktrees. The tool does not create, remove, prune, or repair worktrees. +### `t3_thread_checkout` + +Changes the calling existing thread to one of four typed targets: + +- a branch, optionally creating it in the current checkout or project root; +- an existing worktree returned by `t3_worktree_list`; +- the project root; or +- a new worktree created through the existing Git worktree service. + +The service verifies project scope and the actual Git ref before it writes the durable +`thread.metadata.update` command. The command includes the expected old branch and worktree path, +so a concurrent metadata change is rejected. A failed metadata write rolls a branch switch back +when possible and reports a typed partial failure if rollback also fails. + +The service never stashes or discards files. Branch switches reject dirty checkouts. Reusing a +dedicated worktree bound to another thread is rejected, as is mutating a shared project root while +another live thread is bound there. Moving to a shared project root without switching its branch is +allowed unless another root-bound thread has an active run. + +Changing the worktree path detaches the calling provider session and ends the current turn. If +`continuationPrompt` is present, the service writes the new binding and then durably queues a +replacement turn before the detach can interrupt the MCP call. The replacement provider session +derives its working directory from the new thread projection. Without `continuationPrompt`, the +thread stays idle in the selected checkout until it receives another message. Same-path branch +changes do not detach the session. + +`t3_worktree_handoff` remains the direct convenience tool for creating a new worktree and uses the +same binding, continuation, race, and rollback rules. Neither tool removes an existing source or +target worktree. A failed new-worktree transition attempts to remove only the checkout it just +created when the binding did not commit. + ## Delegated Task Lifecycle The MCP server is a command ingress into V2. It does not call provider adapters @@ -371,8 +403,8 @@ falls back to a terminal-status message when no assistant text exists. - General thread management is limited to the calling thread's project. Send additionally enforces the same runtime and interaction privilege ceiling as child creation. -- Workspace discovery is limited to the calling thread's current project. It does not accept an - environment or cross-project target. +- Workspace reads and checkout are limited to the calling thread and its current project. They do + not accept an environment or cross-project target. - Provider instances must be enabled, installed, available, authenticated, and backed by a V2 adapter. - A requested model must be advertised by the selected provider when the diff --git a/docs/user/source-control.md b/docs/user/source-control.md index b8e55e2f4a00..a1d6fd44678e 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -65,16 +65,28 @@ The **Source Control settings** page shows you exactly what's connected: Run a quick **Rescan** after setting up a new machine or changing credentials. -### Let an agent inspect its checkout +### Let an agent change its checkout Agents running through T3 Code can inspect the checkout recorded on their thread and compare it with Git's actual branch. They can also list the project root and Git-registered worktrees, including detached checkouts, dirty state, and the durable branch and worktree path recorded for other threads using each checkout. Git resolves symlinked checkout paths through the repository's real common-directory and physical-worktree identity, including when a project opens in a nested -folder. Worktree results are paginated, and missing or unreadable checkouts are -reported without hiding the rest. These read paths apply only to the calling thread's current -project and do not create, remove, prune, or revive worktrees. +folder. Worktree results are paginated, and missing or unreadable checkouts are reported without +hiding the rest. + +An agent can move its current thread to an existing branch, return to the project root, reuse an +unclaimed worktree, or create a new worktree. T3 Code performs the Git operation before it updates +the thread's saved branch and worktree path. It refuses to switch a dirty checkout and will not +silently stash or discard files. It also refuses to take over a worktree owned by another thread or +switch a shared root while another thread is bound there. + +Moving between workspace paths ends the current agent turn in the selected checkout. The agent can +queue a continuation as part of that move so a replacement turn starts there without needing the +browser to stay open. Without a continuation, the thread remains idle until its next message. These +controls apply only to the calling thread and its current project. They do not remove, prune, or +revive existing worktrees. If creating a new worktree fails before its durable thread binding +commits, T3 Code attempts to remove only that newly created checkout as rollback. ## Getting Started diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index d14f1dcebea3..3aa0d4ce5ad6 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2090,7 +2090,10 @@ export const OrchestrationV2Command = Schema.Union([ regenerateTitle: Schema.optional(Schema.Boolean), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), expectedWorktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + /** Optional lifecycle CAS used by workspace moves; absent preserves legacy behavior. */ + expectedArchived: Schema.optional(Schema.Boolean), /** Link (object) or unlink (null) a pull request (#8160); absent leaves it unchanged. */ linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), }), diff --git a/packages/contracts/src/worktreeMcp.ts b/packages/contracts/src/worktreeMcp.ts index df0528e89dc1..c0ae9f720617 100644 --- a/packages/contracts/src/worktreeMcp.ts +++ b/packages/contracts/src/worktreeMcp.ts @@ -2,6 +2,13 @@ import * as Schema from "effect/Schema"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +const AbsolutePath = TrimmedNonEmptyString.check( + // Absolute POSIX (/...), Windows drive (C:\\ or C:/), or UNC (\\\\host). + Schema.isPattern(/^(?:[A-Za-z]:[\\/]|[\\/])/), +); + +const ContinuationPrompt = TrimmedNonEmptyString.check(Schema.isMaxLength(120_000)); + /** * Input for the `t3_worktree_handoff` MCP tool. * @@ -27,10 +34,7 @@ export const WorktreeMcpHandoffInput = Schema.Struct({ }), ), path: Schema.optional( - TrimmedNonEmptyString.check( - // Absolute POSIX (/...), Windows drive (C:\ or C:/), or UNC (\\host). - Schema.isPattern(/^(?:[A-Za-z]:[\\/]|[\\/])/), - ).annotate({ + AbsolutePath.annotate({ description: "Absolute filesystem path for the new worktree. Relative paths are rejected. Defaults to the server-managed worktrees directory.", }), @@ -42,7 +46,7 @@ export const WorktreeMcpHandoffInput = Schema.Struct({ }), ), continuationPrompt: Schema.optional( - TrimmedNonEmptyString.check(Schema.isMaxLength(120_000)).annotate({ + ContinuationPrompt.annotate({ description: "Message queued as the thread's next turn after the handoff. The handoff detaches the current provider session, so pass the remaining work here to automatically resume inside the worktree; omit it to stop after the handoff and wait for the next message.", }), @@ -183,6 +187,78 @@ export const WorktreeMcpListResult = Schema.Struct({ }); export type WorktreeMcpListResult = typeof WorktreeMcpListResult.Type; +const CheckoutBranchTarget = Schema.Struct({ + type: Schema.Literal("branch"), + branch: TrimmedNonEmptyString, + create: Schema.optional(Schema.Boolean), + workspace: Schema.optional(Schema.Literals(["auto", "current", "project_root"])), +}); + +const CheckoutWorktreeTarget = Schema.Struct({ + type: Schema.Literal("worktree"), + path: AbsolutePath, +}); + +const CheckoutProjectRootTarget = Schema.Struct({ + type: Schema.Literal("project_root"), + branch: Schema.optional(TrimmedNonEmptyString), + create: Schema.optional(Schema.Boolean), +}); + +const CheckoutNewWorktreeTarget = Schema.Struct({ + type: Schema.Literal("new_worktree"), + branch: TrimmedNonEmptyString, + baseRef: Schema.optional(TrimmedNonEmptyString), + startFromOrigin: Schema.optional(Schema.Boolean), + path: Schema.optional(AbsolutePath), + runSetupScript: Schema.optional(Schema.Boolean), +}); + +export const WorktreeMcpCheckoutInput = Schema.Struct({ + target: Schema.Union([ + CheckoutBranchTarget, + CheckoutWorktreeTarget, + CheckoutProjectRootTarget, + CheckoutNewWorktreeTarget, + ]), + continuationPrompt: Schema.optional( + ContinuationPrompt.annotate({ + description: + "Message queued as the thread's next turn when checkout changes the bound workspace and detaches the calling provider session.", + }), + ), +}); +export type WorktreeMcpCheckoutInput = typeof WorktreeMcpCheckoutInput.Type; + +export const WorktreeMcpCheckoutSnapshot = Schema.Struct({ + workspacePath: TrimmedNonEmptyString, + recordedBranch: Schema.NullOr(TrimmedNonEmptyString), + recordedWorktreePath: Schema.NullOr(TrimmedNonEmptyString), + actualBranch: Schema.NullOr(TrimmedNonEmptyString), +}); +export type WorktreeMcpCheckoutSnapshot = typeof WorktreeMcpCheckoutSnapshot.Type; + +export const WorktreeMcpCheckoutResult = Schema.Struct({ + previous: WorktreeMcpCheckoutSnapshot, + current: WorktreeMcpCheckoutSnapshot, + checkoutAction: Schema.Literals(["unchanged", "reused", "switched", "created"]), + workspaceChanged: Schema.Boolean, + branchChanged: Schema.Boolean, + continuation: WorktreeMcpContinuationStatus, + setupScript: WorktreeMcpSetupScriptStatus, + callerTurnEnds: Schema.Boolean, + note: Schema.String, +}); +export type WorktreeMcpCheckoutResult = typeof WorktreeMcpCheckoutResult.Type; + +export const WorktreeMcpPartialFailure = Schema.Struct({ + workspacePath: TrimmedNonEmptyString, + recordedBranch: Schema.NullOr(TrimmedNonEmptyString), + actualBranch: Schema.NullOr(TrimmedNonEmptyString), + rollback: Schema.Literals(["failed", "not_possible"]), +}); +export type WorktreeMcpPartialFailure = typeof WorktreeMcpPartialFailure.Type; + export class WorktreeMcpFailure extends Schema.TaggedErrorClass()( "WorktreeMcpFailure", { @@ -192,9 +268,16 @@ export class WorktreeMcpFailure extends Schema.TaggedErrorClass { displayName: "Get thread worktree status", logo: "t3-code", }); - expect(resolveT3McpToolPresentation("mcp__t3-code__t3_worktree_list")).toEqual({ + expect(resolveT3McpToolPresentation("t3-code.t3_worktree_list")).toEqual({ displayName: "List project git worktrees", logo: "t3-code", }); + expect(resolveT3McpToolPresentation("mcp__t3-code__t3_thread_checkout")).toEqual({ + displayName: "Check out a thread branch or worktree", + logo: "t3-code", + }); }); it("pretty prints preview T3 MCP tool names", () => { diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 4e08d57c5167..9d34deaee84e 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -54,6 +54,7 @@ const T3_MCP_TOOLS: Record< t3_worktree_handoff: { displayName: "Hand off thread to a git worktree" }, t3_worktree_status: { displayName: "Get thread worktree status" }, t3_worktree_list: { displayName: "List project git worktrees" }, + t3_thread_checkout: { displayName: "Check out a thread branch or worktree" }, preview_status: { displayName: "Get preview browser status" }, preview_open: { displayName: "Open a page in the preview browser" }, preview_navigate: { displayName: "Navigate the preview browser" },