From bdbd1bdac983ff921c276aac183a6a687ff95293 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 14:49:27 -0700 Subject: [PATCH 01/19] feat(mcp): let agents change thread workspaces --- .../server/src/mcp/WorktreeMcpService.test.ts | 1105 +++++++++++------ apps/server/src/mcp/WorktreeMcpService.ts | 983 ++++++++++++--- .../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, 1711 insertions(+), 597 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 17534520a585..81e6d9dd48b3 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -5,27 +5,22 @@ import { EnvironmentId, GitManagerError, 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, @@ -39,8 +34,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"; @@ -82,51 +75,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", @@ -147,12 +95,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; @@ -168,32 +121,10 @@ 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 localStatusFailure?: "typed" | "defect" | "interrupt"; readonly projectThreads?: ReadonlyArray<{ @@ -201,14 +132,13 @@ interface HarnessOptions { 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 = {}) => { @@ -243,6 +173,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({ @@ -267,6 +205,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 })); @@ -285,71 +230,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 @@ -369,84 +283,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 }) => { @@ -471,19 +359,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 }) => { @@ -527,43 +432,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, @@ -574,7 +447,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), @@ -598,8 +483,10 @@ const makeHarness = (options: HarnessOptions = {}) => { deleteLocalBranch, localStatus, listRefs, - listWorktrees, listProjectThreads, + switchRef, + createRef, + invalidateLocalStatus, runForThread, }; }; @@ -638,13 +525,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", () => { @@ -806,17 +699,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(); }); }); @@ -983,11 +892,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({ @@ -1075,19 +1017,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, @@ -1125,21 +1075,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); }); }); @@ -1231,34 +1180,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* () { @@ -1273,7 +1194,6 @@ describe("t3_worktree_status", () => { actualWorkspace: { workspacePath: workspaceRoot, branch: "dev", - isRepo: true, hasWorkingTreeChanges: false, }, agreement: "branch_mismatch", @@ -1311,32 +1231,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* () { @@ -1363,13 +1293,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, @@ -1382,12 +1316,18 @@ describe("t3_worktree_list", () => { [worktreePath]: { branch: "feature/list", dirty: true }, }, projectThreads: [ - { id: threadId, title: "Caller", branch: "dev", worktreePath: null }, + { + id: threadId, + title: "Worktree test thread", + branch: "dev", + worktreePath: null, + }, { id: otherThreadId, title: "Other thread", branch: "feature/list", worktreePath, + status: "running", active: true, }, ], @@ -1395,10 +1335,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, @@ -1407,12 +1343,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, @@ -1420,7 +1354,6 @@ describe("t3_worktree_list", () => { callingThread: true, }, ], - bindingCount: 1, }, { path: worktreePath, @@ -1429,8 +1362,6 @@ describe("t3_worktree_list", () => { isRepo: true, isProjectRoot: false, hasWorkingTreeChanges: true, - availability: "available", - statusError: null, bindings: [ { threadId: otherThreadId, @@ -1442,133 +1373,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("reports typed status failures without swallowing defects or interruption", () => - Effect.gen(function* () { - const typedResult = yield* runList(makeHarness({ localStatusFailure: "typed" }), { - limit: 1, - }); - expect(typedResult.worktrees[0]).toMatchObject({ availability: "missing" }); - - const defectExit = yield* Effect.exit( - runList(makeHarness({ localStatusFailure: "defect" }), { limit: 1 }), - ); - expect(Exit.isFailure(defectExit)).toBe(true); - if (Exit.isFailure(defectExit)) { - expect(Cause.hasDies(defectExit.cause)).toBe(true); - } - - const interruptExit = yield* Effect.exit( - runList(makeHarness({ localStatusFailure: "interrupt" }), { limit: 1 }), - ); - expect(Exit.isFailure(interruptExit)).toBe(true); - if (Exit.isFailure(interruptExit)) { - expect(Cause.hasInterruptsOnly(interruptExit.cause)).toBe(true); - } - }), - ); - - 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" }); }); }); @@ -1796,6 +1843,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 a0bce0c8503a..a1c6d84d33c8 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,6 +20,7 @@ import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -41,13 +45,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 { @@ -65,7 +76,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; @@ -74,10 +84,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") @@ -126,37 +135,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 @@ -166,34 +186,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) { @@ -205,6 +266,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( @@ -216,13 +293,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.`, ); } @@ -283,7 +358,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 @@ -323,8 +398,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({ @@ -335,62 +416,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 @@ -400,34 +536,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) @@ -489,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", @@ -497,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))), ); }), ); @@ -511,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, @@ -562,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, @@ -760,14 +866,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 88fe7598325f..9427a4c10dd5 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1487,6 +1487,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 03c48b1b4d65..4c8f9ffec1b2 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -912,6 +912,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 87c75cf4bad6..48898e8bf4dc 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -45,7 +45,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. @@ -353,6 +353,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 @@ -396,8 +424,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 c6f68d1d0cde..f8faf099a9d9 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -70,16 +70,22 @@ messages, review titles, and descriptions from your changes. Choose the writing style and model in **Settings → Source Control**. **Repository conventions** uses the project's instructions and recent commit subjects. -### 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. ## Review and merge diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 541e65767b0b..77f3bcc521fe 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2139,6 +2139,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 f9dd44cf5b9b..1f0cb3495261 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -55,6 +55,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 8da966936fbf7a724ee07dc669c2ec20c5e8df36 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 15:42:20 -0700 Subject: [PATCH 02/19] 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 81e6d9dd48b3..9fc3ffdc63c7 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -5,6 +5,7 @@ import { EnvironmentId, GitManagerError, type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadShell, type Project, ProjectId, ProviderInstanceId, @@ -104,7 +105,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; @@ -124,6 +128,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 localStatusFailure?: "typed" | "defect" | "interrupt"; @@ -135,9 +145,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; } @@ -189,6 +211,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 && @@ -212,9 +256,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") { @@ -236,34 +297,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 @@ -312,28 +398,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; @@ -363,27 +477,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) @@ -418,8 +552,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 = @@ -429,6 +563,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), ), ); @@ -437,6 +573,7 @@ const makeHarness = (options: HarnessOptions = {}) => { Layer.mergeAll( Layer.mock(ThreadManagementService)({ dispatch, + getShellSnapshot, getThreadProjection, listProjectThreads, sendToThread, @@ -449,6 +586,7 @@ const makeHarness = (options: HarnessOptions = {}) => { }), Layer.mock(GitWorkflowService.GitWorkflowService)({ listRefs, + listWorktrees, listLocalBranchNames, localStatus, fetchRemote, @@ -483,6 +621,7 @@ const makeHarness = (options: HarnessOptions = {}) => { deleteLocalBranch, localStatus, listRefs, + listWorktrees, listProjectThreads, switchRef, createRef, @@ -525,10 +664,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 = ( @@ -987,7 +1129,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 @@ -998,7 +1145,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); @@ -1335,6 +1482,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, @@ -1343,6 +1494,8 @@ describe("t3_worktree_list", () => { isRepo: true, isProjectRoot: true, hasWorkingTreeChanges: false, + availability: "available", + statusError: null, bindings: [ { threadId, @@ -1354,6 +1507,7 @@ describe("t3_worktree_list", () => { callingThread: true, }, ], + bindingCount: 1, }, { path: worktreePath, @@ -1362,6 +1516,8 @@ describe("t3_worktree_list", () => { isRepo: true, isProjectRoot: false, hasWorkingTreeChanges: true, + availability: "available", + statusError: null, bindings: [ { threadId: otherThreadId, @@ -1373,10 +1529,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", () => { @@ -1430,6 +1660,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 }, @@ -1566,6 +1891,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 }, @@ -1844,6 +2214,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 }, @@ -1912,6 +2320,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 }, @@ -1960,7 +2387,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( @@ -1982,6 +2409,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 }, @@ -2009,7 +2462,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( @@ -2091,6 +2544,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 a1c6d84d33c8..1d91ba0251a0 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, @@ -21,6 +22,7 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; 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"; @@ -45,6 +47,7 @@ export class WorktreeMcpService extends Context.Service< ) => Effect.Effect; readonly listWorktrees: ( scope: McpInvocationScope, + input: WorktreeMcpListInput, ) => Effect.Effect; readonly checkout: ( scope: McpInvocationScope, @@ -76,6 +79,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; @@ -135,10 +139,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, @@ -146,14 +157,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 }), }) @@ -166,11 +176,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> => @@ -178,6 +195,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) @@ -265,17 +337,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, @@ -879,12 +946,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, { @@ -903,10 +970,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, @@ -915,17 +981,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), @@ -936,12 +1020,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]), @@ -954,14 +1033,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.`, ); @@ -969,7 +1051,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) { @@ -1000,15 +1082,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; } @@ -1043,7 +1125,7 @@ const make = Effect.gen(function* () { const selectedWorktreePath = selectedRef?.isRemote === true || selectedRef?.worktreePath == null ? null - : normalizePath(selectedRef.worktreePath); + : selectedRef.worktreePath; if ( !createBranch && requestedBranch !== undefined && @@ -1058,16 +1140,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( @@ -1076,7 +1160,7 @@ const make = Effect.gen(function* () { ); } if ( - targetWorkspacePath !== projectWorkspaceRoot && + targetWorkspacePath !== projectWorktreeRoot && (targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) && otherBindings.length > 0 ) { @@ -1086,7 +1170,7 @@ const make = Effect.gen(function* () { ); } if ( - targetWorkspacePath === projectWorkspaceRoot && + targetWorkspacePath === projectWorktreeRoot && shouldMutateCheckout && otherBindings.length > 0 ) { @@ -1103,285 +1187,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)), + ), + ); }), ); }); @@ -1415,6 +1677,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 b0bb6ee6ef0b..9238a4b5a6d8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1560,6 +1560,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 f231073028fbf49cb66eba84a60c03a82f928294 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 15:44:08 -0700 Subject: [PATCH 03/19] 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 48898e8bf4dc..074d79e832f0 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -378,8 +378,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 f8faf099a9d9..ddeb07818520 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -85,7 +85,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. ## Review and merge From a98ab4e7112b7bc60ab351119d519c9ed9417a03 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:03:50 -0700 Subject: [PATCH 04/19] 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 0e634fbf1a82..9fdce49044fd 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -90,6 +90,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; @@ -342,6 +346,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 9fc3ffdc63c7..a3b898d2c9f0 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -16,12 +16,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, @@ -35,6 +37,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"; @@ -118,6 +122,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; @@ -134,9 +139,15 @@ 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 localStatusFailure?: "typed" | "defect" | "interrupt"; + readonly dirtyOnLocalStatusCall?: number; readonly projectThreads?: ReadonlyArray<{ readonly id: ThreadId; readonly title: string; @@ -154,6 +165,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; @@ -291,12 +308,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, @@ -340,6 +361,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)), ); @@ -348,7 +385,7 @@ const makeHarness = (options: HarnessOptions = {}) => { schemaVersion: 1, snapshotSequence: 1, threads: projectThreadShells, - archivedThreads: [], + archivedThreads: archivedThreadShells, } as never), ); const removeWorktree = vi.fn((_: unknown) => @@ -369,6 +406,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 ?? { @@ -441,14 +487,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 }) => { @@ -473,7 +521,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: @@ -482,7 +530,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 }, }); }); @@ -568,6 +617,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( @@ -584,20 +667,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), @@ -616,6 +686,7 @@ const makeHarness = (options: HarnessOptions = {}) => { sendToThread, fetchRemote, resolveRemoteTrackingCommit, + resolveCommit, createWorktree, removeWorktree, deleteLocalBranch, @@ -867,6 +938,7 @@ describe("t3_worktree_handoff", () => { worktreePath: "/worktrees/project/feature/second", expectedBranch: "feature/existing", expectedWorktreePath: "/worktrees/project/existing", + expectedArchived: false, }); }); }); @@ -1327,6 +1399,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* () { @@ -1394,6 +1494,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 }, @@ -1590,6 +1716,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"); @@ -1697,6 +1849,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 }, @@ -1728,7 +1920,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, @@ -1743,12 +1935,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(); }); @@ -1782,6 +1972,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({ @@ -2252,6 +2466,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 }, @@ -2435,6 +2709,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 }, @@ -2457,7 +2759,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, @@ -2470,11 +2772,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 1d91ba0251a0..7b3241dc9692 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -218,30 +218,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())); + }), }), ), ), @@ -542,6 +539,7 @@ const make = Effect.gen(function* () { worktreePath, expectedBranch: projection.thread.branch, expectedWorktreePath: projection.thread.worktreePath, + expectedArchived: false, }), ); if (Exit.isFailure(dispatchExit)) { @@ -1286,7 +1284,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) { @@ -1298,68 +1395,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", }, ); } @@ -1369,7 +1426,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.`, @@ -1388,46 +1445,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", @@ -1450,6 +1477,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 @@ -1461,52 +1528,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) @@ -1545,6 +1566,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 9427a4c10dd5..3df0e6f5f3fc 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1498,6 +1498,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 4c8f9ffec1b2..97905828fd16 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1048,6 +1048,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 37a45608c900..cbe8b992aa45 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -129,6 +129,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 9238a4b5a6d8..a3f5f044505e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5,6 +5,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; 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"; @@ -1798,6 +1799,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("allows worktree removal to run longer than the default command timeout", () => Effect.gen(function* () { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 05bde710acb5..aea6c510ba94 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3239,6 +3239,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 77f3bcc521fe..0cd08e823553 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2141,6 +2141,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 be4b3ba422bf049f7b7b7123414654175a079822 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:05:32 -0700 Subject: [PATCH 05/19] 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 7b3241dc9692..01a60a0f85cc 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -1140,9 +1140,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 afff7ee7a828933099dabb5d2b18398a5ce13f7b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:15:08 -0700 Subject: [PATCH 06/19] 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 a3b898d2c9f0..3bac1486d3a8 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -108,6 +108,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; @@ -173,6 +174,7 @@ interface HarnessOptions { }; readonly switchRefFails?: boolean; readonly switchRefFailsAfterMutation?: boolean; + readonly switchRefFailureBranch?: string | null; readonly switchRefRollbackFails?: boolean; readonly switchRefGate?: Effect.Effect; readonly switchRefResultBranch?: string | null; @@ -250,6 +252,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 && @@ -542,7 +553,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 ( @@ -1916,9 +1933,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({ @@ -1972,7 +2013,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, @@ -1987,12 +2028,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(); }); }); @@ -2472,7 +2512,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, @@ -2493,6 +2536,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({ @@ -2710,8 +2786,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({ @@ -2759,12 +2835,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 01a60a0f85cc..1e22bede897f 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -195,6 +195,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 @@ -219,15 +241,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 || @@ -247,14 +269,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), @@ -1285,19 +1299,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; @@ -1364,30 +1378,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({ @@ -1396,8 +1440,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 }), @@ -1405,7 +1447,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") { @@ -1499,8 +1554,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 cbe8b992aa45..37a45608c900 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -129,7 +129,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 a3f5f044505e..9238a4b5a6d8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5,7 +5,6 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; 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"; @@ -1799,35 +1798,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("allows worktree removal to run longer than the default command timeout", () => Effect.gen(function* () { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index aea6c510ba94..05bde710acb5 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3239,32 +3239,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 bf1f859968b1b04fc3128464433d1ac3029bd025 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:22:36 -0700 Subject: [PATCH 07/19] 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 3bac1486d3a8..d0a19a934987 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -125,7 +125,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; @@ -404,11 +403,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, ); @@ -1168,11 +1163,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(); }); }); @@ -1263,23 +1254,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 1e22bede897f..f85b76640de1 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -474,9 +474,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( @@ -485,15 +485,6 @@ const make = Effect.gen(function* () { createdWorktreeRemoved = true; }), ), - Effect.andThen( - Effect.suspend(() => - gitWorkflow.deleteLocalBranch({ - cwd: projectCwd, - refName: worktree.worktree.refName, - force: true, - }), - ), - ), ), ); From e0d12fe3fb91a0378ed81b83cc10b8d6aea3cbea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:31:04 -0700 Subject: [PATCH 08/19] 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 d0a19a934987..1901607131a7 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -2552,6 +2552,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 f85b76640de1..9cbd800f8b07 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -252,11 +252,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 4ffce0beffb82e3c58de13c0fe251301a90e7ac0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:34:41 -0700 Subject: [PATCH 09/19] 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 1901607131a7..180dafcc7b41 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -2807,6 +2807,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 9cbd800f8b07..231f3f26000e 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -1549,17 +1549,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 1ac65ff3f2ec42f520fc8998137d4de6e128a722 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:53:36 -0700 Subject: [PATCH 10/19] 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 180dafcc7b41..01c29c8387bf 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -2084,6 +2084,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 231f3f26000e..2ee9a69983df 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -354,17 +354,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)) { @@ -959,7 +986,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, { @@ -983,7 +1010,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, @@ -993,7 +1020,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, @@ -1001,28 +1028,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 }, ); @@ -1068,6 +1112,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": { @@ -1095,9 +1154,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; @@ -1687,7 +1747,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 b28f4cc847267a84f8cdbf37ac6a594785242c99 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 10:19:41 -0700 Subject: [PATCH 11/19] 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 01c29c8387bf..dbd76982d1ea 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -9,11 +9,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"; @@ -80,6 +82,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", @@ -137,6 +184,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; @@ -346,46 +406,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)), @@ -495,14 +567,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 }) => { @@ -1759,6 +1833,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 2ee9a69983df..498441ca5508 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -191,9 +191,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 @@ -220,10 +225,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, @@ -1210,11 +1230,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 ddeb07818520..4df320242bc9 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -73,8 +73,12 @@ uses the project's instructions and recent commit subjects. ### 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 055fd400781b925d119b68d87e3dd993a0c0eefa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:04:13 -0700 Subject: [PATCH 12/19] 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 dbd76982d1ea..f005c5eb1ed7 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -2365,6 +2365,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 498441ca5508..2ca88ee97cb1 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -1080,13 +1080,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 074d79e832f0..d3e2c2e8dd94 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -45,10 +45,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 f942baef60a99107f31bfc93181f9d61cac4a42f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:17:15 -0700 Subject: [PATCH 13/19] 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 d3e2c2e8dd94..4234e6f10b1f 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -47,8 +47,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 951c085568851510c2ab6cbbf3d1d543ca78ebf0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:57:25 -0700 Subject: [PATCH 14/19] 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 f005c5eb1ed7..42789f60ee99 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -23,6 +23,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"; @@ -171,6 +172,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<{ @@ -239,12 +241,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 @@ -253,7 +257,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: [] }; + }), ), ), ); @@ -344,13 +357,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, @@ -466,7 +485,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), ); @@ -491,7 +513,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( @@ -1225,33 +1247,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(); }); }); @@ -1385,6 +1406,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* () { @@ -3049,6 +3139,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 2ca88ee97cb1..1c0e8640fc09 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -91,6 +91,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") @@ -475,6 +477,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 @@ -497,7 +500,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). @@ -511,19 +593,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* () { @@ -556,16 +670,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", }, ); } @@ -616,16 +730,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); + } + }), + ), ); }); @@ -1285,8 +1407,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( @@ -1299,14 +1424,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 || @@ -1385,17 +1512,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; @@ -1474,13 +1605,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 4234e6f10b1f..68e09e2e257b 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -373,10 +373,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 80f4aabb1fde7efc6807f81d84766e4a365afc69 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 12:02:19 -0700 Subject: [PATCH 15/19] 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 42789f60ee99..9cd9acbac7d8 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -1247,6 +1247,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* () { @@ -3164,7 +3182,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 1c0e8640fc09..c4eac1b76c93 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -476,6 +476,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; @@ -604,14 +608,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]) => @@ -625,7 +630,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 4df320242bc9..62abd2e46759 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -86,11 +86,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. ## Review and merge From 14449f24783a7f9962ee93f95842987a2d8751f0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 12:17:19 -0700 Subject: [PATCH 16/19] 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 9cd9acbac7d8..63d43571b4df 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -2034,6 +2034,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", () => { @@ -2672,231 +2803,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"; From 061941106d14f87f5cee9f2094a1e1306be9dcf6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 13:27:53 -0700 Subject: [PATCH 17/19] fix(vcs): reject option-like branch names --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 21 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 11 +++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 9238a4b5a6d8..15238bd14231 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1544,6 +1544,27 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("rejects option-like branch names without changing upstream configuration", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + + const upstreamBefore = yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]); + const error = yield* driver + .createRef({ cwd, refName: "--unset-upstream" }) + .pipe(Effect.flip); + + assert.equal(error.operation, "GitVcsDriver.createRef.validate"); + assert.equal(yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), upstreamBefore); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), initialBranch); + }), + ); + it.effect("returns the existing refName when rename source and target match", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 05bde710acb5..4f92cc7f56c1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3355,7 +3355,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const createRef: GitVcsDriver.GitVcsDriver["Service"]["createRef"] = Effect.fn("createRef")( function* (input) { - yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], { + yield* executeGit( + "GitVcsDriver.createRef.validate", + input.cwd, + ["check-ref-format", "--branch", input.refName], + { + timeoutMs: 5_000, + fallbackErrorDetail: "invalid git branch name", + }, + ); + yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", "--", input.refName], { timeoutMs: 10_000, fallbackErrorDetail: "git branch create failed", }); From f45e6866efae4345bf8d3a3d9a80a9fba89393a0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:10:02 -0700 Subject: [PATCH 18/19] test(vcs): match guarded branch creation --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 15238bd14231..b38c42045210 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -399,7 +399,7 @@ it.effect("invalidates a ref snapshot when a mutation fails after changing Git", if (!ChildProcess.isStandardCommand(command)) { return yield* Effect.die("expected a standard Git command"); } - if (command.args[0] === "branch" && command.args[1] === "feature/partial-failure") { + if (command.args[0] === "branch" && command.args.includes("feature/partial-failure")) { const handle = yield* delegate.spawn(command); yield* handle.exitCode; return makeNonRepositoryHandle(); @@ -1677,7 +1677,6 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const cwd = yield* makeTmpDir(); yield* initRepoWithCommit(cwd); const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); - const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const worktreePath = pathService.join(worktreesRoot, "linked\nworktree"); const driver = yield* GitVcsDriver.GitVcsDriver; From 84f260db922bef1a7b54624c2e2f9bc6805f8094 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:29:04 -0700 Subject: [PATCH 19/19] fix(mcp): refresh status after checkout --- .../server/src/mcp/WorktreeMcpService.test.ts | 27 +++++++++++++++++++ apps/server/src/mcp/WorktreeMcpService.ts | 16 ++++++----- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 63d43571b4df..afff1aa431c1 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -805,6 +805,7 @@ const makeHarness = (options: HarnessOptions = {}) => { switchRef, createRef, invalidateLocalStatus, + refreshStatus, runForThread, }; }; @@ -1898,6 +1899,31 @@ describe("t3_worktree_list", () => { }); }); + it.effect("reports typed status failures without swallowing defects or interruption", () => + Effect.gen(function* () { + const typedResult = yield* runList(makeHarness({ localStatusFailure: "typed" }), { + limit: 1, + }); + expect(typedResult.worktrees[0]).toMatchObject({ availability: "missing" }); + + const defectExit = yield* Effect.exit( + runList(makeHarness({ localStatusFailure: "defect" }), { limit: 1 }), + ); + expect(Exit.isFailure(defectExit)).toBe(true); + if (Exit.isFailure(defectExit)) { + expect(Cause.hasDies(defectExit.cause)).toBe(true); + } + + const interruptExit = yield* Effect.exit( + runList(makeHarness({ localStatusFailure: "interrupt" }), { limit: 1 }), + ); + expect(Exit.isFailure(interruptExit)).toBe(true); + if (Exit.isFailure(interruptExit)) { + expect(Cause.hasInterruptsOnly(interruptExit.cause)).toBe(true); + } + }), + ); + it.effect("marks a stale checkout missing when status reports a non-repository path", () => { const stalePath = "/worktrees/project/stale"; const harness = makeHarness({ @@ -2215,6 +2241,7 @@ describe("t3_thread_checkout", () => { expect(harness.switchRef.mock.invocationCallOrder[0]).toBeLessThan( harness.dispatch.mock.invocationCallOrder[0]!, ); + expect(harness.refreshStatus).toHaveBeenCalledWith(workspaceRoot); }); }); diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index c4eac1b76c93..ec7ab72edcf6 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -20,7 +20,6 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -327,12 +326,10 @@ const make = Effect.gen(function* () { creationSource: "mcp", }) .pipe( - Effect.map( - (sendResult): WorktreeMcpContinuationStatus => ({ - status: "scheduled", - delivery: sendResult.delivery, - }), - ), + 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", { @@ -1913,6 +1910,11 @@ const make = Effect.gen(function* () { workspacePath: targetWorkspacePath, }) : ({ status: "skipped" } as const); + if (checkoutAction === "switched" || checkoutAction === "created") { + yield* vcsStatusBroadcaster + .refreshStatus(targetWorkspacePath) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach); + } const previous = { workspacePath: currentWorkspacePath ?? recordedWorkspacePath, recordedBranch: projection.thread.branch,