From 1fef6cdb51019aace3bcf1c30bfc1eb3093b6ac9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 14:49:27 -0700 Subject: [PATCH 01/16] feat(mcp): let agents change thread workspaces --- .../server/src/mcp/WorktreeMcpService.test.ts | 1080 +++++++++++------ apps/server/src/mcp/WorktreeMcpService.ts | 985 ++++++++++++--- .../src/mcp/toolkits/worktree/handlers.ts | 10 +- .../toolkits/worktree/registration.test.ts | 5 + .../server/src/mcp/toolkits/worktree/tools.ts | 24 +- .../src/orchestration-v2/Orchestrator.ts | 11 + .../src/orchestration-v2/runtimeLayer.test.ts | 11 + .../orchestrator-mcp-server.md | 34 +- docs/user/source-control.md | 22 +- packages/contracts/src/orchestrationV2.ts | 1 + packages/contracts/src/worktreeMcp.ts | 95 +- .../shared/src/t3McpToolPresentation.test.ts | 6 +- packages/shared/src/t3McpToolPresentation.ts | 1 + 13 files changed, 1710 insertions(+), 575 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index c116f21f9736..2dafc9a4e76c 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -4,27 +4,22 @@ import { CommandId, EnvironmentId, type OrchestrationV2ThreadProjection, - type OrchestrationV2ThreadShell, type Project, ProjectId, ProviderInstanceId, - RunId, ThreadId, WorktreeMcpHandoffInput, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; 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 Schema from "effect/Schema"; -import * as GitManager from "../git/GitManager.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import { OrchestratorDispatchError, @@ -38,8 +33,6 @@ import { import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; import type * as McpInvocationContext from "./McpInvocationContext.ts"; import { layer as worktreeMcpServiceLayer, WorktreeMcpService } from "./WorktreeMcpService.ts"; @@ -81,51 +74,6 @@ const makeProjection = (overrides: ThreadFixture = {}): OrchestrationV2ThreadPro }, }) as OrchestrationV2ThreadProjection; -const shellFixture = ( - overrides: Partial, -): OrchestrationV2ThreadShell => { - const timestamp = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"); - return { - createdBy: "user", - creationSource: "web", - id: threadId, - projectId, - title: "Worktree test thread", - providerInstanceId: ProviderInstanceId.make("claudeAgent"), - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "test-model", - }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - lineage: { - parentThreadId: null, - relationshipToParent: null, - rootThreadId: threadId, - }, - forkedFrom: null, - activeProviderThreadId: null, - latestRunId: null, - activeRunId: null, - status: "idle", - pendingRuntimeRequest: null, - latestVisibleMessage: null, - latestUserMessageAt: null, - hasActionableProposedPlan: false, - itemCount: 0, - visibleItemCount: 0, - createdAt: timestamp, - updatedAt: timestamp, - archivedAt: null, - settledOverride: null, - settledAt: null, - deletedAt: null, - ...overrides, - }; -}; - const project: Project = { id: projectId, title: "Worktree test project", @@ -146,12 +94,17 @@ 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 threadArchivedOnRecheck?: boolean; readonly threadReadFailsOnRecheck?: boolean; + readonly threadReadFailsOnCall?: number; readonly continuation?: "queued" | "fails" | "dies"; readonly projectMissing?: boolean; readonly projectReadFails?: boolean; @@ -167,46 +120,23 @@ interface HarnessOptions { readonly name: string; readonly current: boolean; readonly isDefault: boolean; + readonly isRemote?: boolean; readonly worktreePath: string | null; }>; - readonly worktrees?: ReadonlyArray<{ - readonly path: string; - readonly refName: string | null; - }>; - readonly worktreeInventories?: Readonly< - Record< - string, - { - readonly repositoryCommonDir: string; - readonly currentWorktreeRoot: string | null; - readonly worktrees: ReadonlyArray<{ - readonly path: string; - readonly refName: string | null; - }>; - } - > - >; - readonly projectWorktreeRoot?: string; - readonly projectWorkspaceRoot?: string; - readonly useRealNonRepositoryWorkflow?: boolean; - readonly workspaceStatuses?: Readonly< - Record - >; - readonly worktreeInventoryFailsFor?: ReadonlySet; + readonly workspaceStatuses?: Readonly>; readonly localStatusFailsOnCall?: 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 archivedProjectThread?: { - readonly id: ThreadId; - readonly title: string; - readonly branch: string | null; - readonly worktreePath: string | null; - }; + readonly switchRefFails?: boolean; + readonly switchRefFailsAfterMutation?: boolean; + readonly switchRefRollbackFails?: boolean; + readonly createRefFails?: boolean; } const makeHarness = (options: HarnessOptions = {}) => { @@ -241,6 +171,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({ @@ -265,6 +203,13 @@ const makeHarness = (options: HarnessOptions = {}) => { ) { return Effect.succeed(makeProjection({ ...thread, archivedAt: "2026-01-02T00:00:00.000Z" })); } + if ( + options.threadAfterFailedDispatch !== undefined && + dispatch.mock.calls.length > 0 && + thread !== null + ) { + return Effect.succeed(makeProjection({ ...thread, ...options.threadAfterFailedDispatch })); + } return id === threadId && thread !== null ? Effect.succeed(makeProjection(thread)) : Effect.fail(new OrchestratorProjectionError({ threadId: id })); @@ -283,71 +228,40 @@ const makeHarness = (options: HarnessOptions = {}) => { return Effect.succeed({ delivery: "queued" } as ThreadManagementSendResult); } }); - const configuredProject = { - ...project, - workspaceRoot: options.projectWorkspaceRoot ?? project.workspaceRoot, - }; const getById = vi.fn((id: ProjectId) => options.projectReadFails ? (Effect.fail("simulated project read failure") as never) : Effect.succeed( id === projectId && options.projectMissing !== true - ? Option.some(configuredProject) + ? Option.some(project) : Option.none(), ), ); - const projectThreadShells = ( - options.projectThreads ?? [ - { - id: threadId, - title: "Worktree test thread", - branch: thread?.branch ?? null, - worktreePath: thread?.worktreePath ?? null, - }, - ] - ).map((item) => - shellFixture({ - id: item.id, - projectId, - title: item.title, - branch: item.branch, - worktreePath: item.worktreePath, - status: item.active === true ? "running" : "idle", - activeRunId: item.active === true ? RunId.make("run-active") : null, - lineage: { - parentThreadId: null, - relationshipToParent: null, - rootThreadId: item.id, - }, - }), - ); - const archivedThreadShells = - options.archivedProjectThread === undefined - ? [] - : [ - shellFixture({ - id: options.archivedProjectThread.id, + const listProjectThreads = vi.fn(() => + Effect.succeed( + ( + options.projectThreads ?? [ + { + id: threadId, + title: "Worktree test thread", + branch: thread?.branch ?? null, + worktreePath: thread?.worktreePath ?? null, + }, + ] + ).map( + (item) => + ({ + id: item.id, projectId, - title: options.archivedProjectThread.title, - branch: options.archivedProjectThread.branch, - worktreePath: options.archivedProjectThread.worktreePath, - activeRunId: null, - archivedAt: DateTime.makeUnsafe("2026-01-02T00:00:00.000Z"), - lineage: { - parentThreadId: null, - relationshipToParent: null, - rootThreadId: options.archivedProjectThread.id, - }, - }), - ]; - const listProjectThreads = vi.fn(() => Effect.succeed(projectThreadShells)); - const getShellSnapshot = vi.fn(() => - Effect.succeed({ - schemaVersion: 1, - snapshotSequence: 1, - threads: projectThreadShells, - archivedThreads: archivedThreadShells, - } as never), + title: item.title, + branch: item.branch, + worktreePath: item.worktreePath, + status: item.status ?? "idle", + activeRunId: item.active === true ? "run-active" : null, + lineage: { relationshipToParent: "none" }, + }) as never, + ), + ), ); const removeWorktree = vi.fn((_: unknown) => options.removeWorktreeFails @@ -367,84 +281,58 @@ const makeHarness = (options: HarnessOptions = {}) => { ? (Effect.fail("simulated remote resolve failure") as never) : Effect.succeed({ commitSha: "abc123", remoteRefName: "origin/dev" }), ); + 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, + }, + }; }), ), ), ); - const listRefs = vi.fn((input: { readonly query?: string | undefined }) => { - const refs = - options.refs !== undefined - ? options.refs.filter((ref) => - input.query === undefined ? true : ref.name.includes(input.query), - ) - : options.existingBranchWorktreePath === undefined - ? [] - : [ - { - name: input.query ?? "", - current: false, - isDefault: false, - worktreePath: options.existingBranchWorktreePath, - }, - ]; - return Effect.succeed({ - refs, + const listRefs = vi.fn((input: { readonly query?: string | undefined }) => + Effect.succeed({ + refs: + options.refs !== undefined + ? options.refs.filter( + (ref) => input.query === undefined || ref.name.includes(input.query), + ) + : options.existingBranchWorktreePath === undefined + ? [] + : [ + { + name: input.query ?? "", + current: false, + isDefault: false, + worktreePath: options.existingBranchWorktreePath, + }, + ], isRepo: true, hasPrimaryRemote: true, nextCursor: null, totalCount: options.refs?.length ?? (options.existingBranchWorktreePath === undefined ? 0 : 1), - }); - }); - const configuredWorktrees = - options.worktrees ?? - (options.refs ?? []).flatMap((ref) => - ref.worktreePath === null ? [] : [{ path: ref.worktreePath, refName: ref.name }], - ); - const projectWorktreeRoot = options.projectWorktreeRoot ?? workspaceRoot; - const listedWorktrees = configuredWorktrees.some( - (worktree) => worktree.path === projectWorktreeRoot, - ) - ? configuredWorktrees - : [ - { - path: projectWorktreeRoot, - refName: options.currentBranch === undefined ? "dev" : options.currentBranch, - }, - ...configuredWorktrees, - ]; - const listWorktrees = vi.fn((cwd: string) => - options.worktreeInventoryFailsFor?.has(cwd) === true - ? (Effect.fail("simulated worktree inventory failure") as never) - : Effect.succeed( - options.worktreeInventories?.[cwd] ?? { - repositoryCommonDir: "/repo/.git", - currentWorktreeRoot: - listedWorktrees.find((worktree) => worktree.path === cwd)?.path ?? - projectWorktreeRoot, - 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 }) => { @@ -454,19 +342,36 @@ const makeHarness = (options: HarnessOptions = {}) => { } const current = workspaceStatuses.get(input.cwd); return Effect.succeed({ - isRepo: current?.isRepo ?? options.notARepo !== true, + isRepo: options.notARepo !== true, hasPrimaryRemote: true, isDefaultRef: false, refName: - current === undefined - ? options.currentBranch === undefined - ? "dev" - : options.currentBranch - : current.branch, + current?.branch ?? (options.currentBranch === undefined ? "dev" : options.currentBranch), hasWorkingTreeChanges: current?.dirty ?? false, workingTree: { files: [], insertions: 0, deletions: 0 }, }); }); + let switchCallCount = 0; + const switchRef = vi.fn((input: { readonly cwd: string; readonly refName: string }) => { + switchCallCount += 1; + if (options.switchRefFailsAfterMutation === true && switchCallCount === 1) { + workspaceStatuses.set(input.cwd, { branch: input.refName, 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; + } + workspaceStatuses.set(input.cwd, { branch: input.refName, dirty: false }); + return Effect.succeed({ refName: input.refName }); + }); + 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 }) => { @@ -510,43 +415,11 @@ const makeHarness = (options: HarnessOptions = {}) => { } as unknown as Path.Path), ), ); - const gitWorkflowLayer = options.useRealNonRepositoryWorkflow - ? GitWorkflowService.layer.pipe( - Layer.provide( - Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ - detect: () => Effect.succeed(null), - resolve: () => Effect.fail("not a repository") as never, - }), - ), - Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), - Layer.provide( - Layer.mock(GitManager.GitManager)({ - invalidateLocalStatus: () => Effect.void, - invalidateRemoteStatus: () => Effect.void, - invalidateStatus: () => Effect.void, - resolvePullRequest: () => Effect.die("unexpected resolvePullRequest"), - preparePullRequestThread: () => Effect.die("unexpected preparePullRequestThread"), - }), - ), - ) - : Layer.mock(GitWorkflowService.GitWorkflowService)({ - listRefs, - listWorktrees, - listLocalBranchNames, - localStatus, - invalidateLocalStatus, - fetchRemote, - resolveRemoteTrackingCommit, - createWorktree, - removeWorktree, - deleteLocalBranch, - } satisfies Partial); const layer = serviceLayer.pipe( Layer.provide( Layer.mergeAll( Layer.mock(ThreadManagementService)({ dispatch, - getShellSnapshot, getThreadProjection, listProjectThreads, sendToThread, @@ -557,7 +430,19 @@ const makeHarness = (options: HarnessOptions = {}) => { ServerSettings.layerTest({ newWorktreesStartFromOrigin: options.newWorktreesStartFromOrigin ?? false, }), - gitWorkflowLayer, + Layer.mock(GitWorkflowService.GitWorkflowService)({ + listRefs, + listLocalBranchNames, + localStatus, + fetchRemote, + resolveRemoteTrackingCommit, + createWorktree, + removeWorktree, + deleteLocalBranch, + switchRef, + createRef, + invalidateLocalStatus, + } satisfies Partial), Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ runForThread, } satisfies Partial), @@ -581,8 +466,10 @@ const makeHarness = (options: HarnessOptions = {}) => { deleteLocalBranch, localStatus, listRefs, - listWorktrees, listProjectThreads, + switchRef, + createRef, + invalidateLocalStatus, runForThread, }; }; @@ -621,13 +508,19 @@ const runStatus = (harness: ReturnType) => return yield* service.status(harness.scope); }).pipe(Effect.provide(harness.layer)); -const runList = ( +const runList = (harness: ReturnType) => + Effect.gen(function* () { + const service = yield* WorktreeMcpService; + return yield* service.listWorktrees(harness.scope); + }).pipe(Effect.provide(harness.layer)); + +const runCheckout = ( harness: ReturnType, - input: Parameters[1] = {}, + input: Parameters[1], ) => Effect.gen(function* () { const service = yield* WorktreeMcpService; - return yield* service.listWorktrees(harness.scope, input); + return yield* service.checkout(harness.scope, input); }).pipe(Effect.provide(harness.layer)); describe("t3_worktree_handoff", () => { @@ -789,17 +682,33 @@ 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", }); - expect(harness.createWorktree).not.toHaveBeenCalled(); }); }); @@ -966,11 +875,44 @@ describe("t3_worktree_handoff", () => { }); }); + 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("re-checks attachment after creating the worktree and backs out on a race", () => { 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" }); + expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); expect(harness.createWorktree).toHaveBeenCalledTimes(1); // The freshly created worktree must not be left orphaned. expect(harness.removeWorktree).toHaveBeenCalledWith({ @@ -1058,19 +1000,27 @@ 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", () => { + it.effect("reports a partial failure when rollback branch deletion also fails", () => { const harness = makeHarness({ dispatchFails: true, deleteLocalBranchFails: true }); return Effect.gen(function* () { const exit = yield* Effect.exit( runHandoff(harness, { branch: "feature/rollback-branch-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).toHaveBeenCalledWith({ cwd: workspaceRoot, @@ -1108,21 +1058,20 @@ 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); }); }); @@ -1214,34 +1163,6 @@ describe("t3_worktree_handoff", () => { }); describe("t3_worktree_status", () => { - it.effect("reports a plain project directory as not a repository", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const plainDirectory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-worktree-status-non-repo-", - }); - const canonicalPlainDirectory = yield* fileSystem.realPath(plainDirectory); - const harness = makeHarness({ - projectWorkspaceRoot: canonicalPlainDirectory, - useRealNonRepositoryWorkflow: true, - }); - - const result = yield* runStatus(harness); - - expect(result).toMatchObject({ - attached: false, - projectWorkspaceRoot: canonicalPlainDirectory, - actualWorkspace: { - workspacePath: canonicalPlainDirectory, - branch: null, - isRepo: false, - hasWorkingTreeChanges: false, - }, - agreement: "not_repository", - }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("reports an unattached thread", () => { const harness = makeHarness({ newWorktreesStartFromOrigin: true }); return Effect.gen(function* () { @@ -1256,7 +1177,6 @@ describe("t3_worktree_status", () => { actualWorkspace: { workspacePath: workspaceRoot, branch: "dev", - isRepo: true, hasWorkingTreeChanges: false, }, agreement: "branch_mismatch", @@ -1294,32 +1214,42 @@ describe("t3_worktree_status", () => { }); }); - it.effect("reports a missing saved worktree even when inventory discovery fails", () => { - const missingPath = "/worktrees/project/deleted"; + it.effect("reports a recorded worktree that is no longer registered", () => { + const worktreePath = "/worktrees/project/missing"; const harness = makeHarness({ - thread: { worktreePath: missingPath, branch: "feature/deleted" }, - workspaceStatuses: { - [workspaceRoot]: { branch: "dev" }, - [missingPath]: { branch: null, isRepo: false }, - }, - worktreeInventoryFailsFor: new Set([missingPath]), + thread: { worktreePath, branch: "feature/missing" }, + workspaceStatuses: { [worktreePath]: { branch: null } }, }); return Effect.gen(function* () { const result = yield* runStatus(harness); - expect(result).toMatchObject({ - attached: true, - worktreePath: missingPath, - branch: "feature/deleted", - actualWorkspace: { - workspacePath: missingPath, - branch: null, - isRepo: false, - }, - agreement: "workspace_missing", + expect(result.agreement).toBe("workspace_missing"); + expect(result.recordedWorkspace).toEqual({ + branch: "feature/missing", + worktreePath, }); }); }); + 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 +1276,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 +1299,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, }, ], @@ -1378,10 +1318,6 @@ describe("t3_worktree_list", () => { return Effect.gen(function* () { const result = yield* runList(harness); expect(result.projectWorkspaceRoot).toBe(workspaceRoot); - expect(result.repositoryCommonDir).toBe("/repo/.git"); - expect(result.projectWorktreeRoot).toBe(workspaceRoot); - expect(result.nextCursor).toBeNull(); - expect(result.total).toBe(2); expect(result.worktrees).toEqual([ { path: workspaceRoot, @@ -1390,12 +1326,10 @@ describe("t3_worktree_list", () => { isRepo: true, isProjectRoot: true, hasWorkingTreeChanges: false, - availability: "available", - statusError: null, bindings: [ { threadId, - title: "Caller", + title: "Worktree test thread", status: "idle", recordedBranch: "dev", recordedWorktreePath: null, @@ -1403,7 +1337,6 @@ describe("t3_worktree_list", () => { callingThread: true, }, ], - bindingCount: 1, }, { path: worktreePath, @@ -1412,8 +1345,6 @@ describe("t3_worktree_list", () => { isRepo: true, isProjectRoot: false, hasWorkingTreeChanges: true, - availability: "available", - statusError: null, bindings: [ { threadId: otherThreadId, @@ -1425,108 +1356,249 @@ describe("t3_worktree_list", () => { callingThread: false, }, ], - bindingCount: 1, }, ]); }); }); +}); - it.effect("includes detached worktrees without inventing a branch label", () => { - const detachedPath = "/worktrees/project/detached"; +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" }, - { path: detachedPath, refName: null }, + 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/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("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("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" }, - [detachedPath]: { branch: null }, + [worktreePath]: { branch: "feature/checkout" }, }, }); return Effect.gen(function* () { - const result = yield* runList(harness); - expect(result.worktrees).toContainEqual({ - path: detachedPath, - branch: null, - actualBranch: null, - isRepo: true, - isProjectRoot: false, - hasWorkingTreeChanges: false, - availability: "available", - statusError: null, - bindings: [], - bindingCount: 0, + 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("pages before status reads and keeps a missing checkout discoverable", () => { - const firstPath = "/worktrees/project/a-missing"; - const secondPath = "/worktrees/project/b"; + it.effect("returns an attached thread to the project root", () => { + const worktreePath = "/worktrees/project/feature-checkout"; const harness = makeHarness({ - worktrees: [ - { path: workspaceRoot, refName: "dev" }, - { path: firstPath, refName: "feature/a" }, - { path: secondPath, refName: "feature/b" }, + thread: { branch: "feature/checkout", worktreePath }, + refs: [ + rootRefs[0], + { + name: "feature/checkout", + current: true, + isDefault: false, + worktreePath, + }, ], - localStatusFailsOnCall: 1, + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [worktreePath]: { branch: "feature/checkout" }, + }, }); return Effect.gen(function* () { - const result = yield* runList(harness, { cursor: 1, limit: 1 }); - expect(result).toMatchObject({ total: 3, nextCursor: 2 }); - expect(result.worktrees).toEqual([ - expect.objectContaining({ - path: firstPath, - availability: "missing", - actualBranch: null, - isRepo: false, - }), - ]); - expect(harness.localStatus).toHaveBeenCalledTimes(1); + 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("marks a stale checkout missing when status reports a non-repository path", () => { - const stalePath = "/worktrees/project/stale"; + it.effect("creates a new worktree for an already attached thread", () => { + const sourcePath = "/worktrees/project/source"; const harness = makeHarness({ - worktrees: [ - { path: workspaceRoot, refName: "dev" }, - { path: stalePath, refName: "feature/stale" }, + 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" }, - [stalePath]: { branch: null, isRepo: false }, + [sourcePath]: { branch: "feature/source" }, }, }); return Effect.gen(function* () { - const result = yield* runList(harness); - expect(result.worktrees).toContainEqual( + 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({ - path: stalePath, - availability: "missing", - statusError: "Worktree path does not exist.", - actualBranch: null, - isRepo: false, + expectedBranch: "feature/source", + expectedWorktreePath: sourcePath, }), ); }); }); - it.effect("bounds returned bindings while reporting the total", () => { - const otherOne = ThreadId.make("thread-binding-one"); - const otherTwo = ThreadId.make("thread-binding-two"); + it.effect("rejects dirty files before switching branches", () => { const harness = makeHarness({ - worktrees: [{ path: workspaceRoot, refName: "dev" }], + 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: otherOne, title: "Other one", branch: "dev", worktreePath: null }, - { id: otherTwo, title: "Other two", branch: "dev", worktreePath: null }, + { + id: ThreadId.make("thread-worktree-shared"), + title: "Shared owner", + branch: "feature/shared", + worktreePath, + }, ], }); return Effect.gen(function* () { - const result = yield* runList(harness, { bindingLimit: 1 }); - expect(result.worktrees[0]?.bindingCount).toBe(3); - expect(result.worktrees[0]?.bindings).toHaveLength(1); + const exit = yield* Effect.exit( + runCheckout(harness, { target: { type: "worktree", path: worktreePath } }), + ); + expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "workspace_shared" }); }); }); @@ -1754,6 +1826,254 @@ describe("t3_worktree_list", () => { expect(harness.listWorktrees).toHaveBeenCalledTimes(2); }); }); + + 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("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, + threadReadFailsOnRecheck: 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("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("rolls back when the switched branch cannot be verified", () => { + const harness = makeHarness({ + thread: { branch: "dev", worktreePath: null }, + refs: rootRefs, + workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + localStatusFailsOnCall: 2, + }); + 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("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); + }), + ); }); describe("WorktreeMcpHandoffInput schema", () => { diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 38d10b58902c..f486b22be665 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -1,13 +1,16 @@ import { CommandId, MessageId, + type OrchestrationV2ThreadProjection, type OrchestrationV2ThreadShell, type ProjectId, + type VcsRef, + type WorktreeMcpCheckoutInput, + type WorktreeMcpCheckoutResult, WorktreeMcpFailure, type WorktreeMcpContinuationStatus, type WorktreeMcpHandoffInput, type WorktreeMcpHandoffResult, - type WorktreeMcpListInput, type WorktreeMcpListResult, type WorktreeMcpSetupScriptStatus, type WorktreeMcpStatusResult, @@ -17,7 +20,6 @@ import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -42,13 +44,20 @@ export class WorktreeMcpService extends Context.Service< ) => Effect.Effect; readonly listWorktrees: ( 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 { @@ -66,7 +75,6 @@ const asOperationFailed = (prefix: string) => const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const threadManagement = yield* ThreadManagementService; const projects = yield* ProjectService.ProjectService; @@ -75,10 +83,9 @@ 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 requireCapability = (scope: McpInvocationScope) => scope.capabilities.has("worktree") @@ -127,37 +134,48 @@ const make = Effect.gen(function* () { const normalizePath = (value: string) => path.normalize(path.resolve(value)); - const canonicalizePath = (value: string) => { - const normalized = normalizePath(value); - return fileSystem.realPath(normalized).pipe(Effect.orElseSucceed(() => normalized)); - }; - - const threadWorkspacePath = Effect.fn("WorktreeMcpService.threadWorkspacePath")(function* ( + const threadWorkspacePath = ( thread: Pick, projectWorkspaceRoot: string, - ) { - return yield* canonicalizePath(thread.worktreePath ?? projectWorkspaceRoot); - }); + ) => normalizePath(thread.worktreePath ?? projectWorkspaceRoot); - const loadWorktrees = Effect.fn("WorktreeMcpService.loadWorktrees")(function* ( + const loadRefs = Effect.fn("WorktreeMcpService.loadRefs")(function* ( projectWorkspaceRoot: string, + refKind: "all" | "local" = "all", ) { - return yield* gitWorkflow - .listWorktrees(projectWorkspaceRoot) - .pipe(asOperationFailed("Unable to list project worktrees")); + const refs: Array = []; + let cursor: number | undefined; + let firstPage = true; + do { + const page = yield* gitWorkflow + .listRefs({ + cwd: projectWorkspaceRoot, + refKind, + includeMatchingRemoteRefs: refKind === "all", + refresh: firstPage, + 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; + firstPage = false; + } while (cursor !== undefined); + return refs; }); const loadProjectThreads = ( projectId: ProjectId, ): Effect.Effect, WorktreeMcpFailure> => - threadManagement.getShellSnapshot().pipe( - Effect.map((snapshot) => - [...snapshot.threads, ...snapshot.archivedThreads].filter( - (thread) => thread.projectId === projectId, - ), - ), - asOperationFailed(`Unable to list threads in project ${projectId}`), - ); + threadManagement + .listProjectThreads({ projectId, includeSubagents: true }) + .pipe(asOperationFailed(`Unable to list threads in project ${projectId}`)); const readWorkspaceStatus = (workspacePath: string) => gitWorkflow @@ -167,34 +185,75 @@ const make = Effect.gen(function* () { asOperationFailed(`Unable to read git status in '${workspacePath}'`), ); - const handoffIds = (scope: McpInvocationScope) => + 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) { @@ -206,6 +265,22 @@ const make = Effect.gen(function* () { const project = yield* loadProject(scope, projection.thread.projectId); const projectCwd = project.workspaceRoot; + const sourceCwd = projection.thread.worktreePath ?? projectCwd; + + if (projection.thread.worktreePath !== null) { + const projectRefs = yield* loadRefs(projectCwd, "local"); + const projectWorktreePaths = new Set( + projectRefs.flatMap((ref) => + ref.worktreePath === null ? [] : [normalizePath(ref.worktreePath)], + ), + ); + if (!projectWorktreePaths.has(normalizePath(projection.thread.worktreePath))) { + return yield* failure( + "scope_mismatch", + `Thread worktree '${projection.thread.worktreePath}' is not registered in project '${projection.thread.projectId}'.`, + ); + } + } if (input.path !== undefined && !path.isAbsolute(input.path)) { return yield* failure( @@ -217,13 +292,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 +357,7 @@ const make = Effect.gen(function* () { worktreeBaseRef = resolvedRemoteBase.commitSha; } - const ids = yield* handoffIds(scope); + const ids = yield* transitionIds(scope, "worktree-handoff"); // uninterruptibleMask: only the potentially slow worktree creation itself // stays interruptible (restore). From the moment it succeeds, through the @@ -324,8 +397,14 @@ const make = Effect.gen(function* () { // 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. + let createdWorktreeRemoved = false; const removeCreatedWorktree = Effect.suspend(() => gitWorkflow.removeWorktree({ cwd: projectCwd, path: worktreePath, force: true }).pipe( + Effect.tap(() => + Effect.sync(() => { + createdWorktreeRemoved = true; + }), + ), Effect.andThen( Effect.suspend(() => gitWorkflow.deleteLocalBranch({ @@ -336,62 +415,117 @@ const make = Effect.gen(function* () { ), ), ), - ).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); + ); + + 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); } - // 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 cleanupExit = yield* Effect.exit(removeCreatedWorktree); + if (Exit.isFailure(cleanupExit)) { 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 could not be removed: ${errorMessage(Cause.squash(recheckExit.cause))}`, + { + workspacePath: worktreePath, + recordedBranch: projection.thread.branch, + actualBranch: createdWorktreeRemoved ? null : worktree.worktree.refName, + rollback: "failed", + }, ); } - 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, + }), ); + 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 cleanupExit = yield* Effect.exit(removeCreatedWorktree); + if (Exit.isFailure(cleanupExit)) { + return yield* failure( + "partial_failure", + `The worktree binding failed and the created worktree could not be removed: ${dispatchDetail}`, + { + workspacePath: worktreePath, + recordedBranch: bindingAfterDispatch.branch, + actualBranch: createdWorktreeRemoved ? null : worktree.worktree.refName, + rollback: "failed", + }, + ); + } + 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 +535,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) @@ -493,7 +609,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 +617,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))), ); }), ); @@ -515,45 +631,31 @@ const make = Effect.gen(function* () { yield* requireCapability(scope); const projection = yield* loadThread(scope); const project = yield* loadProject(scope, projection.thread.projectId); - const projectWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot); + const projectWorkspaceRoot = normalizePath(project.workspaceRoot); const workspacePath = normalizePath(projection.thread.worktreePath ?? projectWorkspaceRoot); - const [ - defaultStartFromOrigin, - actual, - projectInventory, - workspaceInventory, - workspaceExists, - ] = yield* Effect.all( + const [defaultStartFromOrigin, actual, refs] = yield* Effect.all( [ readDefaultStartFromOrigin, readWorkspaceStatus(workspacePath), - Effect.option(loadWorktrees(projectWorkspaceRoot)), - Effect.option(loadWorktrees(workspacePath)), - fileSystem.exists(workspacePath).pipe(Effect.orElseSucceed(() => false)), + loadRefs(projectWorkspaceRoot, "local"), ], - { concurrency: 5 }, + { concurrency: 3 }, ); - const canonicalWorkspacePath = yield* canonicalizePath(workspacePath); - const physicalWorkspacePath = Option.isSome(workspaceInventory) - ? workspaceInventory.value.currentWorktreeRoot - : null; - const agreement = - !actual.isRepo && !workspaceExists - ? "workspace_missing" - : !actual.isRepo - ? "not_repository" - : Option.isNone(projectInventory) || Option.isNone(workspaceInventory) - ? "workspace_missing" - : workspaceInventory.value.repositoryCommonDir !== - projectInventory.value.repositoryCommonDir || - physicalWorkspacePath === null || - !projectInventory.value.worktrees.some( - (worktree) => worktree.path === physicalWorkspacePath, - ) - ? "workspace_missing" - : actual.refName !== projection.thread.branch - ? "branch_mismatch" - : "in_sync"; + const knownWorkspacePaths = new Set([ + projectWorkspaceRoot, + ...refs.flatMap((ref) => + ref.isRemote === true || ref.worktreePath === null + ? [] + : [normalizePath(ref.worktreePath)], + ), + ]); + const agreement = !knownWorkspacePaths.has(workspacePath) + ? "workspace_missing" + : !actual.isRepo + ? "not_repository" + : actual.refName !== projection.thread.branch + ? "branch_mismatch" + : "in_sync"; const result: WorktreeMcpStatusResult = { attached: projection.thread.worktreePath !== null, @@ -566,7 +668,7 @@ const make = Effect.gen(function* () { worktreePath: projection.thread.worktreePath, }, actualWorkspace: { - workspacePath: physicalWorkspacePath ?? canonicalWorkspacePath, + workspacePath, isRepo: actual.isRepo, branch: actual.refName, hasWorkingTreeChanges: actual.hasWorkingTreeChanges, @@ -759,14 +861,555 @@ 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 = normalizePath(project.workspaceRoot); + const currentWorkspacePath = normalizePath( + projection.thread.worktreePath ?? projectWorkspaceRoot, + ); + if (input.target.type === "new_worktree") { + const previousActual = yield* readWorkspaceStatus(currentWorkspacePath); + 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, + ); + const actual = yield* readWorkspaceStatus(handoff.worktreePath); + return { + previous: { + workspacePath: currentWorkspacePath, + recordedBranch: projection.thread.branch, + recordedWorktreePath: projection.thread.worktreePath, + actualBranch: previousActual.refName, + }, + current: { + workspacePath: handoff.worktreePath, + recordedBranch: handoff.branch, + recordedWorktreePath: handoff.worktreePath, + actualBranch: actual.refName, + }, + checkoutAction: "created", + workspaceChanged: true, + branchChanged: previousActual.refName !== actual.refName, + continuation: handoff.continuation, + setupScript: handoff.setupScript, + callerTurnEnds: true, + note: handoff.note, + } satisfies WorktreeMcpCheckoutResult; + } + const [refs, threads, previousActual] = yield* Effect.all( + [ + loadRefs(projectWorkspaceRoot), + loadProjectThreads(projection.thread.projectId), + readWorkspaceStatus(currentWorkspacePath), + ], + { concurrency: 3 }, + ); + + const localRefs = refs.filter((ref) => ref.isRemote !== true); + const workspacePaths = new Set([ + projectWorkspaceRoot, + ...localRefs.flatMap((ref) => + ref.worktreePath === null ? [] : [normalizePath(ref.worktreePath)], + ), + ]); + 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": { + targetWorkspacePath = normalizePath(input.target.path); + if ( + targetWorkspacePath === projectWorkspaceRoot || + !workspacePaths.has(targetWorkspacePath) + ) { + return yield* failure( + "scope_mismatch", + targetWorkspacePath === projectWorkspaceRoot + ? "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 = projectWorkspaceRoot; + 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.", + ); + } + 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 + : normalizePath(selectedRef.worktreePath); + targetWorkspacePath = + workspace === "project_root" + ? projectWorkspaceRoot + : workspace === "current" + ? currentWorkspacePath + : (selectedWorktreePath ?? + (projection.thread.worktreePath !== null && selectedRef?.isDefault === true + ? projectWorkspaceRoot + : 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 + : normalizePath(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 && + targetBefore.refName !== requestedBranch.replace(/^[^/]+\//, "") + : targetBefore.refName !== requestedBranch)); + const otherBindings = threads.filter( + (thread) => + thread.id !== scope.threadId && + threadWorkspacePath(thread, projectWorkspaceRoot) === targetWorkspacePath, + ); + 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 !== projectWorkspaceRoot && + (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 === projectWorkspaceRoot && + 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"); + return yield* Effect.uninterruptibleMask(() => + Effect.gen(function* () { + let checkoutAction: WorktreeMcpCheckoutResult["checkoutAction"] = + targetWorkspacePath === currentWorkspacePath ? "unchanged" : "reused"; + let createdBranch: string | null = null; + + if (shouldMutateCheckout && requestedBranch !== undefined) { + if (createBranch) { + yield* gitWorkflow + .createRef({ + cwd: targetWorkspacePath, + refName: requestedBranch, + switchRef: false, + }) + .pipe(asOperationFailed(`Unable to create branch '${requestedBranch}'`)); + createdBranch = requestedBranch; + } + const switchExit = yield* Effect.exit( + gitWorkflow.switchRef({ cwd: targetWorkspacePath, refName: requestedBranch }), + ); + if (Exit.isFailure(switchExit)) { + const afterFailedSwitchExit = yield* Effect.exit( + readWorkspaceStatus(targetWorkspacePath), + ); + if (Exit.isFailure(afterFailedSwitchExit)) { + return yield* failure( + "partial_failure", + `Branch checkout failed and the resulting Git state could not be verified: ${errorMessage(Cause.squash(switchExit.cause))}`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch: null, + rollback: "not_possible", + }, + ); + } + const afterFailedSwitch = afterFailedSwitchExit.value; + const checkoutChanged = afterFailedSwitch.refName !== targetBefore.refName; + if (checkoutChanged && targetBefore.refName === null) { + return yield* failure( + "partial_failure", + `Branch checkout failed after changing Git state and the previous detached ref cannot be restored automatically: ${errorMessage(Cause.squash(switchExit.cause))}`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch: afterFailedSwitch.refName, + rollback: "not_possible", + }, + ); + } + const rollback = checkoutChanged + ? gitWorkflow.switchRef({ + cwd: targetWorkspacePath, + refName: targetBefore.refName!, + }) + : Effect.void; + const cleanup = rollback.pipe( + Effect.andThen( + createdBranch === null + ? Effect.void + : gitWorkflow.deleteLocalBranch({ + cwd: targetWorkspacePath, + refName: createdBranch, + force: true, + }), + ), + ); + const cleanupExit = yield* Effect.exit(cleanup); + if (Exit.isFailure(cleanupExit)) { + const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath); + return yield* failure( + "partial_failure", + `Branch checkout failed and rollback also failed: ${errorMessage(Cause.squash(switchExit.cause))}`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch, + rollback: "failed", + }, + ); + } + return yield* failure( + "operation_failed", + `Unable to check out '${requestedBranch}': ${errorMessage(Cause.squash(switchExit.cause))}`, + ); + } + 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") { + if (targetBefore.refName === null) { + return yield* failure( + "partial_failure", + `Git checkout completed but its resulting state could not be verified: ${detail}`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch: null, + rollback: "not_possible", + }, + ); + } + const rollbackExit = yield* Effect.exit( + gitWorkflow + .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName }) + .pipe( + Effect.andThen( + createdBranch === null + ? Effect.void + : gitWorkflow.deleteLocalBranch({ + cwd: targetWorkspacePath, + refName: createdBranch, + force: true, + }), + ), + ), + ); + if (Exit.isFailure(rollbackExit)) { + const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath); + return yield* failure( + "partial_failure", + `Git checkout completed, verification failed, and rollback also failed: ${detail}`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch, + rollback: "failed", + }, + ); + } + } + return yield* failure( + "operation_failed", + `Unable to verify the selected checkout '${targetWorkspacePath}': ${detail}`, + ); + } + const actual = actualExit.value; + const nextBranch = actual.refName; + const workspaceChanged = targetWorkspacePath !== currentWorkspacePath; + const nextWorktreePath = workspaceChanged + ? targetWorkspacePath === projectWorkspaceRoot + ? null + : targetWorkspacePath + : projection.thread.worktreePath; + const bindingChanged = + nextBranch !== projection.thread.branch || + nextWorktreePath !== projection.thread.worktreePath; + + if (bindingChanged) { + 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, + }), + ); + 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 { + if (checkoutAction === "switched" || checkoutAction === "created") { + if (targetBefore.refName === null) { + return yield* failure( + "partial_failure", + `Git checkout completed but the durable thread binding failed: ${dispatchDetail}`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch: actual.refName, + rollback: "not_possible", + }, + ); + } + const rollbackExit = yield* Effect.exit( + gitWorkflow + .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName }) + .pipe( + Effect.andThen( + createdBranch === null + ? Effect.void + : gitWorkflow.deleteLocalBranch({ + cwd: targetWorkspacePath, + refName: createdBranch, + force: true, + }), + ), + ), + ); + if (Exit.isFailure(rollbackExit)) { + const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath); + return yield* failure( + "partial_failure", + `Git checkout completed, the durable binding failed, and rollback also failed: ${dispatchDetail}`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch, + rollback: "failed", + }, + ); + } + } + 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, + 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; + }), + ); + }); + + 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< WorktreeMcpService, never, | Crypto.Crypto - | FileSystem.FileSystem | Path.Path | ThreadManagementService | ProjectService.ProjectService diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index 8d2bc64988dd..a7d1c9c53b90 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -17,11 +17,17 @@ const handlers = { const service = yield* WorktreeMcpService; return yield* service.status(scope); }), - t3_worktree_list: (input) => + t3_worktree_list: () => Effect.gen(function* () { const scope = yield* McpInvocationContext; const service = yield* WorktreeMcpService; - return yield* service.listWorktrees(scope, input); + return yield* service.listWorktrees(scope); + }), + t3_thread_checkout: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* WorktreeMcpService; + return yield* service.checkout(scope, input); }), } satisfies Parameters[0]; 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..91cc15eb1cbf 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -1,8 +1,9 @@ import { WorktreeMcpFailure, + WorktreeMcpCheckoutInput, + WorktreeMcpCheckoutResult, WorktreeMcpHandoffInput, WorktreeMcpHandoffResult, - WorktreeMcpListInput, WorktreeMcpListResult, WorktreeMcpStatusResult, } from "@t3tools/contracts"; @@ -15,7 +16,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, @@ -48,8 +49,7 @@ export const WorktreeStatusTool = Tool.make("t3_worktree_status", { export const WorktreeListTool = Tool.make("t3_worktree_list", { description: - "Page through the calling thread's project root and Git-registered worktrees, including detached checkouts. Paths are canonicalized from Git's repository identity. Each entry includes the actual checked-out branch, dirty state, availability, and a bounded list plus total count of threads bound to that checkout. Use cursor until nextCursor is null. This tool does not create, remove, prune, or repair worktrees.", - parameters: WorktreeMcpListInput, + "List the calling thread's project root and existing branch-backed git worktrees. Each entry includes the actual checked-out branch, dirty state, and threads bound to that checkout with their recorded branch and worktree path. Use this read path before t3_thread_checkout; it does not create, remove, prune, or repair worktrees.", success: WorktreeMcpListResult, failure: WorktreeMcpFailure, failureMode: "return", @@ -61,8 +61,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..3a1e06cf06af 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1427,6 +1427,17 @@ 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.archive" && thread.archivedAt !== null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 009b8dded789..8f65df41e44a 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"); diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 9e39bd5579e4..a3a6a74f7f94 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -44,7 +44,7 @@ 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 +The credential grants `preview`, `orchestration`, and `worktree` 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. @@ -328,6 +328,34 @@ 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. If `continuationPrompt` is +present, the service writes the new binding and then durably queues the next turn before the detach +can interrupt the MCP call. The next provider session derives its working directory from the new +thread projection. 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 the source or target +worktree. + ## Delegated Task Lifecycle The MCP server is a command ingress into V2. It does not call provider adapters @@ -371,8 +399,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..d898a36cbc70 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -65,16 +65,22 @@ 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. +with Git's actual branch. They can also list the project root and existing worktrees, including +dirty state and other threads using each checkout. + +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 restarts the agent session in the selected checkout. The agent can +queue a continuation before that restart, so longer work resumes without needing the browser to +stay open. These controls apply only to the calling thread and its current project. They do not +remove, prune, or revive worktrees. ## Getting Started diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index d14f1dcebea3..4a35e4f7433b 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2090,6 +2090,7 @@ 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)), /** 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..32b4f3e41c66 100644 --- a/packages/contracts/src/worktreeMcp.ts +++ b/packages/contracts/src/worktreeMcp.ts @@ -1,6 +1,13 @@ import * as Schema from "effect/Schema"; -import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { 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" }, From 5bfe0379707616cb2c9a339d0c734a7a132b762c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 15:42:20 -0700 Subject: [PATCH 02/16] fix(mcp): serialize physical workspace checkout --- .../server/src/mcp/WorktreeMcpService.test.ts | 646 +++++++++++-- apps/server/src/mcp/WorktreeMcpService.ts | 881 ++++++++++++------ .../SelectionRestart.integration.test.ts | 164 ++++ apps/server/src/vcs/GitVcsDriverCore.test.ts | 27 + 4 files changed, 1339 insertions(+), 379 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 2dafc9a4e76c..9e933bd6259f 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -4,6 +4,7 @@ import { CommandId, EnvironmentId, type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadShell, type Project, ProjectId, ProviderInstanceId, @@ -103,7 +104,10 @@ interface HarnessOptions { readonly dispatchGate?: Effect.Effect; readonly threadAttachedOnRecheck?: boolean; 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; @@ -123,6 +127,12 @@ interface HarnessOptions { readonly isRemote?: boolean; readonly worktreePath: string | null; }>; + readonly worktrees?: ReadonlyArray<{ + readonly path: string; + readonly refName: string | null; + }>; + readonly projectWorktreeRoot?: string; + readonly workspaceAliases?: Readonly>; readonly workspaceStatuses?: Readonly>; readonly localStatusFailsOnCall?: number; readonly projectThreads?: ReadonlyArray<{ @@ -133,9 +143,21 @@ interface HarnessOptions { 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 switchRefFails?: boolean; readonly switchRefFailsAfterMutation?: boolean; readonly switchRefRollbackFails?: boolean; + readonly switchRefGate?: Effect.Effect; + readonly switchRefResultBranch?: string | null; + readonly refChangeAfterSwitch?: string | null; readonly createRefFails?: boolean; } @@ -187,6 +209,28 @@ 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.threadAttachedOnRecheck === true && getThreadProjection.mock.calls.length > 1 && @@ -210,9 +254,26 @@ const makeHarness = (options: HarnessOptions = {}) => { ) { return Effect.succeed(makeProjection({ ...thread, ...options.threadAfterFailedDispatch })); } - return id === threadId && thread !== null - ? Effect.succeed(makeProjection(thread)) - : Effect.fail(new OrchestratorProjectionError({ threadId: id })); + if (id === threadId && thread !== null) { + return Effect.succeed(makeProjection(thread)); + } + const projectThread = options.projectThreads?.find((item) => item.id === id); + if (projectThread !== undefined) { + const projection = makeProjection({ + branch: projectThread.branch, + worktreePath: 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") { @@ -234,34 +295,59 @@ const makeHarness = (options: HarnessOptions = {}) => { : Effect.succeed( id === projectId && options.projectMissing !== true ? Option.some(project) - : Option.none(), + : id === options.otherProjectThread?.projectId + ? Option.some({ + ...project, + id, + workspaceRoot: options.otherProjectThread.workspaceRoot, + }) + : Option.none(), ), ); - const listProjectThreads = vi.fn(() => - Effect.succeed( - ( - options.projectThreads ?? [ - { - id: threadId, - title: "Worktree test thread", - branch: thread?.branch ?? null, - worktreePath: thread?.worktreePath ?? null, - }, - ] - ).map( - (item) => - ({ - id: item.id, - projectId, - title: item.title, - branch: item.branch, - worktreePath: item.worktreePath, - status: item.status ?? "idle", - activeRunId: item.active === true ? "run-active" : null, - lineage: { relationshipToParent: "none" }, - }) as never, - ), - ), + const projectThreadShells: Array = ( + options.projectThreads ?? [ + { + id: threadId, + title: "Worktree test thread", + branch: thread?.branch ?? null, + worktreePath: thread?.worktreePath ?? null, + }, + ] + ).map( + (item) => + ({ + id: item.id, + projectId, + title: item.title, + branch: item.branch, + worktreePath: item.worktreePath, + status: item.status ?? "idle", + activeRunId: item.active === true ? "run-active" : null, + lineage: { relationshipToParent: "none" }, + }) as unknown as OrchestrationV2ThreadShell, + ); + if (options.otherProjectThread !== undefined) { + projectThreadShells.push({ + ...(projectThreadShells[0] ?? makeProjection({}).thread), + 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 ? "run-active" : null, + lineage: { relationshipToParent: "none" }, + } as unknown as OrchestrationV2ThreadShell); + } + 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, + archivedThreads: [], + } as never), ); const removeWorktree = vi.fn((_: unknown) => options.removeWorktreeFails @@ -310,28 +396,56 @@ const makeHarness = (options: HarnessOptions = {}) => { ), ), ); - const listRefs = vi.fn((input: { readonly query?: string | undefined }) => - Effect.succeed({ - refs: - options.refs !== undefined - ? options.refs.filter( - (ref) => input.query === undefined || ref.name.includes(input.query), - ) - : options.existingBranchWorktreePath === undefined - ? [] - : [ - { - name: input.query ?? "", - current: false, - isDefault: false, - worktreePath: options.existingBranchWorktreePath, - }, - ], + const listRefs = vi.fn((input: { readonly query?: string | undefined }) => { + const refs = + options.refs !== undefined + ? options.refs.filter((ref) => + input.query === undefined ? true : ref.name.includes(input.query), + ) + : options.existingBranchWorktreePath === undefined + ? [] + : [ + { + name: input.query ?? "", + current: false, + isDefault: false, + worktreePath: options.existingBranchWorktreePath, + }, + ]; + return Effect.succeed({ + refs, isRepo: true, hasPrimaryRemote: true, nextCursor: null, totalCount: options.refs?.length ?? (options.existingBranchWorktreePath === undefined ? 0 : 1), + }); + }); + const configuredWorktrees = + options.worktrees ?? + (options.refs ?? []).flatMap((ref) => + ref.worktreePath === null ? [] : [{ path: ref.worktreePath, refName: ref.name }], + ); + const projectWorktreeRoot = options.projectWorktreeRoot ?? workspaceRoot; + const listedWorktrees = configuredWorktrees.some( + (worktree) => worktree.path === projectWorktreeRoot, + ) + ? configuredWorktrees + : [ + { + path: projectWorktreeRoot, + refName: options.currentBranch === undefined ? "dev" : options.currentBranch, + }, + ...configuredWorktrees, + ]; + const listWorktrees = vi.fn((cwd: string) => + Effect.succeed({ + repositoryCommonDir: "/repo/.git", + currentWorktreeRoot: + options.workspaceAliases?.[cwd] ?? + listedWorktrees.find((worktree) => worktree.path === cwd)?.path ?? + (cwd === workspaceRoot ? projectWorktreeRoot : cwd), + worktrees: listedWorktrees, }), ); let localStatusCallCount = 0; @@ -346,27 +460,47 @@ const makeHarness = (options: HarnessOptions = {}) => { hasPrimaryRemote: true, isDefaultRef: false, refName: - current?.branch ?? (options.currentBranch === undefined ? "dev" : options.currentBranch), + current === undefined + ? options.currentBranch === undefined + ? "dev" + : options.currentBranch + : current.branch, hasWorkingTreeChanges: current?.dirty ?? false, workingTree: { files: [], insertions: 0, deletions: 0 }, }); }); let switchCallCount = 0; - const switchRef = vi.fn((input: { readonly cwd: string; readonly refName: string }) => { - switchCallCount += 1; - if (options.switchRefFailsAfterMutation === true && switchCallCount === 1) { - workspaceStatuses.set(input.cwd, { branch: input.refName, 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; - } - workspaceStatuses.set(input.cwd, { branch: input.refName, dirty: false }); - return Effect.succeed({ refName: input.refName }); - }); + 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: input.refName, 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) @@ -401,8 +535,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 = @@ -412,6 +546,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), ), ); @@ -420,6 +556,7 @@ const makeHarness = (options: HarnessOptions = {}) => { Layer.mergeAll( Layer.mock(ThreadManagementService)({ dispatch, + getShellSnapshot, getThreadProjection, listProjectThreads, sendToThread, @@ -432,6 +569,7 @@ const makeHarness = (options: HarnessOptions = {}) => { }), Layer.mock(GitWorkflowService.GitWorkflowService)({ listRefs, + listWorktrees, listLocalBranchNames, localStatus, fetchRemote, @@ -466,6 +604,7 @@ const makeHarness = (options: HarnessOptions = {}) => { deleteLocalBranch, localStatus, listRefs, + listWorktrees, listProjectThreads, switchRef, createRef, @@ -508,10 +647,13 @@ const runStatus = (harness: ReturnType) => return yield* service.status(harness.scope); }).pipe(Effect.provide(harness.layer)); -const runList = (harness: ReturnType) => +const runList = ( + harness: ReturnType, + input: Parameters[1] = {}, +) => Effect.gen(function* () { const service = yield* WorktreeMcpService; - return yield* service.listWorktrees(harness.scope); + return yield* service.listWorktrees(harness.scope, input); }).pipe(Effect.provide(harness.layer)); const runCheckout = ( @@ -970,7 +1112,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 @@ -981,7 +1128,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); @@ -1318,6 +1465,10 @@ describe("t3_worktree_list", () => { return Effect.gen(function* () { const result = yield* runList(harness); expect(result.projectWorkspaceRoot).toBe(workspaceRoot); + expect(result.repositoryCommonDir).toBe("/repo/.git"); + expect(result.projectWorktreeRoot).toBe(workspaceRoot); + expect(result.nextCursor).toBeNull(); + expect(result.total).toBe(2); expect(result.worktrees).toEqual([ { path: workspaceRoot, @@ -1326,6 +1477,8 @@ describe("t3_worktree_list", () => { isRepo: true, isProjectRoot: true, hasWorkingTreeChanges: false, + availability: "available", + statusError: null, bindings: [ { threadId, @@ -1337,6 +1490,7 @@ describe("t3_worktree_list", () => { callingThread: true, }, ], + bindingCount: 1, }, { path: worktreePath, @@ -1345,6 +1499,8 @@ describe("t3_worktree_list", () => { isRepo: true, isProjectRoot: false, hasWorkingTreeChanges: true, + availability: "available", + statusError: null, bindings: [ { threadId: otherThreadId, @@ -1356,10 +1512,84 @@ describe("t3_worktree_list", () => { callingThread: false, }, ], + bindingCount: 1, }, ]); }); }); + + it.effect("includes detached worktrees without inventing a branch label", () => { + const detachedPath = "/worktrees/project/detached"; + const harness = makeHarness({ + worktrees: [ + { path: workspaceRoot, refName: "dev" }, + { path: detachedPath, refName: null }, + ], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [detachedPath]: { branch: null }, + }, + }); + return Effect.gen(function* () { + const result = yield* runList(harness); + expect(result.worktrees).toContainEqual({ + path: detachedPath, + branch: null, + actualBranch: null, + isRepo: true, + isProjectRoot: false, + hasWorkingTreeChanges: false, + availability: "available", + statusError: null, + bindings: [], + bindingCount: 0, + }); + }); + }); + + it.effect("pages before status reads and keeps a missing checkout discoverable", () => { + const firstPath = "/worktrees/project/a-missing"; + const secondPath = "/worktrees/project/b"; + const harness = makeHarness({ + worktrees: [ + { path: workspaceRoot, refName: "dev" }, + { path: firstPath, refName: "feature/a" }, + { path: secondPath, refName: "feature/b" }, + ], + localStatusFailsOnCall: 1, + }); + return Effect.gen(function* () { + const result = yield* runList(harness, { cursor: 1, limit: 1 }); + expect(result).toMatchObject({ total: 3, nextCursor: 2 }); + expect(result.worktrees).toEqual([ + expect.objectContaining({ + path: firstPath, + availability: "missing", + actualBranch: null, + isRepo: false, + }), + ]); + expect(harness.localStatus).toHaveBeenCalledTimes(1); + }); + }); + + it.effect("bounds returned bindings while reporting the total", () => { + const otherOne = ThreadId.make("thread-binding-one"); + const otherTwo = ThreadId.make("thread-binding-two"); + const harness = makeHarness({ + worktrees: [{ path: workspaceRoot, refName: "dev" }], + projectThreads: [ + { id: threadId, title: "Caller", branch: "dev", worktreePath: null }, + { id: otherOne, title: "Other one", branch: "dev", worktreePath: null }, + { id: otherTwo, title: "Other two", branch: "dev", worktreePath: null }, + ], + }); + return Effect.gen(function* () { + const result = yield* runList(harness, { bindingLimit: 1 }); + expect(result.worktrees[0]?.bindingCount).toBe(3); + expect(result.worktrees[0]?.bindings).toHaveLength(1); + }); + }); }); describe("t3_thread_checkout", () => { @@ -1413,6 +1643,101 @@ describe("t3_thread_checkout", () => { }); }); + 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("refuses to bind when Git changes again after resolving the requested ref", () => { + const harness = makeHarness({ + 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); + }); + }); + + for (const [state, option] of [ + ["archived", { threadArchivedOnCall: 3 }], + ["deleted", { threadDeletedOnCall: 3 }], + ] as const) { + it.effect(`rolls Git back 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: "checkout_in_progress", + }); + expect(harness.switchRef).toHaveBeenNthCalledWith(2, { + cwd: workspaceRoot, + refName: "dev", + }); + 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 }, @@ -1549,6 +1874,51 @@ describe("t3_thread_checkout", () => { }); }); + 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 }, @@ -1827,6 +2197,44 @@ describe("t3_thread_checkout", () => { }); }); + 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("rejects switching the shared project root while another thread is active", () => { const harness = makeHarness({ thread: { branch: "dev", worktreePath: null }, @@ -1895,6 +2303,25 @@ describe("t3_thread_checkout", () => { }); }); + 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("rolls the git branch back when the durable binding fails", () => { const harness = makeHarness({ thread: { branch: "dev", worktreePath: null }, @@ -1943,7 +2370,7 @@ describe("t3_thread_checkout", () => { refs: rootRefs, workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, dispatchFails: true, - threadReadFailsOnRecheck: true, + threadReadFailsAfterDispatch: true, }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -1965,6 +2392,32 @@ describe("t3_thread_checkout", () => { }); }); + 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("rolls back when checkout reports failure after changing the branch", () => { const harness = makeHarness({ thread: { branch: "dev", worktreePath: null }, @@ -1992,7 +2445,7 @@ describe("t3_thread_checkout", () => { thread: { branch: "dev", worktreePath: null }, refs: rootRefs, workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, - localStatusFailsOnCall: 2, + localStatusFailsOnCall: 3, }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -2074,6 +2527,59 @@ describe("t3_thread_checkout", () => { 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 f486b22be665..9d31eec90cb4 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -11,6 +11,7 @@ import { type WorktreeMcpContinuationStatus, type WorktreeMcpHandoffInput, type WorktreeMcpHandoffResult, + type WorktreeMcpListInput, type WorktreeMcpListResult, type WorktreeMcpSetupScriptStatus, type WorktreeMcpStatusResult, @@ -20,6 +21,7 @@ import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -44,6 +46,7 @@ export class WorktreeMcpService extends Context.Service< ) => Effect.Effect; readonly listWorktrees: ( scope: McpInvocationScope, + input: WorktreeMcpListInput, ) => Effect.Effect; readonly checkout: ( scope: McpInvocationScope, @@ -75,6 +78,7 @@ const asOperationFailed = (prefix: string) => const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const threadManagement = yield* ThreadManagementService; const projects = yield* ProjectService.ProjectService; @@ -134,10 +138,17 @@ const make = Effect.gen(function* () { const normalizePath = (value: string) => path.normalize(path.resolve(value)); - const threadWorkspacePath = ( + const canonicalizePath = (value: string) => { + const normalized = normalizePath(value); + return fileSystem.realPath(normalized).pipe(Effect.orElseSucceed(() => normalized)); + }; + + const threadWorkspacePath = Effect.fn("WorktreeMcpService.threadWorkspacePath")(function* ( thread: Pick, projectWorkspaceRoot: string, - ) => normalizePath(thread.worktreePath ?? projectWorkspaceRoot); + ) { + return yield* canonicalizePath(thread.worktreePath ?? projectWorkspaceRoot); + }); const loadRefs = Effect.fn("WorktreeMcpService.loadRefs")(function* ( projectWorkspaceRoot: string, @@ -145,14 +156,13 @@ const make = Effect.gen(function* () { ) { const refs: Array = []; let cursor: number | undefined; - let firstPage = true; do { const page = yield* gitWorkflow .listRefs({ cwd: projectWorkspaceRoot, refKind, includeMatchingRemoteRefs: refKind === "all", - refresh: firstPage, + refresh: cursor === undefined, limit: 200, ...(cursor === undefined ? {} : { cursor }), }) @@ -165,11 +175,18 @@ const make = Effect.gen(function* () { } refs.push(...page.refs); cursor = page.nextCursor ?? undefined; - firstPage = false; } while (cursor !== undefined); return refs; }); + const loadWorktrees = Effect.fn("WorktreeMcpService.loadWorktrees")(function* ( + projectWorkspaceRoot: string, + ) { + return yield* gitWorkflow + .listWorktrees(projectWorkspaceRoot) + .pipe(asOperationFailed("Unable to list project worktrees")); + }); + const loadProjectThreads = ( projectId: ProjectId, ): Effect.Effect, WorktreeMcpFailure> => @@ -177,6 +194,61 @@ const make = Effect.gen(function* () { .listProjectThreads({ projectId, includeSubagents: true }) .pipe(asOperationFailed(`Unable to list threads in project ${projectId}`)); + const loadActiveWorkspaceBindings = Effect.fn("WorktreeMcpService.loadActiveWorkspaceBindings")( + function* (repositoryCommonDir: string) { + const snapshot = yield* threadManagement + .getShellSnapshot({ location: "active" }) + .pipe(asOperationFailed("Unable to inspect active thread workspace bindings")); + const byProject = new Map>(); + for (const thread of snapshot.threads) { + 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) => + loadWorktrees(project.workspaceRoot).pipe( + Effect.flatMap((projectInventory) => { + if (projectInventory.repositoryCommonDir !== repositoryCommonDir) { + return Effect.succeed([]); + } + return Effect.forEach(projectThreads, (thread) => { + if (thread.worktreePath === null) { + return Effect.succeed( + projectInventory.currentWorktreeRoot === null + ? [] + : [[thread, projectInventory.currentWorktreeRoot] as const], + ); + } + return loadWorktrees(thread.worktreePath).pipe( + Effect.map((threadInventory) => + threadInventory.repositoryCommonDir === repositoryCommonDir && + threadInventory.currentWorktreeRoot !== null + ? [[thread, threadInventory.currentWorktreeRoot] as const] + : [], + ), + ); + }).pipe(Effect.map((bindings) => bindings.flat())); + }), + ), + }), + ), + ), + { concurrency: 4 }, + ).pipe(Effect.map((bindings) => bindings.flat())); + }, + ); + const readWorkspaceStatus = (workspacePath: string) => gitWorkflow .invalidateLocalStatus(workspacePath) @@ -264,17 +336,13 @@ const make = Effect.gen(function* () { } const project = yield* loadProject(scope, projection.thread.projectId); - const projectCwd = project.workspaceRoot; - const sourceCwd = projection.thread.worktreePath ?? projectCwd; + const projectCwd = yield* canonicalizePath(project.workspaceRoot); + const sourceCwd = yield* canonicalizePath(projection.thread.worktreePath ?? projectCwd); if (projection.thread.worktreePath !== null) { - const projectRefs = yield* loadRefs(projectCwd, "local"); - const projectWorktreePaths = new Set( - projectRefs.flatMap((ref) => - ref.worktreePath === null ? [] : [normalizePath(ref.worktreePath)], - ), - ); - if (!projectWorktreePaths.has(normalizePath(projection.thread.worktreePath))) { + const inventory = yield* loadWorktrees(projectCwd); + const projectWorktreePaths = new Set(inventory.worktrees.map((worktree) => worktree.path)); + if (!projectWorktreePaths.has(sourceCwd)) { return yield* failure( "scope_mismatch", `Thread worktree '${projection.thread.worktreePath}' is not registered in project '${projection.thread.projectId}'.`, @@ -631,31 +699,30 @@ const make = Effect.gen(function* () { yield* requireCapability(scope); const projection = yield* loadThread(scope); const project = yield* loadProject(scope, projection.thread.projectId); - const projectWorkspaceRoot = normalizePath(project.workspaceRoot); + const projectWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot); const workspacePath = normalizePath(projection.thread.worktreePath ?? projectWorkspaceRoot); - const [defaultStartFromOrigin, actual, refs] = yield* Effect.all( - [ - readDefaultStartFromOrigin, - readWorkspaceStatus(workspacePath), - loadRefs(projectWorkspaceRoot, "local"), - ], - { concurrency: 3 }, - ); - const knownWorkspacePaths = new Set([ - projectWorkspaceRoot, - ...refs.flatMap((ref) => - ref.isRemote === true || ref.worktreePath === null - ? [] - : [normalizePath(ref.worktreePath)], - ), - ]); - const agreement = !knownWorkspacePaths.has(workspacePath) - ? "workspace_missing" - : !actual.isRepo - ? "not_repository" - : actual.refName !== projection.thread.branch - ? "branch_mismatch" - : "in_sync"; + const [defaultStartFromOrigin, actual, projectInventory, workspaceInventory] = + yield* Effect.all( + [ + readDefaultStartFromOrigin, + readWorkspaceStatus(workspacePath), + loadWorktrees(projectWorkspaceRoot), + loadWorktrees(workspacePath), + ], + { concurrency: 4 }, + ); + const canonicalWorkspacePath = yield* canonicalizePath(workspacePath); + const physicalWorkspacePath = workspaceInventory.currentWorktreeRoot; + const agreement = + workspaceInventory.repositoryCommonDir !== projectInventory.repositoryCommonDir || + physicalWorkspacePath === null || + !projectInventory.worktrees.some((worktree) => worktree.path === physicalWorkspacePath) + ? "workspace_missing" + : !actual.isRepo + ? "not_repository" + : actual.refName !== projection.thread.branch + ? "branch_mismatch" + : "in_sync"; const result: WorktreeMcpStatusResult = { attached: projection.thread.worktreePath !== null, @@ -668,7 +735,7 @@ const make = Effect.gen(function* () { worktreePath: projection.thread.worktreePath, }, actualWorkspace: { - workspacePath, + workspacePath: physicalWorkspacePath ?? canonicalWorkspacePath, isRepo: actual.isRepo, branch: actual.refName, hasWorkingTreeChanges: actual.hasWorkingTreeChanges, @@ -874,12 +941,12 @@ const make = Effect.gen(function* () { } const project = yield* loadProject(scope, projection.thread.projectId); - const projectWorkspaceRoot = normalizePath(project.workspaceRoot); - const currentWorkspacePath = normalizePath( + const projectWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot); + const recordedWorkspacePath = yield* canonicalizePath( projection.thread.worktreePath ?? projectWorkspaceRoot, ); if (input.target.type === "new_worktree") { - const previousActual = yield* readWorkspaceStatus(currentWorkspacePath); + const previousActual = yield* readWorkspaceStatus(recordedWorkspacePath); const handoff = yield* performHandoff( scope, { @@ -898,10 +965,9 @@ const make = Effect.gen(function* () { }, projection, ); - const actual = yield* readWorkspaceStatus(handoff.worktreePath); return { previous: { - workspacePath: currentWorkspacePath, + workspacePath: recordedWorkspacePath, recordedBranch: projection.thread.branch, recordedWorktreePath: projection.thread.worktreePath, actualBranch: previousActual.refName, @@ -910,17 +976,35 @@ const make = Effect.gen(function* () { workspacePath: handoff.worktreePath, recordedBranch: handoff.branch, recordedWorktreePath: handoff.worktreePath, - actualBranch: actual.refName, + actualBranch: handoff.branch, }, checkoutAction: "created", workspaceChanged: true, - branchChanged: previousActual.refName !== actual.refName, + branchChanged: previousActual.refName !== handoff.branch, continuation: handoff.continuation, setupScript: handoff.setupScript, callerTurnEnds: true, note: handoff.note, } satisfies WorktreeMcpCheckoutResult; } + const [inventory, currentInventory] = yield* Effect.all( + [loadWorktrees(projectWorkspaceRoot), loadWorktrees(recordedWorkspacePath)], + { concurrency: 2 }, + ); + if (inventory.repositoryCommonDir !== currentInventory.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 = currentInventory.currentWorktreeRoot; + if (projectWorktreeRoot === null || currentWorkspacePath === null) { + return yield* failure( + "invalid_request", + "Git could not resolve the physical project or thread checkout.", + ); + } const [refs, threads, previousActual] = yield* Effect.all( [ loadRefs(projectWorkspaceRoot), @@ -931,12 +1015,7 @@ const make = Effect.gen(function* () { ); const localRefs = refs.filter((ref) => ref.isRemote !== true); - const workspacePaths = new Set([ - projectWorkspaceRoot, - ...localRefs.flatMap((ref) => - ref.worktreePath === null ? [] : [normalizePath(ref.worktreePath)], - ), - ]); + 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]), @@ -949,14 +1028,17 @@ const make = Effect.gen(function* () { switch (input.target.type) { case "worktree": { - targetWorkspacePath = normalizePath(input.target.path); + const targetInventory = yield* loadWorktrees(input.target.path); + targetWorkspacePath = + targetInventory.currentWorktreeRoot ?? (yield* canonicalizePath(input.target.path)); if ( - targetWorkspacePath === projectWorkspaceRoot || + targetInventory.repositoryCommonDir !== inventory.repositoryCommonDir || + targetWorkspacePath === projectWorktreeRoot || !workspacePaths.has(targetWorkspacePath) ) { return yield* failure( "scope_mismatch", - targetWorkspacePath === projectWorkspaceRoot + 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.`, ); @@ -964,7 +1046,7 @@ const make = Effect.gen(function* () { break; } case "project_root": { - targetWorkspacePath = projectWorkspaceRoot; + targetWorkspacePath = projectWorktreeRoot; requestedBranch = input.target.branch; createBranch = input.target.create ?? false; if (createBranch && requestedBranch === undefined) { @@ -995,15 +1077,15 @@ const make = Effect.gen(function* () { const selectedWorktreePath = selectedRef?.isRemote === true || selectedRef?.worktreePath == null ? null - : normalizePath(selectedRef.worktreePath); + : selectedRef.worktreePath; targetWorkspacePath = workspace === "project_root" - ? projectWorkspaceRoot + ? projectWorktreeRoot : workspace === "current" ? currentWorkspacePath : (selectedWorktreePath ?? (projection.thread.worktreePath !== null && selectedRef?.isDefault === true - ? projectWorkspaceRoot + ? projectWorktreeRoot : currentWorkspacePath)); break; } @@ -1038,7 +1120,7 @@ const make = Effect.gen(function* () { const selectedWorktreePath = selectedRef?.isRemote === true || selectedRef?.worktreePath == null ? null - : normalizePath(selectedRef.worktreePath); + : selectedRef.worktreePath; if ( !createBranch && requestedBranch !== undefined && @@ -1053,16 +1135,18 @@ const make = Effect.gen(function* () { const shouldMutateCheckout = requestedBranch !== undefined && - (createBranch || - (selectedRef?.isRemote === true - ? targetBefore.refName !== requestedBranch && - targetBefore.refName !== requestedBranch.replace(/^[^/]+\//, "") - : targetBefore.refName !== requestedBranch)); - const otherBindings = threads.filter( - (thread) => - thread.id !== scope.threadId && - threadWorkspacePath(thread, projectWorkspaceRoot) === targetWorkspacePath, + (createBranch || selectedRef?.isRemote === true || targetBefore.refName !== requestedBranch); + const threadWorkspaces = yield* Effect.forEach(threads, (thread) => + threadWorkspacePath(thread, projectWorktreeRoot).pipe( + Effect.map((workspacePath) => [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( @@ -1071,7 +1155,7 @@ const make = Effect.gen(function* () { ); } if ( - targetWorkspacePath !== projectWorkspaceRoot && + targetWorkspacePath !== projectWorktreeRoot && (targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) && otherBindings.length > 0 ) { @@ -1081,7 +1165,7 @@ const make = Effect.gen(function* () { ); } if ( - targetWorkspacePath === projectWorkspaceRoot && + targetWorkspacePath === projectWorktreeRoot && shouldMutateCheckout && otherBindings.length > 0 ) { @@ -1098,285 +1182,463 @@ const make = Effect.gen(function* () { } const ids = yield* transitionIds(scope, "checkout"); + const workspaceGuardKey = `workspace:${inventory.repositoryCommonDir}:${targetWorkspacePath}`; return yield* Effect.uninterruptibleMask(() => - Effect.gen(function* () { - let checkoutAction: WorktreeMcpCheckoutResult["checkoutAction"] = - targetWorkspacePath === currentWorkspacePath ? "unchanged" : "reused"; - let createdBranch: string | null = null; - - if (shouldMutateCheckout && requestedBranch !== undefined) { - if (createBranch) { - yield* gitWorkflow - .createRef({ - cwd: targetWorkspacePath, - refName: requestedBranch, - switchRef: false, - }) - .pipe(asOperationFailed(`Unable to create branch '${requestedBranch}'`)); - createdBranch = requestedBranch; + 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* 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 switchExit = yield* Effect.exit( - gitWorkflow.switchRef({ cwd: targetWorkspacePath, refName: requestedBranch }), + const latestOtherBindings = latestBindings + .filter( + ([thread, workspacePath]) => + thread.id !== scope.threadId && workspacePath === targetWorkspacePath, + ) + .map(([thread]) => thread); + const latestActiveBinding = latestOtherBindings.find( + (thread) => thread.activeRunId !== null, ); - if (Exit.isFailure(switchExit)) { - const afterFailedSwitchExit = yield* Effect.exit( - readWorkspaceStatus(targetWorkspacePath), + 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 createdBranch: string | null = null; + let resolvedBranch: string | null = targetBefore.refName; + + if (shouldMutateCheckout && requestedBranch !== undefined) { + if (createBranch) { + yield* gitWorkflow + .createRef({ + cwd: targetWorkspacePath, + refName: requestedBranch, + switchRef: false, + }) + .pipe(asOperationFailed(`Unable to create branch '${requestedBranch}'`)); + createdBranch = requestedBranch; + } + const switchExit = yield* Effect.exit( + gitWorkflow.switchRef({ cwd: targetWorkspacePath, refName: requestedBranch }), ); - if (Exit.isFailure(afterFailedSwitchExit)) { + if (Exit.isFailure(switchExit)) { + const afterFailedSwitchExit = yield* Effect.exit( + readWorkspaceStatus(targetWorkspacePath), + ); + if (Exit.isFailure(afterFailedSwitchExit)) { + return yield* failure( + "partial_failure", + `Branch checkout failed and the resulting Git state could not be verified: ${errorMessage(Cause.squash(switchExit.cause))}`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch: null, + rollback: "not_possible", + }, + ); + } + const afterFailedSwitch = afterFailedSwitchExit.value; + const checkoutChanged = afterFailedSwitch.refName !== targetBefore.refName; + if (checkoutChanged && targetBefore.refName === null) { + return yield* failure( + "partial_failure", + `Branch checkout failed after changing Git state and the previous detached ref cannot be restored automatically: ${errorMessage(Cause.squash(switchExit.cause))}`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch: afterFailedSwitch.refName, + rollback: "not_possible", + }, + ); + } + const rollback = checkoutChanged + ? gitWorkflow.switchRef({ + cwd: targetWorkspacePath, + refName: targetBefore.refName!, + }) + : Effect.void; + const cleanup = rollback.pipe( + Effect.andThen( + createdBranch === null + ? Effect.void + : gitWorkflow.deleteLocalBranch({ + cwd: targetWorkspacePath, + refName: createdBranch, + force: true, + }), + ), + ); + const cleanupExit = yield* Effect.exit(cleanup); + if (Exit.isFailure(cleanupExit)) { + const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath); + return yield* failure( + "partial_failure", + `Branch checkout failed and rollback also failed: ${errorMessage(Cause.squash(switchExit.cause))}`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch, + rollback: "failed", + }, + ); + } return yield* failure( - "partial_failure", - `Branch checkout failed and the resulting Git state could not be verified: ${errorMessage(Cause.squash(switchExit.cause))}`, - { - workspacePath: targetWorkspacePath, - recordedBranch: projection.thread.branch, - actualBranch: null, - rollback: "not_possible", - }, + "operation_failed", + `Unable to check out '${requestedBranch}': ${errorMessage(Cause.squash(switchExit.cause))}`, ); } - const afterFailedSwitch = afterFailedSwitchExit.value; - const checkoutChanged = afterFailedSwitch.refName !== targetBefore.refName; - if (checkoutChanged && targetBefore.refName === null) { + resolvedBranch = switchExit.value.refName; + if (resolvedBranch === null) { return yield* failure( "partial_failure", - `Branch checkout failed after changing Git state and the previous detached ref cannot be restored automatically: ${errorMessage(Cause.squash(switchExit.cause))}`, + `Git reported a detached checkout after selecting '${requestedBranch}'. The durable thread binding was not changed.`, { workspacePath: targetWorkspacePath, recordedBranch: projection.thread.branch, - actualBranch: afterFailedSwitch.refName, + actualBranch: null, rollback: "not_possible", }, ); } - const rollback = checkoutChanged - ? gitWorkflow.switchRef({ - cwd: targetWorkspacePath, - refName: targetBefore.refName!, - }) - : Effect.void; - const cleanup = rollback.pipe( - Effect.andThen( - createdBranch === null - ? Effect.void - : gitWorkflow.deleteLocalBranch({ - cwd: targetWorkspacePath, - refName: createdBranch, - force: true, - }), - ), - ); - const cleanupExit = yield* Effect.exit(cleanup); - if (Exit.isFailure(cleanupExit)) { - const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath); - return yield* failure( - "partial_failure", - `Branch checkout failed and rollback also failed: ${errorMessage(Cause.squash(switchExit.cause))}`, - { - workspacePath: targetWorkspacePath, - recordedBranch: projection.thread.branch, - actualBranch, - rollback: "failed", - }, + 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") { + if (targetBefore.refName === null) { + return yield* failure( + "partial_failure", + `Git checkout completed but its resulting state could not be verified: ${detail}`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch: null, + rollback: "not_possible", + }, + ); + } + const rollbackExit = yield* Effect.exit( + gitWorkflow + .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName }) + .pipe( + Effect.andThen( + createdBranch === null + ? Effect.void + : gitWorkflow.deleteLocalBranch({ + cwd: targetWorkspacePath, + refName: createdBranch, + force: true, + }), + ), + ), ); + if (Exit.isFailure(rollbackExit)) { + const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath); + return yield* failure( + "partial_failure", + `Git checkout completed, verification failed, and rollback also failed: ${detail}`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch, + rollback: "failed", + }, + ); + } } return yield* failure( "operation_failed", - `Unable to check out '${requestedBranch}': ${errorMessage(Cause.squash(switchExit.cause))}`, + `Unable to verify the selected checkout '${targetWorkspacePath}': ${detail}`, ); } - 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") { - if (targetBefore.refName === null) { - return yield* failure( - "partial_failure", - `Git checkout completed but its resulting state could not be verified: ${detail}`, - { - workspacePath: targetWorkspacePath, - recordedBranch: projection.thread.branch, - actualBranch: null, - rollback: "not_possible", - }, + 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", + }, + ); + } + 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; + + const rollbackOwnedCheckout = Effect.fn("WorktreeMcpService.rollbackOwnedCheckout")( + function* () { + if (checkoutAction !== "switched" && checkoutAction !== "created") { + return "not_needed" as const; + } + if (targetBefore.refName === null) { + return "not_possible" as const; + } + const [latestStatus, latestBindings, callerProjectionExit] = yield* Effect.all( + [ + readWorkspaceStatus(targetWorkspacePath), + loadActiveWorkspaceBindings(inventory.repositoryCommonDir), + Effect.exit(threadManagement.getThreadProjection(scope.threadId)), + ], + { concurrency: 3 }, ); - } - const rollbackExit = yield* Effect.exit( - gitWorkflow - .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName }) - .pipe( - Effect.andThen( - createdBranch === null - ? Effect.void - : gitWorkflow.deleteLocalBranch({ - cwd: targetWorkspacePath, - refName: createdBranch, - force: true, - }), + 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; + if (latestStatus.refName !== actual.refName || anotherOwner || !callerStillInitial) { + return "not_possible" as const; + } + const rollbackExit = yield* Effect.exit( + gitWorkflow + .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName }) + .pipe( + Effect.andThen( + createdBranch === null + ? Effect.void + : gitWorkflow.deleteLocalBranch({ + cwd: targetWorkspacePath, + refName: createdBranch, + force: true, + }), + ), ), - ), - ); - if (Exit.isFailure(rollbackExit)) { - const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath); - return yield* failure( - "partial_failure", - `Git checkout completed, verification failed, and rollback also failed: ${detail}`, - { - workspacePath: targetWorkspacePath, - recordedBranch: projection.thread.branch, - actualBranch, - rollback: "failed", - }, ); - } - } - return yield* failure( - "operation_failed", - `Unable to verify the selected checkout '${targetWorkspacePath}': ${detail}`, + return Exit.isSuccess(rollbackExit) ? ("rolled_back" as const) : ("failed" as const); + }, ); - } - const actual = actualExit.value; - const nextBranch = actual.refName; - const workspaceChanged = targetWorkspacePath !== currentWorkspacePath; - const nextWorktreePath = workspaceChanged - ? targetWorkspacePath === projectWorkspaceRoot - ? null - : targetWorkspacePath - : projection.thread.worktreePath; - const bindingChanged = - nextBranch !== projection.thread.branch || - nextWorktreePath !== projection.thread.worktreePath; - - if (bindingChanged) { - 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, - }), - ); - if (Exit.isFailure(dispatchExit)) { - const dispatchDetail = errorMessage(Cause.squash(dispatchExit.cause)); - const bindingAfterDispatchExit = yield* Effect.exit(loadThread(scope)); - if (Exit.isFailure(bindingAfterDispatchExit)) { + + 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( - "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", - }, + "checkout_in_progress", + `Thread '${scope.threadId}' changed or disappeared before the workspace binding committed. Git state was left unchanged.`, ); } - 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 { - if (checkoutAction === "switched" || checkoutAction === "created") { - if (targetBefore.refName === null) { - return yield* failure( - "partial_failure", - `Git checkout completed but the durable thread binding failed: ${dispatchDetail}`, - { - workspacePath: targetWorkspacePath, - recordedBranch: projection.thread.branch, - actualBranch: actual.refName, - rollback: "not_possible", - }, - ); - } - const rollbackExit = yield* Effect.exit( - gitWorkflow - .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName }) - .pipe( - Effect.andThen( - createdBranch === null - ? Effect.void - : gitWorkflow.deleteLocalBranch({ - cwd: targetWorkspacePath, - refName: createdBranch, - force: true, - }), - ), - ), + 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, + }), + ); + 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, + }, ); - if (Exit.isFailure(rollbackExit)) { - const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath); + } 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, the durable binding failed, and rollback also failed: ${dispatchDetail}`, + `Git checkout completed but the durable binding failed, and rollback was ${rollback === "failed" ? "unsuccessful" : "unsafe"}: ${dispatchDetail}`, { workspacePath: targetWorkspacePath, - recordedBranch: projection.thread.branch, - actualBranch, - rollback: "failed", + 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}`, + ); } - 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, - 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; + 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, + 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)), + ), + ); }), ); }); @@ -1410,6 +1672,7 @@ export const layer: Layer.Layer< WorktreeMcpService, never, | Crypto.Crypto + | FileSystem.FileSystem | Path.Path | ThreadManagementService | ProjectService.ProjectService 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/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", () => { From ead4c69c3e68554a9ac5ffd091e65307a7b3a838 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 15:44:08 -0700 Subject: [PATCH 03/16] docs(mcp): clarify checkout rollback cleanup --- docs/orchestration-v2/orchestrator-mcp-server.md | 5 +++-- docs/user/source-control.md | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index a3a6a74f7f94..eead57483272 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -353,8 +353,9 @@ can interrupt the MCP call. The next provider session derives its working direct thread projection. 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 the source or target -worktree. +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 diff --git a/docs/user/source-control.md b/docs/user/source-control.md index d898a36cbc70..5f2c28f5e3ab 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -80,7 +80,8 @@ switch a shared root while another thread is bound there. Moving between workspace paths restarts the agent session in the selected checkout. The agent can queue a continuation before that restart, so longer work resumes without needing the browser to stay open. These controls apply only to the calling thread and its current project. They do not -remove, prune, or revive worktrees. +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 From a60cf2b286765c4528c684af5c27737fd7f1aff2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:03:50 -0700 Subject: [PATCH 04/16] fix(mcp): guard checkout transitions atomically --- apps/server/src/git/GitWorkflowService.ts | 8 + .../server/src/mcp/WorktreeMcpService.test.ts | 379 ++++++++++++++++-- apps/server/src/mcp/WorktreeMcpService.ts | 342 ++++++++-------- .../src/orchestration-v2/Orchestrator.ts | 11 + .../src/orchestration-v2/runtimeLayer.test.ts | 21 + apps/server/src/vcs/GitVcsDriver.ts | 1 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 30 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 26 ++ packages/contracts/src/orchestrationV2.ts | 2 + 9 files changed, 622 insertions(+), 198 deletions(-) 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 9e933bd6259f..22227adb7bc5 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -15,12 +15,14 @@ import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; 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 Schema from "effect/Schema"; +import * as GitManager from "../git/GitManager.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import { OrchestratorDispatchError, @@ -34,6 +36,8 @@ import { import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; import type * as McpInvocationContext from "./McpInvocationContext.ts"; import { layer as worktreeMcpServiceLayer, WorktreeMcpService } from "./WorktreeMcpService.ts"; @@ -117,6 +121,7 @@ interface HarnessOptions { readonly createWorktreeFails?: boolean; readonly fetchRemoteFails?: boolean; readonly resolveRemoteFails?: boolean; + readonly resolvedCommits?: ReadonlyArray; readonly removeWorktreeFails?: boolean; readonly deleteLocalBranchFails?: boolean; readonly createWorktreeGate?: Effect.Effect; @@ -133,8 +138,14 @@ interface HarnessOptions { }>; readonly projectWorktreeRoot?: string; readonly workspaceAliases?: Readonly>; - readonly workspaceStatuses?: Readonly>; + readonly projectWorkspaceRoot?: string; + readonly useRealNonRepositoryWorkflow?: boolean; + readonly workspaceStatuses?: Readonly< + Record + >; + readonly worktreeInventoryFailsFor?: ReadonlySet; readonly localStatusFailsOnCall?: number; + readonly dirtyOnLocalStatusCall?: number; readonly projectThreads?: ReadonlyArray<{ readonly id: ThreadId; readonly title: string; @@ -152,6 +163,12 @@ interface HarnessOptions { 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 switchRefRollbackFails?: boolean; @@ -289,12 +306,16 @@ const makeHarness = (options: HarnessOptions = {}) => { return Effect.succeed({ delivery: "queued" } as ThreadManagementSendResult); } }); + const configuredProject = { + ...project, + workspaceRoot: options.projectWorkspaceRoot ?? project.workspaceRoot, + }; const getById = vi.fn((id: ProjectId) => options.projectReadFails ? (Effect.fail("simulated project read failure") as never) : Effect.succeed( id === projectId && options.projectMissing !== true - ? Option.some(project) + ? Option.some(configuredProject) : id === options.otherProjectThread?.projectId ? Option.some({ ...project, @@ -338,6 +359,22 @@ const makeHarness = (options: HarnessOptions = {}) => { lineage: { relationshipToParent: "none" }, } as unknown as OrchestrationV2ThreadShell); } + const archivedThreadShells = + options.archivedProjectThread === undefined + ? [] + : [ + { + ...(projectThreadShells[0] ?? makeProjection({}).thread), + id: options.archivedProjectThread.id, + projectId, + title: options.archivedProjectThread.title, + branch: options.archivedProjectThread.branch, + worktreePath: options.archivedProjectThread.worktreePath, + activeRunId: null, + archivedAt: "2026-01-02T00:00:00.000Z", + lineage: { relationshipToParent: "none" }, + } as unknown as OrchestrationV2ThreadShell, + ]; const listProjectThreads = vi.fn((input: { readonly projectId: ProjectId }) => Effect.succeed(projectThreadShells.filter((item) => item.projectId === input.projectId)), ); @@ -346,7 +383,7 @@ const makeHarness = (options: HarnessOptions = {}) => { schemaVersion: 1, snapshotSequence: 1, threads: projectThreadShells, - archivedThreads: [], + archivedThreads: archivedThreadShells, } as never), ); const removeWorktree = vi.fn((_: unknown) => @@ -367,6 +404,15 @@ 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 Effect.succeed({ commitSha }); + }); const workspaceStatuses = new Map( Object.entries( options.workspaceStatuses ?? { @@ -439,14 +485,16 @@ const makeHarness = (options: HarnessOptions = {}) => { ...configuredWorktrees, ]; const listWorktrees = vi.fn((cwd: string) => - Effect.succeed({ - repositoryCommonDir: "/repo/.git", - currentWorktreeRoot: - options.workspaceAliases?.[cwd] ?? - listedWorktrees.find((worktree) => worktree.path === cwd)?.path ?? - (cwd === workspaceRoot ? projectWorktreeRoot : cwd), - worktrees: listedWorktrees, - }), + options.worktreeInventoryFailsFor?.has(cwd) === true + ? (Effect.fail("simulated worktree inventory failure") as never) + : Effect.succeed({ + repositoryCommonDir: "/repo/.git", + currentWorktreeRoot: + options.workspaceAliases?.[cwd] ?? + listedWorktrees.find((worktree) => worktree.path === cwd)?.path ?? + (cwd === workspaceRoot ? projectWorktreeRoot : cwd), + worktrees: listedWorktrees, + }), ); let localStatusCallCount = 0; const localStatus = vi.fn((input: { readonly cwd: string }) => { @@ -456,7 +504,7 @@ const makeHarness = (options: HarnessOptions = {}) => { } const current = workspaceStatuses.get(input.cwd); return Effect.succeed({ - isRepo: options.notARepo !== true, + isRepo: current?.isRepo ?? options.notARepo !== true, hasPrimaryRemote: true, isDefaultRef: false, refName: @@ -465,7 +513,8 @@ 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 }, }); }); @@ -551,6 +600,40 @@ const makeHarness = (options: HarnessOptions = {}) => { } as unknown as Path.Path), ), ); + const gitWorkflowLayer = options.useRealNonRepositoryWorkflow + ? GitWorkflowService.layer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + detect: () => Effect.succeed(null), + resolve: () => Effect.fail("not a repository") as never, + }), + ), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide( + Layer.mock(GitManager.GitManager)({ + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + resolvePullRequest: () => Effect.die("unexpected resolvePullRequest"), + preparePullRequestThread: () => Effect.die("unexpected preparePullRequestThread"), + }), + ), + ) + : Layer.mock(GitWorkflowService.GitWorkflowService)({ + listRefs, + listWorktrees, + listLocalBranchNames, + localStatus, + invalidateLocalStatus, + fetchRemote, + resolveRemoteTrackingCommit, + resolveCommit, + createWorktree, + removeWorktree, + deleteLocalBranch, + switchRef, + createRef, + } satisfies Partial); const layer = serviceLayer.pipe( Layer.provide( Layer.mergeAll( @@ -567,20 +650,7 @@ const makeHarness = (options: HarnessOptions = {}) => { ServerSettings.layerTest({ newWorktreesStartFromOrigin: options.newWorktreesStartFromOrigin ?? false, }), - Layer.mock(GitWorkflowService.GitWorkflowService)({ - listRefs, - listWorktrees, - listLocalBranchNames, - localStatus, - fetchRemote, - resolveRemoteTrackingCommit, - createWorktree, - removeWorktree, - deleteLocalBranch, - switchRef, - createRef, - invalidateLocalStatus, - } satisfies Partial), + gitWorkflowLayer, Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ runForThread, } satisfies Partial), @@ -599,6 +669,7 @@ const makeHarness = (options: HarnessOptions = {}) => { sendToThread, fetchRemote, resolveRemoteTrackingCommit, + resolveCommit, createWorktree, removeWorktree, deleteLocalBranch, @@ -850,6 +921,7 @@ describe("t3_worktree_handoff", () => { worktreePath: "/worktrees/project/feature/second", expectedBranch: "feature/existing", expectedWorktreePath: "/worktrees/project/existing", + expectedArchived: false, }); }); }); @@ -1310,6 +1382,34 @@ describe("t3_worktree_handoff", () => { }); describe("t3_worktree_status", () => { + it.effect("reports a plain project directory as not a repository", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const plainDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-worktree-status-non-repo-", + }); + const canonicalPlainDirectory = yield* fileSystem.realPath(plainDirectory); + const harness = makeHarness({ + projectWorkspaceRoot: canonicalPlainDirectory, + useRealNonRepositoryWorkflow: true, + }); + + const result = yield* runStatus(harness); + + expect(result).toMatchObject({ + attached: false, + projectWorkspaceRoot: canonicalPlainDirectory, + actualWorkspace: { + workspacePath: canonicalPlainDirectory, + branch: null, + isRepo: false, + hasWorkingTreeChanges: false, + }, + agreement: "not_repository", + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("reports an unattached thread", () => { const harness = makeHarness({ newWorktreesStartFromOrigin: true }); return Effect.gen(function* () { @@ -1377,6 +1477,32 @@ describe("t3_worktree_status", () => { }); }); + it.effect("reports a missing saved worktree even when inventory discovery fails", () => { + const missingPath = "/worktrees/project/deleted"; + const harness = makeHarness({ + thread: { worktreePath: missingPath, branch: "feature/deleted" }, + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [missingPath]: { branch: null, isRepo: false }, + }, + worktreeInventoryFailsFor: new Set([missingPath]), + }); + return Effect.gen(function* () { + const result = yield* runStatus(harness); + expect(result).toMatchObject({ + attached: true, + worktreePath: missingPath, + branch: "feature/deleted", + actualWorkspace: { + workspacePath: missingPath, + branch: null, + isRepo: false, + }, + agreement: "workspace_missing", + }); + }); + }); + it.effect("reports a recorded workspace that is not a Git repository", () => { const harness = makeHarness({ thread: { branch: "dev", worktreePath: null }, @@ -1573,6 +1699,32 @@ describe("t3_worktree_list", () => { }); }); + it.effect("marks a stale checkout missing when status reports a non-repository path", () => { + const stalePath = "/worktrees/project/stale"; + const harness = makeHarness({ + worktrees: [ + { path: workspaceRoot, refName: "dev" }, + { path: stalePath, refName: "feature/stale" }, + ], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [stalePath]: { branch: null, isRepo: false }, + }, + }); + return Effect.gen(function* () { + const result = yield* runList(harness); + expect(result.worktrees).toContainEqual( + expect.objectContaining({ + path: stalePath, + availability: "missing", + statusError: "Worktree path does not exist.", + actualBranch: null, + isRepo: false, + }), + ); + }); + }); + it.effect("bounds returned bindings while reporting the total", () => { const otherOne = ThreadId.make("thread-binding-one"); const otherTwo = ThreadId.make("thread-binding-two"); @@ -1680,6 +1832,46 @@ describe("t3_thread_checkout", () => { }, ); + it.effect("records a verified detached checkout of an explicit remote ref", () => { + const remoteCommit = "remote-feature-commit"; + 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: null, + resolvedCommits: ["local-feature-commit", remoteCommit, remoteCommit], + }); + return Effect.gen(function* () { + const result = yield* runCheckout(harness, { + target: { type: "branch", branch: "origin/feature" }, + }); + + 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("refuses to bind when Git changes again after resolving the requested ref", () => { const harness = makeHarness({ thread: { branch: "dev", worktreePath: null }, @@ -1711,7 +1903,7 @@ describe("t3_thread_checkout", () => { ["archived", { threadArchivedOnCall: 3 }], ["deleted", { threadDeletedOnCall: 3 }], ] as const) { - it.effect(`rolls Git back when the thread is ${state} before binding`, () => { + it.effect(`preserves Git state when the thread is ${state} before binding`, () => { const harness = makeHarness({ thread: { branch: "dev", worktreePath: null }, refs: rootRefs, @@ -1726,12 +1918,10 @@ describe("t3_thread_checkout", () => { ); expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", - code: "checkout_in_progress", - }); - expect(harness.switchRef).toHaveBeenNthCalledWith(2, { - cwd: workspaceRoot, - refName: "dev", + 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(); }); @@ -1765,6 +1955,30 @@ describe("t3_thread_checkout", () => { }); }); + it.effect("compare-and-deletes an owned created branch during rollback", () => { + 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.deleteLocalBranch).toHaveBeenCalledWith({ + cwd: workspaceRoot, + refName: "feature/created-rollback", + force: true, + expectedCommitSha: "commit-test", + }); + }); + }); + it.effect("reuses an existing worktree and queues continuation after binding", () => { const worktreePath = "/worktrees/project/feature-checkout"; const harness = makeHarness({ @@ -2235,6 +2449,66 @@ describe("t3_thread_checkout", () => { }); }); + 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" } }, + 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("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 }, @@ -2418,6 +2692,34 @@ describe("t3_thread_checkout", () => { }); }); + for (const [change, options] of [ + ["a new commit", { resolvedCommits: ["before", "selected", "intervening"] }], + ["new dirty files", { dirtyOnLocalStatusCall: 4 }], + ] 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 }, @@ -2440,7 +2742,7 @@ describe("t3_thread_checkout", () => { }); }); - it.effect("rolls back when the switched branch cannot be verified", () => { + it.effect("preserves the switched branch when its resulting state cannot be verified", () => { const harness = makeHarness({ thread: { branch: "dev", worktreePath: null }, refs: rootRefs, @@ -2453,11 +2755,12 @@ describe("t3_thread_checkout", () => { target: { type: "branch", branch: "feature/checkout" }, }), ); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); - expect(harness.switchRef).toHaveBeenNthCalledWith(2, { - cwd: workspaceRoot, - refName: "dev", + expectTypedFailure(exit, { + _tag: "WorktreeMcpFailure", + code: "partial_failure", + partial: { actualBranch: null, rollback: "not_possible" }, }); + expect(harness.switchRef).toHaveBeenCalledTimes(1); expect(harness.dispatch).not.toHaveBeenCalled(); }); }); diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 9d31eec90cb4..c0ae16a17c1f 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -217,30 +217,27 @@ const make = Effect.gen(function* () { Option.match({ onNone: () => Effect.succeed([]), onSome: (project) => - loadWorktrees(project.workspaceRoot).pipe( - Effect.flatMap((projectInventory) => { - if (projectInventory.repositoryCommonDir !== repositoryCommonDir) { - return Effect.succeed([]); - } - return Effect.forEach(projectThreads, (thread) => { - if (thread.worktreePath === null) { - return Effect.succeed( - projectInventory.currentWorktreeRoot === null - ? [] - : [[thread, projectInventory.currentWorktreeRoot] as const], - ); + Effect.gen(function* () { + const projectInventory = yield* Effect.option( + loadWorktrees(project.workspaceRoot), + ); + return yield* Effect.forEach(projectThreads, (thread) => + Effect.gen(function* () { + const inventory = + thread.worktreePath === null + ? projectInventory + : yield* Effect.option(loadWorktrees(thread.worktreePath)); + if ( + Option.isNone(inventory) || + inventory.value.repositoryCommonDir !== repositoryCommonDir || + inventory.value.currentWorktreeRoot === null + ) { + return []; } - return loadWorktrees(thread.worktreePath).pipe( - Effect.map((threadInventory) => - threadInventory.repositoryCommonDir === repositoryCommonDir && - threadInventory.currentWorktreeRoot !== null - ? [[thread, threadInventory.currentWorktreeRoot] as const] - : [], - ), - ); - }).pipe(Effect.map((bindings) => bindings.flat())); - }), - ), + return [[thread, inventory.value.currentWorktreeRoot] as const]; + }), + ).pipe(Effect.map((bindings) => bindings.flat())); + }), }), ), ), @@ -541,6 +538,7 @@ const make = Effect.gen(function* () { worktreePath, expectedBranch: projection.thread.branch, expectedWorktreePath: projection.thread.worktreePath, + expectedArchived: false, }), ); if (Exit.isFailure(dispatchExit)) { @@ -1281,7 +1279,106 @@ const make = Effect.gen(function* () { let checkoutAction: WorktreeMcpCheckoutResult["checkoutAction"] = targetWorkspacePath === currentWorkspacePath ? "unchanged" : "reused"; let createdBranch: string | null = null; + let createdBranchCommit: string | null = null; let resolvedBranch: string | null = targetBefore.refName; + const targetBeforeCommit = shouldMutateCheckout + ? yield* gitWorkflow + .resolveCommit({ cwd: targetWorkspacePath, revision: "HEAD" }) + .pipe(asOperationFailed("Unable to record the checkout's current commit")) + : null; + const requestedRemoteCommit = + shouldMutateCheckout && requestedBranch !== undefined && selectedRef?.isRemote === true + ? yield* gitWorkflow + .resolveCommit({ cwd: targetWorkspacePath, revision: requestedBranch }) + .pipe(asOperationFailed(`Unable to resolve remote 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 + ).pipe( + Effect.andThen( + createdBranch === null || createdBranchCommit === null + ? Effect.void + : gitWorkflow.deleteLocalBranch({ + cwd: targetWorkspacePath, + refName: createdBranch, + force: true, + expectedCommitSha: createdBranchCommit, + }), + ), + ), + ); + return Exit.isSuccess(rollbackExit) ? ("rolled_back" as const) : ("failed" as const); + }, + ); if (shouldMutateCheckout && requestedBranch !== undefined) { if (createBranch) { @@ -1293,68 +1390,28 @@ const make = Effect.gen(function* () { }) .pipe(asOperationFailed(`Unable to create branch '${requestedBranch}'`)); createdBranch = requestedBranch; + createdBranchCommit = targetBeforeCommit?.commitSha ?? null; } const switchExit = yield* Effect.exit( gitWorkflow.switchRef({ cwd: targetWorkspacePath, refName: requestedBranch }), ); if (Exit.isFailure(switchExit)) { - const afterFailedSwitchExit = yield* Effect.exit( - readWorkspaceStatus(targetWorkspacePath), - ); - if (Exit.isFailure(afterFailedSwitchExit)) { - return yield* failure( - "partial_failure", - `Branch checkout failed and the resulting Git state could not be verified: ${errorMessage(Cause.squash(switchExit.cause))}`, - { - workspacePath: targetWorkspacePath, - recordedBranch: projection.thread.branch, - actualBranch: null, - rollback: "not_possible", - }, - ); - } - const afterFailedSwitch = afterFailedSwitchExit.value; - const checkoutChanged = afterFailedSwitch.refName !== targetBefore.refName; - if (checkoutChanged && targetBefore.refName === null) { - return yield* failure( - "partial_failure", - `Branch checkout failed after changing Git state and the previous detached ref cannot be restored automatically: ${errorMessage(Cause.squash(switchExit.cause))}`, - { - workspacePath: targetWorkspacePath, - recordedBranch: projection.thread.branch, - actualBranch: afterFailedSwitch.refName, - rollback: "not_possible", - }, - ); + const afterFailedSwitchExit = yield* Effect.exit(captureCheckoutState()); + if (Exit.isSuccess(afterFailedSwitchExit)) { + ownedCheckoutState = afterFailedSwitchExit.value; } - const rollback = checkoutChanged - ? gitWorkflow.switchRef({ - cwd: targetWorkspacePath, - refName: targetBefore.refName!, - }) - : Effect.void; - const cleanup = rollback.pipe( - Effect.andThen( - createdBranch === null - ? Effect.void - : gitWorkflow.deleteLocalBranch({ - cwd: targetWorkspacePath, - refName: createdBranch, - force: true, - }), - ), - ); - const cleanupExit = yield* Effect.exit(cleanup); - if (Exit.isFailure(cleanupExit)) { - const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath); + const rollback = yield* rollbackOwnedCheckout(); + if (rollback === "not_possible" || rollback === "failed") { return yield* failure( "partial_failure", - `Branch checkout failed and rollback also failed: ${errorMessage(Cause.squash(switchExit.cause))}`, + `Branch checkout failed and rollback was ${rollback === "failed" ? "unsuccessful" : "unsafe"}: ${errorMessage(Cause.squash(switchExit.cause))}`, { workspacePath: targetWorkspacePath, recordedBranch: projection.thread.branch, - actualBranch, - rollback: "failed", + actualBranch: Exit.isSuccess(afterFailedSwitchExit) + ? afterFailedSwitchExit.value.refName + : null, + rollback: rollback === "failed" ? "failed" : "not_possible", }, ); } @@ -1364,7 +1421,7 @@ const make = Effect.gen(function* () { ); } resolvedBranch = switchExit.value.refName; - if (resolvedBranch === null) { + 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.`, @@ -1383,46 +1440,16 @@ const make = Effect.gen(function* () { if (Exit.isFailure(actualExit)) { const detail = errorMessage(Cause.squash(actualExit.cause)); if (checkoutAction === "switched" || checkoutAction === "created") { - if (targetBefore.refName === null) { - return yield* failure( - "partial_failure", - `Git checkout completed but its resulting state could not be verified: ${detail}`, - { - workspacePath: targetWorkspacePath, - recordedBranch: projection.thread.branch, - actualBranch: null, - rollback: "not_possible", - }, - ); - } - const rollbackExit = yield* Effect.exit( - gitWorkflow - .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName }) - .pipe( - Effect.andThen( - createdBranch === null - ? Effect.void - : gitWorkflow.deleteLocalBranch({ - cwd: targetWorkspacePath, - refName: createdBranch, - force: true, - }), - ), - ), + 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", + }, ); - if (Exit.isFailure(rollbackExit)) { - const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath); - return yield* failure( - "partial_failure", - `Git checkout completed, verification failed, and rollback also failed: ${detail}`, - { - workspacePath: targetWorkspacePath, - recordedBranch: projection.thread.branch, - actualBranch, - rollback: "failed", - }, - ); - } } return yield* failure( "operation_failed", @@ -1445,6 +1472,46 @@ const make = Effect.gen(function* () { }, ); } + 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 ( + resolvedBranch === null && + requestedRemoteCommit !== null && + actualCommitExit.value.commitSha !== requestedRemoteCommit.commitSha + ) { + return yield* failure( + "partial_failure", + `Git detached HEAD while selecting '${requestedBranch}', but HEAD does not match the requested remote commit. The durable binding was not changed.`, + { + workspacePath: targetWorkspacePath, + recordedBranch: projection.thread.branch, + actualBranch: null, + 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 @@ -1456,52 +1523,6 @@ const make = Effect.gen(function* () { nextBranch !== projection.thread.branch || nextWorktreePath !== projection.thread.worktreePath; - const rollbackOwnedCheckout = Effect.fn("WorktreeMcpService.rollbackOwnedCheckout")( - function* () { - if (checkoutAction !== "switched" && checkoutAction !== "created") { - return "not_needed" as const; - } - if (targetBefore.refName === null) { - return "not_possible" as const; - } - const [latestStatus, latestBindings, callerProjectionExit] = yield* Effect.all( - [ - readWorkspaceStatus(targetWorkspacePath), - 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; - if (latestStatus.refName !== actual.refName || anotherOwner || !callerStillInitial) { - return "not_possible" as const; - } - const rollbackExit = yield* Effect.exit( - gitWorkflow - .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName }) - .pipe( - Effect.andThen( - createdBranch === null - ? Effect.void - : gitWorkflow.deleteLocalBranch({ - cwd: targetWorkspacePath, - refName: createdBranch, - force: true, - }), - ), - ), - ); - return Exit.isSuccess(rollbackExit) ? ("rolled_back" as const) : ("failed" as const); - }, - ); - if (bindingChanged) { const preCommitProjectionExit = yield* Effect.exit(loadThread(scope)); const preCommitProjection = Exit.isSuccess(preCommitProjectionExit) @@ -1540,6 +1561,7 @@ const make = Effect.gen(function* () { worktreePath: nextWorktreePath, expectedBranch: projection.thread.branch, expectedWorktreePath: projection.thread.worktreePath, + expectedArchived: false, }), ); if (Exit.isFailure(dispatchExit)) { diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 3a1e06cf06af..e0c4ad0924e7 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1438,6 +1438,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio 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/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 8f65df41e44a..726d77a4042e 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -712,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/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 6942f2fe4b55..5a49cde79f55 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -120,6 +120,7 @@ export interface GitDeleteLocalBranchInput { readonly cwd: string; readonly refName: string; readonly force?: boolean; + readonly expectedCommitSha?: string; } export interface GitPushResult { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 48980d14a7f7..335e426da46f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it, describe } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -1584,6 +1585,35 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("compare-and-deletes a local branch only at the expected commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createRef({ cwd, refName: "feature/owned", switchRef: false }); + const commitSha = yield* git(cwd, ["rev-parse", "feature/owned"]); + + const staleDelete = yield* Effect.exit( + driver.deleteLocalBranch({ + cwd, + refName: "feature/owned", + force: true, + expectedCommitSha: "1111111111111111111111111111111111111111", + }), + ); + assert.isTrue(Exit.isFailure(staleDelete)); + assert.include(yield* driver.listLocalBranchNames(cwd), "feature/owned"); + + yield* driver.deleteLocalBranch({ + cwd, + refName: "feature/owned", + force: true, + expectedCommitSha: commitSha, + }); + assert.notInclude(yield* driver.listLocalBranchNames(cwd), "feature/owned"); + }), + ); + it.effect("removes the same worktree path twice without failing", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 9b599d85e4d9..3a964892fe7d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3166,6 +3166,32 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const deleteLocalBranch: GitVcsDriver.GitVcsDriver["Service"]["deleteLocalBranch"] = Effect.fn( "deleteLocalBranch", )(function* (input) { + if (input.expectedCommitSha !== undefined) { + yield* executeGit( + "GitVcsDriver.deleteLocalBranch", + input.cwd, + ["update-ref", "-d", `refs/heads/${input.refName}`, input.expectedCommitSha], + { + timeoutMs: 10_000, + fallbackErrorDetail: "git branch compare-and-delete failed", + }, + ); + const stillExists = yield* executeGit( + "GitVcsDriver.deleteLocalBranch.verify", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], + { timeoutMs: 5_000, allowNonZeroExit: true }, + ).pipe(Effect.map((result) => result.exitCode === 0)); + if (stillExists) { + return yield* new GitCommandError({ + operation: "GitVcsDriver.deleteLocalBranch", + command: "git update-ref -d", + cwd: input.cwd, + detail: `Local branch '${input.refName}' changed before it could be deleted safely.`, + }); + } + return; + } yield* executeGit( "GitVcsDriver.deleteLocalBranch", input.cwd, diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 4a35e4f7433b..3aa0d4ce5ad6 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2092,6 +2092,8 @@ export const OrchestrationV2Command = Schema.Union([ 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)), }), From 5ffb28b15b0093b088c2516375b90861dbc1673e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:05:32 -0700 Subject: [PATCH 05/16] fix(mcp): guard nested checkout bindings --- apps/server/src/mcp/WorktreeMcpService.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index c0ae16a17c1f..a66499b07882 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -1135,9 +1135,11 @@ const make = Effect.gen(function* () { requestedBranch !== undefined && (createBranch || selectedRef?.isRemote === true || targetBefore.refName !== requestedBranch); const threadWorkspaces = yield* Effect.forEach(threads, (thread) => - threadWorkspacePath(thread, projectWorktreeRoot).pipe( - Effect.map((workspacePath) => [thread, workspacePath] as const), - ), + threadWorkspacePath( + thread, + projectWorktreeRoot, + inventory.worktrees.map((worktree) => worktree.path), + ).pipe(Effect.map((workspacePath) => [thread, workspacePath] as const)), ); const otherBindings = threadWorkspaces .filter( From 179da697cf45019cdbc030726a3111bca79ee569 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:15:08 -0700 Subject: [PATCH 06/16] fix(mcp): preserve unowned checkout state --- .../server/src/mcp/WorktreeMcpService.test.ts | 128 +++++++++++++++-- apps/server/src/mcp/WorktreeMcpService.ts | 129 +++++++++++++----- apps/server/src/vcs/GitVcsDriver.ts | 1 - apps/server/src/vcs/GitVcsDriverCore.test.ts | 30 ---- apps/server/src/vcs/GitVcsDriverCore.ts | 26 ---- 5 files changed, 208 insertions(+), 106 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 22227adb7bc5..f926245cac01 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -107,6 +107,7 @@ interface HarnessOptions { readonly dispatchInterrupts?: boolean; readonly dispatchGate?: Effect.Effect; readonly threadAttachedOnRecheck?: boolean; + readonly threadAttachedOnCall?: number; readonly threadArchivedOnRecheck?: boolean; readonly threadArchivedOnCall?: number; readonly threadDeletedOnCall?: number; @@ -171,6 +172,7 @@ interface HarnessOptions { }; readonly switchRefFails?: boolean; readonly switchRefFailsAfterMutation?: boolean; + readonly switchRefFailureBranch?: string | null; readonly switchRefRollbackFails?: boolean; readonly switchRefGate?: Effect.Effect; readonly switchRefResultBranch?: string | null; @@ -248,6 +250,15 @@ const makeHarness = (options: HarnessOptions = {}) => { ) { 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 && @@ -525,7 +536,13 @@ const makeHarness = (options: HarnessOptions = {}) => { Effect.suspend(() => { switchCallCount += 1; if (options.switchRefFailsAfterMutation === true && switchCallCount === 1) { - workspaceStatuses.set(input.cwd, { branch: input.refName, dirty: false }); + 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 ( @@ -1899,9 +1916,33 @@ describe("t3_thread_checkout", () => { }); }); + 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 exit = yield* Effect.exit( + runCheckout(harness, { + target: { type: "branch", branch: "feature/checkout" }, + }), + ); + + expectTypedFailure(exit, { + _tag: "WorktreeMcpFailure", + code: "checkout_in_progress", + }); + expect(harness.resolveCommit).toHaveBeenCalledTimes(2); + expect(harness.switchRef).not.toHaveBeenCalled(); + expect(harness.dispatch).not.toHaveBeenCalled(); + }); + }); + for (const [state, option] of [ - ["archived", { threadArchivedOnCall: 3 }], - ["deleted", { threadDeletedOnCall: 3 }], + ["archived", { threadArchivedOnCall: 4 }], + ["deleted", { threadDeletedOnCall: 4 }], ] as const) { it.effect(`preserves Git state when the thread is ${state} before binding`, () => { const harness = makeHarness({ @@ -1955,7 +1996,7 @@ describe("t3_thread_checkout", () => { }); }); - it.effect("compare-and-deletes an owned created branch during rollback", () => { + it.effect("retains a created branch when a later binding operation fails", () => { const harness = makeHarness({ thread: { branch: "dev", worktreePath: null }, refs: rootRefs, @@ -1970,12 +2011,11 @@ describe("t3_thread_checkout", () => { ); expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); - expect(harness.deleteLocalBranch).toHaveBeenCalledWith({ + expect(harness.switchRef).toHaveBeenNthCalledWith(2, { cwd: workspaceRoot, - refName: "feature/created-rollback", - force: true, - expectedCommitSha: "commit-test", + refName: "dev", }); + expect(harness.deleteLocalBranch).not.toHaveBeenCalled(); }); }); @@ -2455,7 +2495,10 @@ describe("t3_thread_checkout", () => { const harness = makeHarness({ thread: { branch: "dev", worktreePath: null }, refs: rootRefs, - workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [plainProjectRoot]: { branch: null, isRepo: false }, + }, worktreeInventoryFailsFor: new Set([plainProjectRoot]), otherProjectThread: { projectId: otherProjectId, @@ -2476,6 +2519,39 @@ describe("t3_thread_checkout", () => { }); }); + 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("rejects a physical worktree retained by an archived thread", () => { const targetPath = "/worktrees/project/archived-owner"; const harness = makeHarness({ @@ -2693,8 +2769,8 @@ describe("t3_thread_checkout", () => { }); for (const [change, options] of [ - ["a new commit", { resolvedCommits: ["before", "selected", "intervening"] }], - ["new dirty files", { dirtyOnLocalStatusCall: 4 }], + ["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({ @@ -2742,12 +2818,40 @@ describe("t3_thread_checkout", () => { }); }); + 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: 3, + localStatusFailsOnCall: 4, }); return Effect.gen(function* () { const exit = yield* Effect.exit( diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index a66499b07882..61be04de57be 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -194,6 +194,28 @@ const make = Effect.gen(function* () { .listProjectThreads({ projectId, includeSubagents: true }) .pipe(asOperationFailed(`Unable to list threads in project ${projectId}`)); + const readWorkspaceStatus = (workspacePath: string) => + gitWorkflow + .invalidateLocalStatus(workspacePath) + .pipe( + Effect.andThen(gitWorkflow.localStatus({ cwd: workspacePath })), + asOperationFailed(`Unable to read git status in '${workspacePath}'`), + ); + + 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 @@ -218,15 +240,15 @@ const make = Effect.gen(function* () { onNone: () => Effect.succeed([]), onSome: (project) => Effect.gen(function* () { - const projectInventory = yield* Effect.option( - loadWorktrees(project.workspaceRoot), + const projectInventory = yield* loadWorkspaceBindingInventory( + project.workspaceRoot, ); return yield* Effect.forEach(projectThreads, (thread) => Effect.gen(function* () { const inventory = thread.worktreePath === null ? projectInventory - : yield* Effect.option(loadWorktrees(thread.worktreePath)); + : yield* loadWorkspaceBindingInventory(thread.worktreePath); if ( Option.isNone(inventory) || inventory.value.repositoryCommonDir !== repositoryCommonDir || @@ -246,14 +268,6 @@ const make = Effect.gen(function* () { }, ); - const readWorkspaceStatus = (workspacePath: string) => - gitWorkflow - .invalidateLocalStatus(workspacePath) - .pipe( - Effect.andThen(gitWorkflow.localStatus({ cwd: workspacePath })), - asOperationFailed(`Unable to read git status in '${workspacePath}'`), - ); - const readWorkspaceBranchOrNull = (workspacePath: string) => readWorkspaceStatus(workspacePath).pipe( Effect.map((status) => status.refName), @@ -1280,19 +1294,19 @@ const make = Effect.gen(function* () { let checkoutAction: WorktreeMcpCheckoutResult["checkoutAction"] = targetWorkspacePath === currentWorkspacePath ? "unchanged" : "reused"; - let createdBranch: string | null = null; - let createdBranchCommit: string | null = null; let resolvedBranch: string | null = targetBefore.refName; const targetBeforeCommit = shouldMutateCheckout ? yield* gitWorkflow .resolveCommit({ cwd: targetWorkspacePath, revision: "HEAD" }) .pipe(asOperationFailed("Unable to record the checkout's current commit")) : null; - const requestedRemoteCommit = - shouldMutateCheckout && requestedBranch !== undefined && selectedRef?.isRemote === true - ? yield* gitWorkflow - .resolveCommit({ cwd: targetWorkspacePath, revision: requestedBranch }) - .pipe(asOperationFailed(`Unable to resolve remote ref '${requestedBranch}'`)) + const requestedTransitionCommit = + shouldMutateCheckout && requestedBranch !== undefined + ? createBranch + ? targetBeforeCommit + : yield* gitWorkflow + .resolveCommit({ cwd: targetWorkspacePath, revision: requestedBranch }) + .pipe(asOperationFailed(`Unable to resolve ref '${requestedBranch}'`)) : null; let ownedCheckoutState: { readonly refName: string | null; @@ -1359,30 +1373,60 @@ const make = Effect.gen(function* () { return "not_possible" as const; } const rollbackExit = yield* Effect.exit( - (checkoutChanged + checkoutChanged ? gitWorkflow.switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName!, }) - : Effect.void - ).pipe( - Effect.andThen( - createdBranch === null || createdBranchCommit === null - ? Effect.void - : gitWorkflow.deleteLocalBranch({ - cwd: targetWorkspacePath, - refName: createdBranch, - force: true, - expectedCommitSha: createdBranchCommit, - }), - ), - ), + : Effect.void, ); return Exit.isSuccess(rollbackExit) ? ("rolled_back" as const) : ("failed" as const); }, ); if (shouldMutateCheckout && requestedBranch !== undefined) { + const [mutationProjection, mutationBindings, mutationTargetBefore] = yield* 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({ @@ -1391,8 +1435,6 @@ const make = Effect.gen(function* () { switchRef: false, }) .pipe(asOperationFailed(`Unable to create branch '${requestedBranch}'`)); - createdBranch = requestedBranch; - createdBranchCommit = targetBeforeCommit?.commitSha ?? null; } const switchExit = yield* Effect.exit( gitWorkflow.switchRef({ cwd: targetWorkspacePath, refName: requestedBranch }), @@ -1400,7 +1442,20 @@ const make = Effect.gen(function* () { if (Exit.isFailure(switchExit)) { const afterFailedSwitchExit = yield* Effect.exit(captureCheckoutState()); if (Exit.isSuccess(afterFailedSwitchExit)) { - ownedCheckoutState = afterFailedSwitchExit.value; + 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") { @@ -1494,8 +1549,8 @@ const make = Effect.gen(function* () { } if ( resolvedBranch === null && - requestedRemoteCommit !== null && - actualCommitExit.value.commitSha !== requestedRemoteCommit.commitSha + requestedTransitionCommit !== null && + actualCommitExit.value.commitSha !== requestedTransitionCommit.commitSha ) { return yield* failure( "partial_failure", diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 5a49cde79f55..6942f2fe4b55 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -120,7 +120,6 @@ export interface GitDeleteLocalBranchInput { readonly cwd: string; readonly refName: string; readonly force?: boolean; - readonly expectedCommitSha?: string; } export interface GitPushResult { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 335e426da46f..48980d14a7f7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2,7 +2,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it, describe } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -1585,35 +1584,6 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); - it.effect("compare-and-deletes a local branch only at the expected commit", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* driver.createRef({ cwd, refName: "feature/owned", switchRef: false }); - const commitSha = yield* git(cwd, ["rev-parse", "feature/owned"]); - - const staleDelete = yield* Effect.exit( - driver.deleteLocalBranch({ - cwd, - refName: "feature/owned", - force: true, - expectedCommitSha: "1111111111111111111111111111111111111111", - }), - ); - assert.isTrue(Exit.isFailure(staleDelete)); - assert.include(yield* driver.listLocalBranchNames(cwd), "feature/owned"); - - yield* driver.deleteLocalBranch({ - cwd, - refName: "feature/owned", - force: true, - expectedCommitSha: commitSha, - }); - assert.notInclude(yield* driver.listLocalBranchNames(cwd), "feature/owned"); - }), - ); - it.effect("removes the same worktree path twice without failing", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3a964892fe7d..9b599d85e4d9 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3166,32 +3166,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const deleteLocalBranch: GitVcsDriver.GitVcsDriver["Service"]["deleteLocalBranch"] = Effect.fn( "deleteLocalBranch", )(function* (input) { - if (input.expectedCommitSha !== undefined) { - yield* executeGit( - "GitVcsDriver.deleteLocalBranch", - input.cwd, - ["update-ref", "-d", `refs/heads/${input.refName}`, input.expectedCommitSha], - { - timeoutMs: 10_000, - fallbackErrorDetail: "git branch compare-and-delete failed", - }, - ); - const stillExists = yield* executeGit( - "GitVcsDriver.deleteLocalBranch.verify", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], - { timeoutMs: 5_000, allowNonZeroExit: true }, - ).pipe(Effect.map((result) => result.exitCode === 0)); - if (stillExists) { - return yield* new GitCommandError({ - operation: "GitVcsDriver.deleteLocalBranch", - command: "git update-ref -d", - cwd: input.cwd, - detail: `Local branch '${input.refName}' changed before it could be deleted safely.`, - }); - } - return; - } yield* executeGit( "GitVcsDriver.deleteLocalBranch", input.cwd, From f50420bbe51c11c605d821432f8115a39dd85fc8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:22:36 -0700 Subject: [PATCH 07/16] fix(mcp): retain uncertain handoff branches --- .../server/src/mcp/WorktreeMcpService.test.ts | 29 ++++--------------- apps/server/src/mcp/WorktreeMcpService.ts | 15 ++-------- 2 files changed, 9 insertions(+), 35 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index f926245cac01..f5effdbf9aad 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -124,7 +124,6 @@ interface HarnessOptions { readonly resolveRemoteFails?: boolean; readonly resolvedCommits?: ReadonlyArray; readonly removeWorktreeFails?: boolean; - readonly deleteLocalBranchFails?: boolean; readonly createWorktreeGate?: Effect.Effect; readonly refs?: ReadonlyArray<{ readonly name: string; @@ -402,11 +401,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, ); @@ -1151,11 +1146,7 @@ describe("t3_worktree_handoff", () => { path: "/worktrees/project/feature/raced", force: true, }); - expect(harness.deleteLocalBranch).toHaveBeenCalledWith({ - cwd: workspaceRoot, - refName: "feature/raced", - force: true, - }); + expect(harness.deleteLocalBranch).not.toHaveBeenCalled(); expect(harness.dispatch).not.toHaveBeenCalled(); }); }); @@ -1246,23 +1237,15 @@ describe("t3_worktree_handoff", () => { }); }); - it.effect("reports a partial 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: "partial_failure", - partial: { rollback: "failed" }, - }); + 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(); }); }); diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 61be04de57be..d66a20e0496d 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -473,9 +473,9 @@ 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. + // Removing the new worktree is safe because this call still owns its + // path. Retain the branch: after concurrent Git activity, branch-name + // identity alone is not enough to authorize deleting the ref. let createdWorktreeRemoved = false; const removeCreatedWorktree = Effect.suspend(() => gitWorkflow.removeWorktree({ cwd: projectCwd, path: worktreePath, force: true }).pipe( @@ -484,15 +484,6 @@ const make = Effect.gen(function* () { createdWorktreeRemoved = true; }), ), - Effect.andThen( - Effect.suspend(() => - gitWorkflow.deleteLocalBranch({ - cwd: projectCwd, - refName: worktree.worktree.refName, - force: true, - }), - ), - ), ), ); From e7429fd93a678d0fc9d331e21f75ba32215fef0a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:31:04 -0700 Subject: [PATCH 08/16] fix(mcp): fail closed on unresolved owners --- .../server/src/mcp/WorktreeMcpService.test.ts | 39 +++++++++++++++++++ apps/server/src/mcp/WorktreeMcpService.ts | 9 ++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index f5effdbf9aad..9aa1690fe740 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -2535,6 +2535,45 @@ describe("t3_thread_checkout", () => { }); }); + 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({ diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index d66a20e0496d..e5065edd7cbb 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -251,11 +251,16 @@ const make = Effect.gen(function* () { : yield* loadWorkspaceBindingInventory(thread.worktreePath); if ( Option.isNone(inventory) || - inventory.value.repositoryCommonDir !== repositoryCommonDir || - inventory.value.currentWorktreeRoot === null + 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())); From c079c2a7f8fd96816a2e277a1a29b382c954697b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:34:41 -0700 Subject: [PATCH 09/16] fix(mcp): verify local checkout ownership --- .../server/src/mcp/WorktreeMcpService.test.ts | 27 +++++++++++++++++++ apps/server/src/mcp/WorktreeMcpService.ts | 5 ++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 9aa1690fe740..6839d408a797 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -2790,6 +2790,33 @@ describe("t3_thread_checkout", () => { }); }); + 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 }], diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index e5065edd7cbb..3f9852d38d93 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -1544,17 +1544,16 @@ const make = Effect.gen(function* () { ); } if ( - resolvedBranch === null && requestedTransitionCommit !== null && actualCommitExit.value.commitSha !== requestedTransitionCommit.commitSha ) { return yield* failure( "partial_failure", - `Git detached HEAD while selecting '${requestedBranch}', but HEAD does not match the requested remote commit. The durable binding was not changed.`, + `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: null, + actualBranch: actual.refName, rollback: "not_possible", }, ); From ce0050d3dcb49d5435635326bf6e9aa6efbe7ab0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:53:36 -0700 Subject: [PATCH 10/16] fix(mcp): recover threads from missing worktrees --- .../server/src/mcp/WorktreeMcpService.test.ts | 149 ++++++++++++++++++ apps/server/src/mcp/WorktreeMcpService.ts | 106 ++++++++++--- 2 files changed, 232 insertions(+), 23 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 6839d408a797..24540e284b4f 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -2067,6 +2067,155 @@ describe("t3_thread_checkout", () => { }); }); + 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("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({ diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 3f9852d38d93..48a0e1ff1933 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -353,17 +353,44 @@ const make = Effect.gen(function* () { const project = yield* loadProject(scope, projection.thread.projectId); const projectCwd = yield* canonicalizePath(project.workspaceRoot); - const sourceCwd = yield* canonicalizePath(projection.thread.worktreePath ?? projectCwd); - - if (projection.thread.worktreePath !== null) { - const inventory = yield* loadWorktrees(projectCwd); - const projectWorktreePaths = new Set(inventory.worktrees.map((worktree) => worktree.path)); - if (!projectWorktreePaths.has(sourceCwd)) { - return yield* failure( - "scope_mismatch", - `Thread worktree '${projection.thread.worktreePath}' is not registered in project '${projection.thread.projectId}'.`, - ); - } + 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)) { @@ -954,7 +981,7 @@ const make = Effect.gen(function* () { projection.thread.worktreePath ?? projectWorkspaceRoot, ); if (input.target.type === "new_worktree") { - const previousActual = yield* readWorkspaceStatus(recordedWorkspacePath); + const previousActualBranch = yield* readWorkspaceBranchOrNull(recordedWorkspacePath); const handoff = yield* performHandoff( scope, { @@ -978,7 +1005,7 @@ const make = Effect.gen(function* () { workspacePath: recordedWorkspacePath, recordedBranch: projection.thread.branch, recordedWorktreePath: projection.thread.worktreePath, - actualBranch: previousActual.refName, + actualBranch: previousActualBranch, }, current: { workspacePath: handoff.worktreePath, @@ -988,7 +1015,7 @@ const make = Effect.gen(function* () { }, checkoutAction: "created", workspaceChanged: true, - branchChanged: previousActual.refName !== handoff.branch, + branchChanged: previousActualBranch !== handoff.branch, continuation: handoff.continuation, setupScript: handoff.setupScript, callerTurnEnds: true, @@ -996,28 +1023,45 @@ const make = Effect.gen(function* () { } satisfies WorktreeMcpCheckoutResult; } const [inventory, currentInventory] = yield* Effect.all( - [loadWorktrees(projectWorkspaceRoot), loadWorktrees(recordedWorkspacePath)], + [loadWorktrees(projectWorkspaceRoot), loadWorkspaceBindingInventory(recordedWorkspacePath)], { concurrency: 2 }, ); - if (inventory.repositoryCommonDir !== currentInventory.repositoryCommonDir) { + 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 = currentInventory.currentWorktreeRoot; - if (projectWorktreeRoot === null || currentWorkspacePath === null) { + 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 project or thread checkout.", + "Git could not resolve the physical thread checkout.", ); } const [refs, threads, previousActual] = yield* Effect.all( [ loadRefs(projectWorkspaceRoot), loadProjectThreads(projection.thread.projectId), - readWorkspaceStatus(currentWorkspacePath), + readWorkspaceStatus(recordedWorkspacePath).pipe( + Effect.orElseSucceed(() => ({ + isRepo: false, + refName: null, + hasWorkingTreeChanges: false, + })), + ), ], { concurrency: 3 }, ); @@ -1063,6 +1107,21 @@ const make = Effect.gen(function* () { "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": { @@ -1090,9 +1149,10 @@ const make = Effect.gen(function* () { workspace === "project_root" ? projectWorktreeRoot : workspace === "current" - ? currentWorkspacePath + ? (currentWorkspacePath ?? recordedWorkspacePath) : (selectedWorktreePath ?? - (projection.thread.worktreePath !== null && selectedRef?.isDefault === true + (currentWorkspacePath === null || + (projection.thread.worktreePath !== null && selectedRef?.isDefault === true) ? projectWorktreeRoot : currentWorkspacePath)); break; @@ -1682,7 +1742,7 @@ const make = Effect.gen(function* () { }) : ({ status: "skipped" } as const); const previous = { - workspacePath: currentWorkspacePath, + workspacePath: currentWorkspacePath ?? recordedWorkspacePath, recordedBranch: projection.thread.branch, recordedWorktreePath: projection.thread.worktreePath, actualBranch: previousActual.refName, From 0b39eb17d5af0ea8f20908e630876df93113eaeb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 10:19:41 -0700 Subject: [PATCH 11/16] fix(mcp): preserve reviewed checkout composition --- .../server/src/mcp/WorktreeMcpService.test.ts | 237 +++++++++++++++--- apps/server/src/mcp/WorktreeMcpService.ts | 84 ++++--- .../src/mcp/toolkits/worktree/handlers.ts | 4 +- .../server/src/mcp/toolkits/worktree/tools.ts | 4 +- docs/user/source-control.md | 8 +- packages/contracts/src/worktreeMcp.ts | 2 +- 6 files changed, 270 insertions(+), 69 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 24540e284b4f..9250b7461848 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -8,11 +8,13 @@ import { type Project, ProjectId, ProviderInstanceId, + RunId, ThreadId, WorktreeMcpHandoffInput, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; @@ -79,6 +81,51 @@ const makeProjection = (overrides: ThreadFixture = {}): OrchestrationV2ThreadPro }, }) as OrchestrationV2ThreadProjection; +const shellFixture = ( + overrides: Partial, +): OrchestrationV2ThreadShell => { + const timestamp = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"); + return { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId, + title: "Worktree test thread", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "test-model", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: threadId, + }, + forkedFrom: null, + activeProviderThreadId: null, + latestRunId: null, + activeRunId: null, + status: "idle", + pendingRuntimeRequest: null, + latestVisibleMessage: null, + latestUserMessageAt: null, + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + ...overrides, + }; +}; + const project: Project = { id: projectId, title: "Worktree test project", @@ -136,6 +183,19 @@ interface HarnessOptions { readonly path: string; readonly refName: string | null; }>; + readonly worktreeInventories?: Readonly< + Record< + string, + { + readonly repositoryCommonDir: string; + readonly currentWorktreeRoot: string | null; + readonly worktrees: ReadonlyArray<{ + readonly path: string; + readonly refName: string | null; + }>; + } + > + >; readonly projectWorktreeRoot?: string; readonly workspaceAliases?: Readonly>; readonly projectWorkspaceRoot?: string; @@ -344,46 +404,58 @@ const makeHarness = (options: HarnessOptions = {}) => { worktreePath: thread?.worktreePath ?? null, }, ] - ).map( - (item) => - ({ - id: item.id, - projectId, - title: item.title, - branch: item.branch, - worktreePath: item.worktreePath, - status: item.status ?? "idle", - activeRunId: item.active === true ? "run-active" : null, - lineage: { relationshipToParent: "none" }, - }) as unknown as OrchestrationV2ThreadShell, + ).map((item) => + shellFixture({ + id: item.id, + projectId, + title: item.title, + branch: item.branch, + worktreePath: item.worktreePath, + status: item.status ?? (item.active === true ? "running" : "idle"), + activeRunId: item.active === true ? RunId.make("run-active") : null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: item.id, + }, + }), ); if (options.otherProjectThread !== undefined) { - projectThreadShells.push({ - ...(projectThreadShells[0] ?? makeProjection({}).thread), - 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 ? "run-active" : null, - lineage: { relationshipToParent: "none" }, - } as unknown as OrchestrationV2ThreadShell); + 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 ? [] : [ - { - ...(projectThreadShells[0] ?? makeProjection({}).thread), + shellFixture({ id: options.archivedProjectThread.id, projectId, title: options.archivedProjectThread.title, branch: options.archivedProjectThread.branch, worktreePath: options.archivedProjectThread.worktreePath, activeRunId: null, - archivedAt: "2026-01-02T00:00:00.000Z", - lineage: { relationshipToParent: "none" }, - } as unknown as OrchestrationV2ThreadShell, + archivedAt: DateTime.makeUnsafe("2026-01-02T00:00:00.000Z"), + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: options.archivedProjectThread.id, + }, + }), ]; const listProjectThreads = vi.fn((input: { readonly projectId: ProjectId }) => Effect.succeed(projectThreadShells.filter((item) => item.projectId === input.projectId)), @@ -493,14 +565,16 @@ const makeHarness = (options: HarnessOptions = {}) => { const listWorktrees = vi.fn((cwd: string) => options.worktreeInventoryFailsFor?.has(cwd) === true ? (Effect.fail("simulated worktree inventory failure") as never) - : Effect.succeed({ - repositoryCommonDir: "/repo/.git", - currentWorktreeRoot: - options.workspaceAliases?.[cwd] ?? - listedWorktrees.find((worktree) => worktree.path === cwd)?.path ?? - (cwd === workspaceRoot ? projectWorktreeRoot : cwd), - worktrees: listedWorktrees, - }), + : Effect.succeed( + options.worktreeInventories?.[cwd] ?? { + repositoryCommonDir: "/repo/.git", + currentWorktreeRoot: + options.workspaceAliases?.[cwd] ?? + listedWorktrees.find((worktree) => worktree.path === cwd)?.path ?? + (cwd.startsWith(`${projectWorktreeRoot}/`) ? projectWorktreeRoot : cwd), + worktrees: listedWorktrees, + }, + ), ); let localStatusCallCount = 0; const localStatus = vi.fn((input: { readonly cwd: string }) => { @@ -1742,6 +1816,99 @@ describe("t3_worktree_list", () => { expect(result.worktrees[0]?.bindings).toHaveLength(1); }); }); + + 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" }], + archivedProjectThread: { + id: archivedThreadId, + title: "Archived checkout owner", + branch: "dev", + worktreePath: workspaceRoot, + }, + }); + 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, + }), + ]), + }); + }); + }); + + 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); + }); + }); }); describe("t3_thread_checkout", () => { diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 48a0e1ff1933..e360733a0fb0 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -190,9 +190,14 @@ const make = Effect.gen(function* () { const loadProjectThreads = ( projectId: ProjectId, ): Effect.Effect, WorktreeMcpFailure> => - threadManagement - .listProjectThreads({ projectId, includeSubagents: true }) - .pipe(asOperationFailed(`Unable to list threads in project ${projectId}`)); + threadManagement.getShellSnapshot().pipe( + Effect.map((snapshot) => + [...snapshot.threads, ...snapshot.archivedThreads].filter( + (thread) => thread.projectId === projectId, + ), + ), + asOperationFailed(`Unable to list threads in project ${projectId}`), + ); const readWorkspaceStatus = (workspacePath: string) => gitWorkflow @@ -219,10 +224,10 @@ const make = Effect.gen(function* () { const loadActiveWorkspaceBindings = Effect.fn("WorktreeMcpService.loadActiveWorkspaceBindings")( function* (repositoryCommonDir: string) { const snapshot = yield* threadManagement - .getShellSnapshot({ location: "active" }) - .pipe(asOperationFailed("Unable to inspect active thread workspace bindings")); + .getShellSnapshot() + .pipe(asOperationFailed("Unable to inspect thread workspace bindings")); const byProject = new Map>(); - for (const thread of snapshot.threads) { + for (const thread of [...snapshot.threads, ...snapshot.archivedThreads]) { const projectThreads = byProject.get(thread.projectId) ?? []; projectThreads.push(thread); byProject.set(thread.projectId, projectThreads); @@ -736,28 +741,43 @@ const make = Effect.gen(function* () { const project = yield* loadProject(scope, projection.thread.projectId); const projectWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot); const workspacePath = normalizePath(projection.thread.worktreePath ?? projectWorkspaceRoot); - const [defaultStartFromOrigin, actual, projectInventory, workspaceInventory] = - yield* Effect.all( - [ - readDefaultStartFromOrigin, - readWorkspaceStatus(workspacePath), - loadWorktrees(projectWorkspaceRoot), - loadWorktrees(workspacePath), - ], - { concurrency: 4 }, - ); + const [ + defaultStartFromOrigin, + actual, + projectInventory, + workspaceInventory, + workspaceExists, + ] = yield* Effect.all( + [ + readDefaultStartFromOrigin, + readWorkspaceStatus(workspacePath), + Effect.option(loadWorktrees(projectWorkspaceRoot)), + Effect.option(loadWorktrees(workspacePath)), + fileSystem.exists(workspacePath).pipe(Effect.orElseSucceed(() => false)), + ], + { concurrency: 5 }, + ); const canonicalWorkspacePath = yield* canonicalizePath(workspacePath); - const physicalWorkspacePath = workspaceInventory.currentWorktreeRoot; + const physicalWorkspacePath = Option.isSome(workspaceInventory) + ? workspaceInventory.value.currentWorktreeRoot + : null; const agreement = - workspaceInventory.repositoryCommonDir !== projectInventory.repositoryCommonDir || - physicalWorkspacePath === null || - !projectInventory.worktrees.some((worktree) => worktree.path === physicalWorkspacePath) + !actual.isRepo && !workspaceExists && Option.isNone(workspaceInventory) ? "workspace_missing" : !actual.isRepo ? "not_repository" - : actual.refName !== projection.thread.branch - ? "branch_mismatch" - : "in_sync"; + : Option.isNone(projectInventory) || Option.isNone(workspaceInventory) + ? "workspace_missing" + : workspaceInventory.value.repositoryCommonDir !== + projectInventory.value.repositoryCommonDir || + physicalWorkspacePath === null || + !projectInventory.value.worktrees.some( + (worktree) => worktree.path === physicalWorkspacePath, + ) + ? "workspace_missing" + : actual.refName !== projection.thread.branch + ? "branch_mismatch" + : "in_sync"; const result: WorktreeMcpStatusResult = { attached: projection.thread.worktreePath !== null, @@ -1205,11 +1225,19 @@ const make = Effect.gen(function* () { requestedBranch !== undefined && (createBranch || selectedRef?.isRemote === true || targetBefore.refName !== requestedBranch); const threadWorkspaces = yield* Effect.forEach(threads, (thread) => - threadWorkspacePath( - thread, - projectWorktreeRoot, - inventory.worktrees.map((worktree) => worktree.path), - ).pipe(Effect.map((workspacePath) => [thread, workspacePath] as const)), + 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( diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index a7d1c9c53b90..a448534d3930 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -17,11 +17,11 @@ const handlers = { const service = yield* WorktreeMcpService; return yield* service.status(scope); }), - t3_worktree_list: () => + t3_worktree_list: (input) => Effect.gen(function* () { const scope = yield* McpInvocationContext; const service = yield* WorktreeMcpService; - return yield* service.listWorktrees(scope); + return yield* service.listWorktrees(scope, input); }), t3_thread_checkout: (input) => Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index 91cc15eb1cbf..6b0f91e1b3d4 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -4,6 +4,7 @@ import { WorktreeMcpCheckoutResult, WorktreeMcpHandoffInput, WorktreeMcpHandoffResult, + WorktreeMcpListInput, WorktreeMcpListResult, WorktreeMcpStatusResult, } from "@t3tools/contracts"; @@ -49,7 +50,8 @@ export const WorktreeStatusTool = Tool.make("t3_worktree_status", { export const WorktreeListTool = Tool.make("t3_worktree_list", { description: - "List the calling thread's project root and existing branch-backed git worktrees. Each entry includes the actual checked-out branch, dirty state, and threads bound to that checkout with their recorded branch and worktree path. Use this read path before t3_thread_checkout; it does not create, remove, prune, or repair worktrees.", + "Page through the calling thread's project root and Git-registered worktrees, including detached checkouts. Paths are canonicalized from Git's repository identity. Each entry includes the actual checked-out branch, dirty state, availability, and a bounded list plus total count of threads bound to that checkout. Use cursor until nextCursor is null. This tool does not create, remove, prune, or repair worktrees.", + parameters: WorktreeMcpListInput, success: WorktreeMcpListResult, failure: WorktreeMcpFailure, failureMode: "return", diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 5f2c28f5e3ab..dd16e19cb09b 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -68,8 +68,12 @@ Run a quick **Rescan** after setting up a new machine or changing credentials. ### 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 existing worktrees, including -dirty state and other threads using each checkout. +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. 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 diff --git a/packages/contracts/src/worktreeMcp.ts b/packages/contracts/src/worktreeMcp.ts index 32b4f3e41c66..c0ae9f720617 100644 --- a/packages/contracts/src/worktreeMcp.ts +++ b/packages/contracts/src/worktreeMcp.ts @@ -1,6 +1,6 @@ import * as Schema from "effect/Schema"; -import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; const AbsolutePath = TrimmedNonEmptyString.check( // Absolute POSIX (/...), Windows drive (C:\\ or C:/), or UNC (\\\\host). From e95af0ea5687bb59007f44728f655dccbca3f670 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:04:13 -0700 Subject: [PATCH 12/16] fix(mcp): fail closed on checkout status errors --- .../server/src/mcp/WorktreeMcpService.test.ts | 22 +++++++++++++++++++ apps/server/src/mcp/WorktreeMcpService.ts | 14 ++++++------ .../orchestrator-mcp-server.md | 8 +++---- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 9250b7461848..28383331d131 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -2348,6 +2348,28 @@ describe("t3_thread_checkout", () => { }); }); + 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 }, diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index e360733a0fb0..94ffe7eac434 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -1075,13 +1075,13 @@ const make = Effect.gen(function* () { [ loadRefs(projectWorkspaceRoot), loadProjectThreads(projection.thread.projectId), - readWorkspaceStatus(recordedWorkspacePath).pipe( - Effect.orElseSucceed(() => ({ - isRepo: false, - refName: null, - hasWorkingTreeChanges: false, - })), - ), + Option.isNone(currentInventory) + ? Effect.succeed({ + isRepo: false, + refName: null, + hasWorkingTreeChanges: false, + }) + : readWorkspaceStatus(recordedWorkspacePath), ], { concurrency: 3 }, ); diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index eead57483272..bd16a4a32ca8 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -44,10 +44,10 @@ Before `ProviderSessionManager` opens a new V2 provider session, it asks - the concrete provider instance; and - the provider session. -The credential grants `preview`, `orchestration`, and `worktree` 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 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 MCP HTTP server resolves the bearer token and supplies the resulting `McpInvocationScope` to tool handlers. Orchestration handlers additionally From 69afd25d7a604c150202fec55efd927f0c8eda83 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:17:15 -0700 Subject: [PATCH 13/16] docs(mcp): clarify credential liveness --- docs/orchestration-v2/orchestrator-mcp-server.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index bd16a4a32ca8..3edba412b70e 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -46,8 +46,9 @@ Before `ProviderSessionManager` opens a new V2 provider session, it asks 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 a maximum lifetime, expire when idle, and are revoked when -the provider session is released. The raw token is not persisted in orchestration state. +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 From 1d4be0603e474a2a4c65fde33eb0d06d0f1c0f6c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:57:25 -0700 Subject: [PATCH 14/16] fix(mcp): guard workspace transition ownership --- .../server/src/mcp/WorktreeMcpService.test.ts | 170 ++++++++++++-- apps/server/src/mcp/WorktreeMcpService.ts | 219 ++++++++++++++---- .../orchestrator-mcp-server.md | 10 +- 3 files changed, 332 insertions(+), 67 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 28383331d131..367131a5a5ff 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"; @@ -170,6 +171,7 @@ interface HarnessOptions { readonly fetchRemoteFails?: boolean; readonly resolveRemoteFails?: boolean; readonly resolvedCommits?: ReadonlyArray; + readonly resolveCommitGate?: Effect.Effect; readonly removeWorktreeFails?: boolean; readonly createWorktreeGate?: Effect.Effect; readonly refs?: ReadonlyArray<{ @@ -237,12 +239,14 @@ interface HarnessOptions { 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 @@ -251,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: [] }; + }), ), ), ); @@ -342,13 +355,19 @@ const makeHarness = (options: HarnessOptions = {}) => { return Effect.succeed(makeProjection({ ...thread, ...options.threadAfterFailedDispatch })); } if (id === threadId && thread !== null) { - return Effect.succeed(makeProjection(thread)); + 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: projectThread.worktreePath, + worktreePath: dispatchedWorktreePaths.get(id) ?? projectThread.worktreePath, }); return Effect.succeed({ ...projection, @@ -464,7 +483,10 @@ const makeHarness = (options: HarnessOptions = {}) => { Effect.succeed({ schemaVersion: 1, snapshotSequence: 1, - threads: projectThreadShells, + threads: projectThreadShells.map((thread) => ({ + ...thread, + worktreePath: dispatchedWorktreePaths.get(thread.id) ?? thread.worktreePath, + })), archivedThreads: archivedThreadShells, } as never), ); @@ -489,7 +511,7 @@ const makeHarness = (options: HarnessOptions = {}) => { Math.min(resolveCommitCallCount, (options.resolvedCommits?.length ?? 1) - 1) ] ?? "commit-test"; resolveCommitCallCount += 1; - return Effect.succeed({ commitSha }); + return (options.resolveCommitGate ?? Effect.void).pipe(Effect.as({ commitSha })); }); const workspaceStatuses = new Map( Object.entries( @@ -1208,33 +1230,32 @@ describe("t3_worktree_handoff", () => { }); }); - it.effect("re-checks attachment after creating the worktree and backs out on a race", () => { + 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: "operation_failed" }); - 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, + 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(); }); }); @@ -1368,6 +1389,75 @@ describe("t3_worktree_handoff", () => { }); }); + 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* () { @@ -3032,6 +3122,46 @@ describe("t3_thread_checkout", () => { }); }); + 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); + yield* Fiber.interrupt(first); + 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 }, diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 94ffe7eac434..df0c7c4ff43c 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -90,6 +90,8 @@ const make = Effect.gen(function* () { // 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") @@ -474,6 +476,7 @@ const make = Effect.gen(function* () { } 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 @@ -496,7 +499,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). @@ -510,19 +592,51 @@ const make = Effect.gen(function* () { }).pipe(Effect.as({ status: "failed", detail } as const)); }); - // Removing the new worktree is safe because this call still owns its - // path. Retain the branch: after concurrent Git activity, branch-name - // identity alone is not enough to authorize deleting the ref. let createdWorktreeRemoved = false; - const removeCreatedWorktree = Effect.suspend(() => - gitWorkflow.removeWorktree({ cwd: projectCwd, path: worktreePath, force: true }).pipe( - Effect.tap(() => - Effect.sync(() => { - createdWorktreeRemoved = true; - }), + 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), + ], + { concurrency: 4 }, ), - ), - ); + ); + if (Exit.isFailure(verificationExit)) { + return "not_possible" as const; + } + const [bindings, callerProjection, worktreeInventory, worktreeStatus] = + 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; + 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* () { @@ -555,16 +669,16 @@ const make = Effect.gen(function* () { if (Cause.hasInterruptsOnly(recheckExit.cause)) { return yield* Effect.failCause(recheckExit.cause as Cause.Cause); } - const cleanupExit = yield* Effect.exit(removeCreatedWorktree); - if (Exit.isFailure(cleanupExit)) { + const cleanup = yield* removeCreatedWorktreeIfOwned(); + if (cleanup !== "removed") { return yield* failure( "partial_failure", - `The handoff failed before binding and the created worktree could not be removed: ${errorMessage(Cause.squash(recheckExit.cause))}`, + `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: "failed", + rollback: cleanup === "failed" ? "failed" : "not_possible", }, ); } @@ -615,16 +729,16 @@ const make = Effect.gen(function* () { }, ); } else { - const cleanupExit = yield* Effect.exit(removeCreatedWorktree); - if (Exit.isFailure(cleanupExit)) { + const cleanup = yield* removeCreatedWorktreeIfOwned(); + if (cleanup !== "removed") { return yield* failure( "partial_failure", - `The worktree binding failed and the created worktree could not be removed: ${dispatchDetail}`, + `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: "failed", + rollback: cleanup === "failed" ? "failed" : "not_possible", }, ); } @@ -704,6 +818,14 @@ const make = Effect.gen(function* () { }; return result; }), + ).pipe( + Effect.ensuring( + Effect.sync(() => { + if (acquiredPhysicalWorkspaceGuard !== null) { + workspaceTransitionsInFlight.delete(acquiredPhysicalWorkspaceGuard); + } + }), + ), ); }); @@ -1280,8 +1402,11 @@ const make = Effect.gen(function* () { } const ids = yield* transitionIds(scope, "checkout"); - const workspaceGuardKey = `workspace:${inventory.repositoryCommonDir}:${targetWorkspacePath}`; - return yield* Effect.uninterruptibleMask(() => + const workspaceGuardKey = physicalWorkspaceGuardKey( + inventory.repositoryCommonDir, + targetWorkspacePath, + ); + return yield* Effect.uninterruptibleMask((restore) => Effect.suspend(() => { if (workspaceTransitionsInFlight.has(workspaceGuardKey)) { return Effect.fail( @@ -1294,14 +1419,16 @@ const make = Effect.gen(function* () { workspaceTransitionsInFlight.add(workspaceGuardKey); return Effect.gen(function* () { const [latestProjection, latestInventory, latestBindings, latestTargetBefore] = - yield* Effect.all( - [ - loadThread(scope), - loadWorktrees(projectWorkspaceRoot), - loadActiveWorkspaceBindings(inventory.repositoryCommonDir), - readWorkspaceStatus(targetWorkspacePath), - ], - { concurrency: 4 }, + yield* restore( + Effect.all( + [ + loadThread(scope), + loadWorktrees(projectWorkspaceRoot), + loadActiveWorkspaceBindings(inventory.repositoryCommonDir), + readWorkspaceStatus(targetWorkspacePath), + ], + { concurrency: 4 }, + ), ); if ( latestProjection.thread.branch !== projection.thread.branch || @@ -1380,17 +1507,21 @@ const make = Effect.gen(function* () { targetWorkspacePath === currentWorkspacePath ? "unchanged" : "reused"; let resolvedBranch: string | null = targetBefore.refName; const targetBeforeCommit = shouldMutateCheckout - ? yield* gitWorkflow - .resolveCommit({ cwd: targetWorkspacePath, revision: "HEAD" }) - .pipe(asOperationFailed("Unable to record the checkout's current commit")) + ? 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* gitWorkflow - .resolveCommit({ cwd: targetWorkspacePath, revision: requestedBranch }) - .pipe(asOperationFailed(`Unable to resolve ref '${requestedBranch}'`)) + : yield* restore( + gitWorkflow + .resolveCommit({ cwd: targetWorkspacePath, revision: requestedBranch }) + .pipe(asOperationFailed(`Unable to resolve ref '${requestedBranch}'`)), + ) : null; let ownedCheckoutState: { readonly refName: string | null; @@ -1469,13 +1600,15 @@ const make = Effect.gen(function* () { ); if (shouldMutateCheckout && requestedBranch !== undefined) { - const [mutationProjection, mutationBindings, mutationTargetBefore] = yield* Effect.all( - [ - loadThread(scope), - loadActiveWorkspaceBindings(inventory.repositoryCommonDir), - readWorkspaceStatus(targetWorkspacePath), - ], - { concurrency: 3 }, + const [mutationProjection, mutationBindings, mutationTargetBefore] = yield* restore( + Effect.all( + [ + loadThread(scope), + loadActiveWorkspaceBindings(inventory.repositoryCommonDir), + readWorkspaceStatus(targetWorkspacePath), + ], + { concurrency: 3 }, + ), ); if ( mutationProjection.thread.branch !== projection.thread.branch || diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 3edba412b70e..31919c3ac4c6 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -348,10 +348,12 @@ dedicated worktree bound to another thread is rejected, as is mutating a shared 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. If `continuationPrompt` is -present, the service writes the new binding and then durably queues the next turn before the detach -can interrupt the MCP call. The next provider session derives its working directory from the new -thread projection. Same-path branch changes do not detach the session. +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 From 875fd5e4a1832f0ce7632c4b264cc98c0dbe5284 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 12:02:19 -0700 Subject: [PATCH 15/16] fix(mcp): preserve changed handoff worktrees --- .../server/src/mcp/WorktreeMcpService.test.ts | 21 ++++++++++++++++++- apps/server/src/mcp/WorktreeMcpService.ts | 12 ++++++++--- docs/user/source-control.md | 11 +++++----- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 367131a5a5ff..b16a673f14a5 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -1230,6 +1230,24 @@ describe("t3_worktree_handoff", () => { }); }); + 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* () { @@ -3147,7 +3165,8 @@ describe("t3_thread_checkout", () => { startImmediately: true, }); yield* Deferred.await(entered); - yield* Fiber.interrupt(first); + first.interruptUnsafe(); + yield* Deferred.succeed(release, undefined); const interrupted = yield* Fiber.await(first); expect(Exit.isFailure(interrupted)).toBe(true); expect(harness.createRef).not.toHaveBeenCalled(); diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index df0c7c4ff43c..023099553f30 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -475,6 +475,10 @@ const make = Effect.gen(function* () { worktreeBaseRef = resolvedRemoteBase.commitSha; } + 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; @@ -603,14 +607,15 @@ const make = Effect.gen(function* () { threadManagement.getThreadProjection(scope.threadId), loadWorktrees(worktreePath), readWorkspaceStatus(worktreePath), + gitWorkflow.resolveCommit({ cwd: worktreePath, revision: "HEAD" }), ], - { concurrency: 4 }, + { concurrency: 5 }, ), ); if (Exit.isFailure(verificationExit)) { return "not_possible" as const; } - const [bindings, callerProjection, worktreeInventory, worktreeStatus] = + const [bindings, callerProjection, worktreeInventory, worktreeStatus, currentCommit] = verificationExit.value; const competingBinding = bindings.some( ([thread, workspacePath]) => @@ -624,7 +629,8 @@ const make = Effect.gen(function* () { worktreeInventory.currentWorktreeRoot === worktreePath && worktreeStatus.isRepo && !worktreeStatus.hasWorkingTreeChanges && - worktreeStatus.refName === worktree.worktree.refName; + worktreeStatus.refName === worktree.worktree.refName && + currentCommit.commitSha === expectedCreationCommit.commitSha; if (competingBinding || !callerStillUnbound || !checkoutStillCreatedByThisCall) { return "not_possible" as const; } diff --git a/docs/user/source-control.md b/docs/user/source-control.md index dd16e19cb09b..a1d6fd44678e 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -81,11 +81,12 @@ the thread's saved branch and worktree path. It refuses to switch a dirty checko 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 restarts the agent session in the selected checkout. The agent can -queue a continuation before that restart, so longer work resumes without needing the browser to -stay open. 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. +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 From 1ed8ca43207f300ed83e1f991be080eb0b9f3e54 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 12:17:19 -0700 Subject: [PATCH 16/16] test(mcp): group worktree inventory coverage --- .../server/src/mcp/WorktreeMcpService.test.ts | 356 +++++++----------- 1 file changed, 131 insertions(+), 225 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index b16a673f14a5..fb93b3973f61 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -2017,6 +2017,137 @@ describe("t3_worktree_list", () => { 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); + }); + }); + + it.effect("resolves only nested binding candidates for the selected worktree page", () => { + const firstWorktree = "/worktrees/project-a"; + const secondWorktree = "/worktrees/project-b"; + const firstNestedPath = `${firstWorktree}/packages/app`; + const secondNestedPath = `${secondWorktree}/packages/app`; + const listedWorktrees = [ + { path: workspaceRoot, refName: "dev" }, + { path: firstWorktree, refName: "feature/a" }, + { path: secondWorktree, refName: "feature/b" }, + ]; + const harness = makeHarness({ + worktrees: listedWorktrees, + projectThreads: [ + { + id: ThreadId.make("thread-off-page-nested-binding"), + title: "Off-page nested binding", + branch: "feature/a", + worktreePath: firstNestedPath, + }, + { + id: threadId, + title: "Selected-page nested binding", + branch: "feature/b", + worktreePath: secondNestedPath, + }, + ], + worktreeInventories: { + [firstNestedPath]: { + repositoryCommonDir: "/repo/.git", + currentWorktreeRoot: firstWorktree, + worktrees: listedWorktrees, + }, + [secondNestedPath]: { + repositoryCommonDir: "/repo/.git", + currentWorktreeRoot: secondWorktree, + worktrees: listedWorktrees, + }, + }, + }); + return Effect.gen(function* () { + const result = yield* runList(harness, { cursor: 2, limit: 1, bindingLimit: 1 }); + + expect(result.bindingPathResolution).toEqual({ + totalCandidates: 1, + attemptedCandidates: 1, + truncated: false, + complete: true, + }); + expect(result.worktrees[0]).toMatchObject({ + path: secondWorktree, + bindingCount: 1, + bindings: [expect.objectContaining({ threadId })], + }); + expect(harness.listWorktrees).toHaveBeenCalledTimes(2); + expect(harness.listWorktrees).not.toHaveBeenCalledWith(firstNestedPath); + expect(harness.listWorktrees).toHaveBeenCalledWith(secondNestedPath); + }); + }); + + it.effect("reports incomplete binding counts when a candidate inventory read fails", () => { + const nestedPath = `${workspaceRoot}/packages/unreadable`; + const harness = makeHarness({ + worktrees: [{ path: workspaceRoot, refName: "dev" }], + projectThreads: [ + { + id: threadId, + title: "Unreadable nested binding", + branch: "dev", + worktreePath: nestedPath, + }, + ], + worktreeInventoryFailsFor: new Set([nestedPath]), + }); + return Effect.gen(function* () { + const result = yield* runList(harness, { limit: 1 }); + + expect(result.bindingPathResolution).toEqual({ + totalCandidates: 1, + attemptedCandidates: 1, + truncated: false, + complete: false, + }); + expect(result.worktrees[0]).toMatchObject({ + path: workspaceRoot, + bindingCount: 0, + bindings: [], + }); + }); + }); }); describe("t3_thread_checkout", () => { @@ -2655,231 +2786,6 @@ describe("t3_thread_checkout", () => { }); }); - 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); - }); - }); - - it.effect("resolves only nested binding candidates for the selected worktree page", () => { - const firstWorktree = "/worktrees/project-a"; - const secondWorktree = "/worktrees/project-b"; - const firstNestedPath = `${firstWorktree}/packages/app`; - const secondNestedPath = `${secondWorktree}/packages/app`; - const listedWorktrees = [ - { path: workspaceRoot, refName: "dev" }, - { path: firstWorktree, refName: "feature/a" }, - { path: secondWorktree, refName: "feature/b" }, - ]; - const harness = makeHarness({ - worktrees: listedWorktrees, - projectThreads: [ - { - id: ThreadId.make("thread-off-page-nested-binding"), - title: "Off-page nested binding", - branch: "feature/a", - worktreePath: firstNestedPath, - }, - { - id: threadId, - title: "Selected-page nested binding", - branch: "feature/b", - worktreePath: secondNestedPath, - }, - ], - worktreeInventories: { - [firstNestedPath]: { - repositoryCommonDir: "/repo/.git", - currentWorktreeRoot: firstWorktree, - worktrees: listedWorktrees, - }, - [secondNestedPath]: { - repositoryCommonDir: "/repo/.git", - currentWorktreeRoot: secondWorktree, - worktrees: listedWorktrees, - }, - }, - }); - return Effect.gen(function* () { - const result = yield* runList(harness, { cursor: 2, limit: 1, bindingLimit: 1 }); - - expect(result.bindingPathResolution).toEqual({ - totalCandidates: 1, - attemptedCandidates: 1, - truncated: false, - complete: true, - }); - expect(result.worktrees[0]).toMatchObject({ - path: secondWorktree, - bindingCount: 1, - bindings: [expect.objectContaining({ threadId })], - }); - expect(harness.listWorktrees).toHaveBeenCalledTimes(2); - expect(harness.listWorktrees).not.toHaveBeenCalledWith(firstNestedPath); - expect(harness.listWorktrees).toHaveBeenCalledWith(secondNestedPath); - }); - }); - - it.effect("reports incomplete binding counts when a candidate inventory read fails", () => { - const nestedPath = `${workspaceRoot}/packages/unreadable`; - const harness = makeHarness({ - worktrees: [{ path: workspaceRoot, refName: "dev" }], - projectThreads: [ - { - id: threadId, - title: "Unreadable nested binding", - branch: "dev", - worktreePath: nestedPath, - }, - ], - worktreeInventoryFailsFor: new Set([nestedPath]), - }); - return Effect.gen(function* () { - const result = yield* runList(harness, { limit: 1 }); - - expect(result.bindingPathResolution).toEqual({ - totalCandidates: 1, - attemptedCandidates: 1, - truncated: false, - complete: false, - }); - expect(result.worktrees[0]).toMatchObject({ - path: workspaceRoot, - bindingCount: 0, - bindings: [], - }); - }); - }); - - 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" }], - archivedProjectThread: { - id: archivedThreadId, - title: "Archived checkout owner", - branch: "dev", - worktreePath: workspaceRoot, - }, - }); - 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, - }), - ]), - }); - }); - }); - - 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("rejects a physical worktree bound through another project alias", () => { const targetPath = "/worktrees/project/cross-project"; const otherProjectRoot = "/aliases/other-project";