From 6a407b72ea524adf9a3ec766242d07b235f719c0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 14:54:59 -0700 Subject: [PATCH 01/16] feat(mcp): expose thread workspace inventory --- .../server/src/mcp/WorktreeMcpService.test.ts | 204 ++++++++++++++++-- apps/server/src/mcp/WorktreeMcpService.ts | 157 +++++++++++++- .../src/mcp/toolkits/worktree/handlers.ts | 6 + .../toolkits/worktree/registration.test.ts | 4 + .../server/src/mcp/toolkits/worktree/tools.ts | 23 +- .../Adapters/ClaudeAdapterV2.test.ts | 6 +- .../Adapters/ClaudeAdapterV2.ts | 6 +- .../orchestrator-mcp-server.md | 17 +- docs/user/source-control.md | 8 + packages/contracts/src/worktreeMcp.ts | 55 ++++- .../shared/src/t3McpToolPresentation.test.ts | 4 + packages/shared/src/t3McpToolPresentation.ts | 1 + 12 files changed, 460 insertions(+), 31 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 193087f2eb3c..e7ce781cdfd7 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -111,6 +111,20 @@ interface HarnessOptions { readonly removeWorktreeFails?: boolean; readonly deleteLocalBranchFails?: boolean; readonly createWorktreeGate?: Effect.Effect; + readonly refs?: ReadonlyArray<{ + readonly name: string; + readonly current: boolean; + readonly isDefault: boolean; + readonly worktreePath: string | null; + }>; + readonly workspaceStatuses?: Readonly>; + readonly projectThreads?: ReadonlyArray<{ + readonly id: ThreadId; + readonly title: string; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly active?: boolean; + }>; } const makeHarness = (options: HarnessOptions = {}) => { @@ -196,6 +210,32 @@ const makeHarness = (options: HarnessOptions = {}) => { : 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.active === true ? "running" : "idle", + activeRunId: item.active === true ? "run-active" : null, + lineage: { relationshipToParent: "none" }, + }) as never, + ), + ), + ); const removeWorktree = vi.fn((_: unknown) => options.removeWorktreeFails ? (Effect.fail("simulated worktree removal failure") as never) @@ -232,32 +272,49 @@ const makeHarness = (options: HarnessOptions = {}) => { const listRefs = vi.fn((input: { readonly query?: string | undefined }) => Effect.succeed({ refs: - options.existingBranchWorktreePath === undefined - ? [] - : [ - { - name: input.query ?? "", - current: false, - isDefault: false, - worktreePath: options.existingBranchWorktreePath, - }, - ], + 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.existingBranchWorktreePath === undefined ? 0 : 1, + totalCount: + options.refs?.length ?? (options.existingBranchWorktreePath === undefined ? 0 : 1), }), ); - const localStatus = vi.fn((_: unknown) => - Effect.succeed({ + const workspaceStatuses = new Map( + Object.entries( + options.workspaceStatuses ?? { + [workspaceRoot]: { + branch: options.currentBranch === undefined ? "dev" : options.currentBranch, + }, + }, + ), + ); + const localStatus = vi.fn((input: { readonly cwd: string }) => { + const current = workspaceStatuses.get(input.cwd); + return Effect.succeed({ isRepo: options.notARepo !== true, hasPrimaryRemote: true, isDefaultRef: false, - refName: options.currentBranch === undefined ? "dev" : options.currentBranch, - hasWorkingTreeChanges: false, + refName: + current?.branch ?? (options.currentBranch === undefined ? "dev" : options.currentBranch), + hasWorkingTreeChanges: current?.dirty ?? false, workingTree: { files: [], insertions: 0, deletions: 0 }, - }), - ); + }); + }); + const invalidateLocalStatus = vi.fn((_: string) => Effect.void); const refreshStatus = vi.fn((_: string) => Effect.die("refreshStatus stub")); const runForThread = vi.fn((input: { readonly worktreePath: string }) => { switch (options.setupScript ?? "started") { @@ -306,6 +363,7 @@ const makeHarness = (options: HarnessOptions = {}) => { Layer.mock(ThreadManagementService)({ dispatch, getThreadProjection, + listProjectThreads, sendToThread, } satisfies Partial), Layer.mock(ProjectService.ProjectService)({ @@ -318,6 +376,7 @@ const makeHarness = (options: HarnessOptions = {}) => { listRefs, listLocalBranchNames, localStatus, + invalidateLocalStatus, fetchRemote, resolveRemoteTrackingCommit, createWorktree, @@ -346,6 +405,8 @@ const makeHarness = (options: HarnessOptions = {}) => { removeWorktree, deleteLocalBranch, localStatus, + listRefs, + listProjectThreads, runForThread, }; }; @@ -384,6 +445,12 @@ const runStatus = (harness: ReturnType) => return yield* service.status(harness.scope); }).pipe(Effect.provide(harness.layer)); +const runList = (harness: ReturnType) => + Effect.gen(function* () { + const service = yield* WorktreeMcpService; + return yield* service.listWorktrees(harness.scope); + }).pipe(Effect.provide(harness.layer)); + describe("t3_worktree_handoff", () => { it.effect("creates a worktree from the current branch and re-points the thread", () => { const harness = makeHarness(); @@ -972,30 +1039,50 @@ describe("t3_worktree_status", () => { const harness = makeHarness({ newWorktreesStartFromOrigin: true }); return Effect.gen(function* () { const result = yield* runStatus(harness); - expect(result).toEqual({ + expect(result).toMatchObject({ attached: false, worktreePath: null, branch: null, projectWorkspaceRoot: workspaceRoot, defaultStartFromOrigin: true, + recordedWorkspace: { branch: null, worktreePath: null }, + actualWorkspace: { + workspacePath: workspaceRoot, + branch: "dev", + isRepo: true, + hasWorkingTreeChanges: false, + }, + agreement: "branch_mismatch", }); }); }); it.effect("reports an attached thread's worktree and branch", () => { + const worktreePath = "/worktrees/project/existing"; const harness = makeHarness({ thread: { - worktreePath: "/worktrees/project/existing", + worktreePath, branch: "feature/existing", }, + refs: [ + { + name: "feature/existing", + current: true, + isDefault: false, + worktreePath, + }, + ], + workspaceStatuses: { [worktreePath]: { branch: "feature/existing" } }, }); return Effect.gen(function* () { const result = yield* runStatus(harness); expect(result).toMatchObject({ attached: true, - worktreePath: "/worktrees/project/existing", + worktreePath, branch: "feature/existing", defaultStartFromOrigin: false, + actualWorkspace: { workspacePath: worktreePath, branch: "feature/existing", isRepo: true }, + agreement: "in_sync", }); }); }); @@ -1025,6 +1112,83 @@ describe("t3_worktree_status", () => { }); }); +describe("t3_worktree_list", () => { + it.effect("reports actual checkout state and durable thread bindings", () => { + 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: "feature/list", + current: false, + isDefault: false, + worktreePath, + }, + ], + workspaceStatuses: { + [workspaceRoot]: { branch: "dev" }, + [worktreePath]: { branch: "feature/list", dirty: true }, + }, + projectThreads: [ + { id: threadId, title: "Caller", branch: "dev", worktreePath: null }, + { + id: otherThreadId, + title: "Other thread", + branch: "feature/list", + worktreePath, + active: true, + }, + ], + }); + return Effect.gen(function* () { + const result = yield* runList(harness); + expect(result.projectWorkspaceRoot).toBe(workspaceRoot); + expect(result.worktrees).toEqual([ + { + path: workspaceRoot, + branch: "dev", + actualBranch: "dev", + isRepo: true, + isProjectRoot: true, + hasWorkingTreeChanges: false, + bindings: [ + { + threadId, + title: "Caller", + status: "idle", + recordedBranch: "dev", + recordedWorktreePath: null, + active: false, + callingThread: true, + }, + ], + }, + { + path: worktreePath, + branch: "feature/list", + actualBranch: "feature/list", + isRepo: true, + isProjectRoot: false, + hasWorkingTreeChanges: true, + bindings: [ + { + threadId: otherThreadId, + title: "Other thread", + status: "running", + recordedBranch: "feature/list", + recordedWorktreePath: worktreePath, + active: true, + callingThread: false, + }, + ], + }, + ]); + }); + }); +}); + describe("WorktreeMcpHandoffInput schema", () => { const decode = Schema.decodeUnknownEffect(WorktreeMcpHandoffInput); diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 4b8803b27f8e..3b57f7dbfe3d 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -1,11 +1,14 @@ import { CommandId, MessageId, + type OrchestrationV2ThreadShell, type ProjectId, + type VcsRef, WorktreeMcpFailure, type WorktreeMcpContinuationStatus, type WorktreeMcpHandoffInput, type WorktreeMcpHandoffResult, + type WorktreeMcpListResult, type WorktreeMcpSetupScriptStatus, type WorktreeMcpStatusResult, } from "@t3tools/contracts"; @@ -35,6 +38,9 @@ export class WorktreeMcpService extends Context.Service< readonly status: ( scope: McpInvocationScope, ) => Effect.Effect; + readonly listWorktrees: ( + scope: McpInvocationScope, + ) => Effect.Effect; } >()("t3/mcp/WorktreeMcpService") {} @@ -115,6 +121,58 @@ const make = Effect.gen(function* () { asOperationFailed("Unable to read server settings"), ); + const normalizePath = (value: string) => path.normalize(path.resolve(value)); + + const threadWorkspacePath = ( + thread: Pick, + projectWorkspaceRoot: string, + ) => normalizePath(thread.worktreePath ?? projectWorkspaceRoot); + + const loadRefs = Effect.fn("WorktreeMcpService.loadRefs")(function* ( + projectWorkspaceRoot: string, + ) { + const refs: Array = []; + let cursor: number | undefined; + let firstPage = true; + do { + const page = yield* gitWorkflow + .listRefs({ + cwd: projectWorkspaceRoot, + refKind: "local", + includeMatchingRemoteRefs: false, + 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 + .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 handoffIds = (scope: McpInvocationScope) => crypto.randomUUIDv4.pipe( Effect.map((uuid) => { @@ -460,21 +518,112 @@ const make = Effect.gen(function* () { yield* requireCapability(scope); const projection = yield* loadThread(scope); const project = yield* loadProject(scope, projection.thread.projectId); - - const defaultStartFromOrigin = yield* readDefaultStartFromOrigin; + const projectWorkspaceRoot = normalizePath(project.workspaceRoot); + const workspacePath = normalizePath(projection.thread.worktreePath ?? projectWorkspaceRoot); + const [defaultStartFromOrigin, actual, refs] = yield* Effect.all( + [ + readDefaultStartFromOrigin, + readWorkspaceStatus(workspacePath), + loadRefs(projectWorkspaceRoot), + ], + { 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 result: WorktreeMcpStatusResult = { attached: projection.thread.worktreePath !== null, worktreePath: projection.thread.worktreePath, branch: projection.thread.branch, - projectWorkspaceRoot: project.workspaceRoot, + projectWorkspaceRoot, defaultStartFromOrigin, + recordedWorkspace: { + branch: projection.thread.branch, + worktreePath: projection.thread.worktreePath, + }, + actualWorkspace: { + workspacePath, + isRepo: actual.isRepo, + branch: actual.refName, + hasWorkingTreeChanges: actual.hasWorkingTreeChanges, + }, + agreement, }; return result; }, ); - return WorktreeMcpService.of({ handoff, status }); + const listWorktrees: WorktreeMcpService["Service"]["listWorktrees"] = Effect.fn( + "WorktreeMcpService.listWorktrees", + )(function* (scope) { + yield* requireCapability(scope); + const projection = yield* loadThread(scope); + const project = yield* loadProject(scope, projection.thread.projectId); + const projectWorkspaceRoot = normalizePath(project.workspaceRoot); + const [refs, threads] = yield* Effect.all( + [loadRefs(projectWorkspaceRoot), loadProjectThreads(projection.thread.projectId)], + { concurrency: 2 }, + ); + + const branchByWorkspacePath = new Map([[projectWorkspaceRoot, null]]); + for (const ref of refs) { + if (ref.isRemote === true || ref.worktreePath === null) continue; + branchByWorkspacePath.set(normalizePath(ref.worktreePath), ref.name); + } + + const worktrees = yield* Effect.forEach( + [...branchByWorkspacePath.entries()], + ([workspacePath, branch]) => + readWorkspaceStatus(workspacePath).pipe( + Effect.map((actual) => ({ + path: workspacePath, + branch, + actualBranch: actual.refName, + isRepo: actual.isRepo, + isProjectRoot: workspacePath === projectWorkspaceRoot, + hasWorkingTreeChanges: actual.hasWorkingTreeChanges, + bindings: threads + .filter( + (thread) => threadWorkspacePath(thread, projectWorkspaceRoot) === workspacePath, + ) + .map((thread) => ({ + threadId: thread.id, + title: thread.title, + status: thread.status, + recordedBranch: thread.branch, + recordedWorktreePath: thread.worktreePath, + active: thread.activeRunId !== null, + callingThread: thread.id === scope.threadId, + })), + })), + ), + { concurrency: 8 }, + ); + + return { + projectWorkspaceRoot, + worktrees: worktrees.toSorted( + (left, right) => + Number(right.isProjectRoot) - Number(left.isProjectRoot) || + left.path.localeCompare(right.path), + ), + } satisfies WorktreeMcpListResult; + }); + + return WorktreeMcpService.of({ handoff, status, listWorktrees }); }); export const layer: Layer.Layer< diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index b75e0c5dfbef..82aca4d36df7 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -17,6 +17,12 @@ const handlers = { const service = yield* WorktreeMcpService; return yield* service.status(scope); }), + t3_worktree_list: () => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* WorktreeMcpService; + return yield* service.listWorktrees(scope); + }), } satisfies Parameters[0]; export const WorktreeToolkitHandlersLive = WorktreeToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 3300a869fe67..fd10d3f589a0 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -110,6 +110,7 @@ it.effect("production mcp layer lists worktree tools over http", () => const toolNames = tools.map((tool) => tool.name); expect(toolNames).toContain("t3_worktree_handoff"); expect(toolNames).toContain("t3_worktree_status"); + expect(toolNames).toContain("t3_worktree_list"); // The worktree registration merges alongside the other toolkits rather // than replacing them. expect(toolNames).toContain("preview_status"); @@ -125,6 +126,9 @@ it.effect("production mcp layer lists worktree tools over http", () => const status = tools.find((tool) => tool.name === "t3_worktree_status"); expect(status?.annotations?.readOnlyHint).toBe(true); expect(status?.annotations?.destructiveHint).toBe(false); + const list = tools.find((tool) => tool.name === "t3_worktree_list"); + expect(list?.annotations?.readOnlyHint).toBe(true); + expect(list?.annotations?.destructiveHint).toBe(false); // 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 69a33cb79d07..c95d02454db4 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -2,6 +2,7 @@ import { WorktreeMcpFailure, WorktreeMcpHandoffInput, WorktreeMcpHandoffResult, + WorktreeMcpListResult, WorktreeMcpStatusResult, } from "@t3tools/contracts"; import { Tool, Toolkit } from "effect/unstable/ai"; @@ -28,7 +29,7 @@ export const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", { export const WorktreeStatusTool = Tool.make("t3_worktree_status", { description: - "Report this agent thread's worktree binding: whether it is attached to a git worktree, the worktree path and branch, the project's main workspace root, and the server default for t3_worktree_handoff's startFromOrigin. Call this before t3_worktree_handoff to check whether a handoff is possible or has already happened.", + "Report both the durable workspace recorded on this agent thread and the branch actually checked out on disk. The agreement field calls out a branch mismatch, a missing worktree binding, or a non-repository path. Call this before a handoff or checkout and after failures.", // No `parameters`: Tool.make defaults to Tool.EmptyParams, which serializes // to a top-level `type: "object"` JSON Schema. An explicit empty // Schema.Struct({}) serializes to `anyOf: [object, array]`, which is not a @@ -44,4 +45,22 @@ export const WorktreeStatusTool = Tool.make("t3_worktree_status", { .annotate(Tool.Idempotent, true) .annotate(Tool.OpenWorld, false); -export const WorktreeToolkit = Toolkit.make(WorktreeHandoffTool, WorktreeStatusTool); +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. It does not create, remove, prune, or repair worktrees.", + success: WorktreeMcpListResult, + failure: WorktreeMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "List project git worktrees") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const WorktreeToolkit = Toolkit.make( + WorktreeHandoffTool, + WorktreeStatusTool, + WorktreeListTool, +); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b422190fc04c..6d56554b83f4 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -46,6 +46,7 @@ import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeComp import { attachmentRelativePath } from "../../attachmentStore.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; +import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; import { ProviderAdapterV2RuntimePolicy, @@ -580,7 +581,10 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { }); it("matches the read-only allowlist to the orchestrator toolkit annotations", () => { - const readOnlyToolNames = Object.values(OrchestratorToolkit.tools) + const readOnlyToolNames = [ + ...Object.values(OrchestratorToolkit.tools), + ...Object.values(WorktreeToolkit.tools), + ] .filter((tool) => Context.get(tool.annotations, Tool.Readonly)) .map((tool) => `mcp__t3-code__${tool.name}`) .sort(); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index c0684ca2357c..791164da05c9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -801,13 +801,15 @@ export function makeClaudeQueryOptions(input: { export const CLAUDE_T3_MCP_TOOL_WILDCARD = "mcp__t3-code__*"; -// Must stay in sync with the Tool.Readonly annotations on OrchestratorToolkit; -// ClaudeAdapterV2.test.ts cross-checks this list against the toolkit. +// Must stay in sync with the Tool.Readonly annotations on the orchestration +// and worktree toolkits; ClaudeAdapterV2.test.ts cross-checks this list. export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__orchestrator_capabilities", "mcp__t3-code__list_scheduled_tasks", "mcp__t3-code__t3_thread_list", "mcp__t3-code__t3_thread_wait", + "mcp__t3-code__t3_worktree_list", + "mcp__t3-code__t3_worktree_status", ]; // The SDK's `allowedTools` only pre-approves tool calls; availability is the diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 215a350a2f04..f48f8ea29645 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -141,7 +141,7 @@ selection model-visible without allowing a request that cannot run. ## Tool Surface -The server exposes eleven orchestration tools. +The server exposes orchestration and thread-scoped workspace tools. ### `orchestrator_capabilities` @@ -327,6 +327,19 @@ Without `runId`, it selects the newest interruptible run. A terminal run is returned unchanged, and a thread with no active provider turn returns `no_active_run`. +### `t3_worktree_status` + +Reads the calling thread's saved branch and worktree path, then reads Git status from that path. +The result keeps recorded and actual state separate and reports whether they agree. A missing +worktree, non-repository path, or branch mismatch is visible without changing either state. + +### `t3_worktree_list` + +Lists the project root and existing branch-backed worktrees in the calling thread's current +project. Each entry includes its listed and actual branch, dirty state, and threads bound to the +checkout. Bindings keep each thread's recorded branch and worktree path separate from the actual +checkout. The tool does not create, remove, prune, or repair worktrees. + ## Delegated Task Lifecycle The MCP server is a command ingress into V2. It does not call provider adapters @@ -370,6 +383,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. - 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 2d7ed8badbec..a45a6f9b374e 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -70,6 +70,14 @@ 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 + +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 the durable branch and worktree path recorded for other threads using each +checkout. These read paths apply only to the calling thread's current project and do not create, +remove, prune, or revive worktrees. + ## Review and merge Open **Pull requests** to review changes and comments, request reviewers, check out a branch, diff --git a/packages/contracts/src/worktreeMcp.ts b/packages/contracts/src/worktreeMcp.ts index 154839fae6bb..5d8408efd6e7 100644 --- a/packages/contracts/src/worktreeMcp.ts +++ b/packages/contracts/src/worktreeMcp.ts @@ -1,6 +1,6 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; /** * Input for the `t3_worktree_handoff` MCP tool. @@ -95,6 +95,28 @@ export const WorktreeMcpHandoffResult = Schema.Struct({ }); export type WorktreeMcpHandoffResult = typeof WorktreeMcpHandoffResult.Type; +export const WorktreeMcpRecordedWorkspace = Schema.Struct({ + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), +}); +export type WorktreeMcpRecordedWorkspace = typeof WorktreeMcpRecordedWorkspace.Type; + +export const WorktreeMcpActualWorkspace = Schema.Struct({ + workspacePath: TrimmedNonEmptyString, + isRepo: Schema.Boolean, + branch: Schema.NullOr(TrimmedNonEmptyString), + hasWorkingTreeChanges: Schema.Boolean, +}); +export type WorktreeMcpActualWorkspace = typeof WorktreeMcpActualWorkspace.Type; + +export const WorktreeMcpWorkspaceAgreement = Schema.Literals([ + "in_sync", + "branch_mismatch", + "workspace_missing", + "not_repository", +]); +export type WorktreeMcpWorkspaceAgreement = typeof WorktreeMcpWorkspaceAgreement.Type; + export const WorktreeMcpStatusResult = Schema.Struct({ attached: Schema.Boolean.annotate({ description: "True when this thread is already attached to a git worktree.", @@ -107,9 +129,40 @@ export const WorktreeMcpStatusResult = Schema.Struct({ defaultStartFromOrigin: Schema.Boolean.annotate({ description: "Server default used by t3_worktree_handoff when startFromOrigin is omitted.", }), + recordedWorkspace: WorktreeMcpRecordedWorkspace, + actualWorkspace: WorktreeMcpActualWorkspace, + agreement: WorktreeMcpWorkspaceAgreement, }); export type WorktreeMcpStatusResult = typeof WorktreeMcpStatusResult.Type; +export const WorktreeMcpThreadBinding = Schema.Struct({ + threadId: ThreadId, + title: Schema.String, + status: TrimmedNonEmptyString, + recordedBranch: Schema.NullOr(TrimmedNonEmptyString), + recordedWorktreePath: Schema.NullOr(TrimmedNonEmptyString), + active: Schema.Boolean, + callingThread: Schema.Boolean, +}); +export type WorktreeMcpThreadBinding = typeof WorktreeMcpThreadBinding.Type; + +export const WorktreeMcpListEntry = Schema.Struct({ + path: TrimmedNonEmptyString, + branch: Schema.NullOr(TrimmedNonEmptyString), + actualBranch: Schema.NullOr(TrimmedNonEmptyString), + isRepo: Schema.Boolean, + isProjectRoot: Schema.Boolean, + hasWorkingTreeChanges: Schema.Boolean, + bindings: Schema.Array(WorktreeMcpThreadBinding), +}); +export type WorktreeMcpListEntry = typeof WorktreeMcpListEntry.Type; + +export const WorktreeMcpListResult = Schema.Struct({ + projectWorkspaceRoot: TrimmedNonEmptyString, + worktrees: Schema.Array(WorktreeMcpListEntry), +}); +export type WorktreeMcpListResult = typeof WorktreeMcpListResult.Type; + export class WorktreeMcpFailure extends Schema.TaggedErrorClass()( "WorktreeMcpFailure", { diff --git a/packages/shared/src/t3McpToolPresentation.test.ts b/packages/shared/src/t3McpToolPresentation.test.ts index 765c4f6ca822..a54e51412f65 100644 --- a/packages/shared/src/t3McpToolPresentation.test.ts +++ b/packages/shared/src/t3McpToolPresentation.test.ts @@ -40,6 +40,10 @@ describe("resolveT3McpToolPresentation", () => { displayName: "Get thread worktree status", logo: "t3-code", }); + expect(resolveT3McpToolPresentation("mcp__t3-code__t3_worktree_list")).toEqual({ + displayName: "List project git worktrees", + 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 a9dcd685cc0c..f9dd44cf5b9b 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -54,6 +54,7 @@ const T3_MCP_TOOLS: Record< t3_thread_interrupt: { displayName: "Interrupt a T3 thread", summaryAction: "thread-interrupt" }, 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" }, 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 d88df344d422c5e8c18035ced4340cef7af4b2ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 15:02:45 -0700 Subject: [PATCH 02/16] fix(mcp): verify canonical worktree inventory --- .../server/src/git/GitWorkflowService.test.ts | 1 + apps/server/src/git/GitWorkflowService.ts | 1 + .../server/src/mcp/WorktreeMcpService.test.ts | 78 ++++++++++++++----- apps/server/src/mcp/WorktreeMcpService.ts | 61 +++++++++------ .../server/src/mcp/toolkits/worktree/tools.ts | 2 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 27 +++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 50 ++++++++---- .../orchestrator-mcp-server.md | 10 ++- docs/user/source-control.md | 9 ++- packages/contracts/src/git.ts | 7 ++ 10 files changed, 181 insertions(+), 65 deletions(-) diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 2ea14b951fe2..cc086f96ae65 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -121,6 +121,7 @@ describe("GitWorkflowService", () => { assert.deepStrictEqual(refs, { refs: [], + worktrees: [], isRepo: false, hasPrimaryRemote: false, nextCursor: null, diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index ae544fc83f34..ead0e7aa990e 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -139,6 +139,7 @@ function nonRepositoryStatus(): VcsStatusResult { function nonRepositoryListRefs(): VcsListRefsResult { return { refs: [], + worktrees: [], isRepo: false, hasPrimaryRemote: false, nextCursor: null, diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index e7ce781cdfd7..e8d59552db56 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -117,6 +117,10 @@ interface HarnessOptions { readonly isDefault: boolean; readonly worktreePath: string | null; }>; + readonly worktrees?: ReadonlyArray<{ + readonly path: string; + readonly refName: string | null; + }>; readonly workspaceStatuses?: Readonly>; readonly projectThreads?: ReadonlyArray<{ readonly id: ThreadId; @@ -269,30 +273,34 @@ 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 || ref.name.includes(input.query)) + : options.existingBranchWorktreePath === undefined + ? [] + : [ + { + name: input.query ?? "", + current: false, + isDefault: false, + worktreePath: options.existingBranchWorktreePath, + }, + ]; + return Effect.succeed({ + refs, + worktrees: + options.worktrees ?? + refs.flatMap((ref) => + ref.worktreePath === null ? [] : [{ path: ref.worktreePath, refName: ref.name }], + ), isRepo: true, hasPrimaryRemote: true, nextCursor: null, totalCount: options.refs?.length ?? (options.existingBranchWorktreePath === undefined ? 0 : 1), - }), - ); + }); + }); const workspaceStatuses = new Map( Object.entries( options.workspaceStatuses ?? { @@ -309,7 +317,11 @@ 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 }, }); @@ -1187,6 +1199,32 @@ describe("t3_worktree_list", () => { ]); }); }); + + 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, + bindings: [], + }); + }); + }); }); describe("WorktreeMcpHandoffInput schema", () => { diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 3b57f7dbfe3d..96f5201a42d2 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -4,6 +4,7 @@ import { type OrchestrationV2ThreadShell, type ProjectId, type VcsRef, + type VcsWorktreeCheckout, WorktreeMcpFailure, type WorktreeMcpContinuationStatus, type WorktreeMcpHandoffInput, @@ -16,6 +17,7 @@ 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 Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -63,6 +65,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; @@ -123,15 +126,23 @@ 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, ) { const refs: Array = []; + let worktrees: ReadonlyArray = []; let cursor: number | undefined; let firstPage = true; do { @@ -152,10 +163,11 @@ const make = Effect.gen(function* () { ); } refs.push(...page.refs); + if (firstPage) worktrees = page.worktrees; cursor = page.nextCursor ?? undefined; firstPage = false; } while (cursor !== undefined); - return refs; + return { refs, worktrees }; }); const loadProjectThreads = ( @@ -518,9 +530,9 @@ 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( + const [defaultStartFromOrigin, actual, inventory] = yield* Effect.all( [ readDefaultStartFromOrigin, readWorkspaceStatus(workspacePath), @@ -530,13 +542,10 @@ const make = Effect.gen(function* () { ); const knownWorkspacePaths = new Set([ projectWorkspaceRoot, - ...refs.flatMap((ref) => - ref.isRemote === true || ref.worktreePath === null - ? [] - : [normalizePath(ref.worktreePath)], - ), + ...inventory.worktrees.map((worktree) => worktree.path), ]); - const agreement = !knownWorkspacePaths.has(workspacePath) + const canonicalWorkspacePath = yield* canonicalizePath(workspacePath); + const agreement = !knownWorkspacePaths.has(canonicalWorkspacePath) ? "workspace_missing" : !actual.isRepo ? "not_repository" @@ -555,7 +564,7 @@ const make = Effect.gen(function* () { worktreePath: projection.thread.worktreePath, }, actualWorkspace: { - workspacePath, + workspacePath: canonicalWorkspacePath, isRepo: actual.isRepo, branch: actual.refName, hasWorkingTreeChanges: actual.hasWorkingTreeChanges, @@ -572,17 +581,24 @@ 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 [refs, threads] = yield* Effect.all( + const projectWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot); + const [inventory, threads] = yield* Effect.all( [loadRefs(projectWorkspaceRoot), loadProjectThreads(projection.thread.projectId)], { concurrency: 2 }, ); - const branchByWorkspacePath = new Map([[projectWorkspaceRoot, null]]); - for (const ref of refs) { - if (ref.isRemote === true || ref.worktreePath === null) continue; - branchByWorkspacePath.set(normalizePath(ref.worktreePath), ref.name); + const branchByWorkspacePath = new Map(); + for (const worktree of inventory.worktrees) { + branchByWorkspacePath.set(worktree.path, worktree.refName); } + if (!branchByWorkspacePath.has(projectWorkspaceRoot)) { + branchByWorkspacePath.set(projectWorkspaceRoot, null); + } + const threadWorkspaces = yield* Effect.forEach(threads, (thread) => + threadWorkspacePath(thread, projectWorkspaceRoot).pipe( + Effect.map((workspacePath) => [thread, workspacePath] as const), + ), + ); const worktrees = yield* Effect.forEach( [...branchByWorkspacePath.entries()], @@ -595,11 +611,9 @@ const make = Effect.gen(function* () { isRepo: actual.isRepo, isProjectRoot: workspacePath === projectWorkspaceRoot, hasWorkingTreeChanges: actual.hasWorkingTreeChanges, - bindings: threads - .filter( - (thread) => threadWorkspacePath(thread, projectWorkspaceRoot) === workspacePath, - ) - .map((thread) => ({ + bindings: threadWorkspaces + .filter(([, threadPath]) => threadPath === workspacePath) + .map(([thread]) => ({ threadId: thread.id, title: thread.title, status: thread.status, @@ -630,6 +644,7 @@ 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/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index c95d02454db4..557395ff2b4b 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -47,7 +47,7 @@ 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. It does not create, remove, prune, or repair worktrees.", + "List 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, and threads bound to that checkout with their recorded branch and worktree path. It does not create, remove, prune, or repair worktrees.", success: WorktreeMcpListResult, failure: WorktreeMcpFailure, failureMode: "return", diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 8a1f0d966420..99e7b6181bd8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1559,6 +1559,32 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { + it.effect("lists canonical attached and detached worktrees through a symlinked checkout", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); + const detachedPath = pathService.join(worktreesRoot, "detached"); + const linksRoot = yield* makeTmpDir("git-vcs-driver-links-"); + const checkoutLink = pathService.join(linksRoot, "checkout"); + yield* git(cwd, ["worktree", "add", "--detach", detachedPath, "HEAD"]); + yield* fileSystem.symlink(cwd, checkoutLink); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const refs = yield* driver.listRefs({ cwd: checkoutLink, refresh: true }); + + assert.deepEqual( + refs.worktrees.toSorted((left, right) => left.path.localeCompare(right.path)), + [ + { path: yield* fileSystem.realPath(cwd), refName: initialBranch }, + { path: yield* fileSystem.realPath(detachedPath), refName: null }, + ].toSorted((left, right) => left.path.localeCompare(right.path)), + ); + }), + ); + // NTFS rejects a newline in a file name, so there is nothing to preserve there. it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( "preserves newline characters in worktree paths when listing refs", @@ -1567,6 +1593,7 @@ 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; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 1fd7918c883c..7aadfee971f0 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -25,6 +25,7 @@ import { type ReviewDiffPreviewInput, type ReviewDiffPreviewSource, type VcsRef, + type VcsWorktreeCheckout, } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { compactTraceAttributes } from "@t3tools/shared/observability"; @@ -144,6 +145,7 @@ interface GitRepositoryPaths { interface GitRefsSnapshot { readonly localBranches: ReadonlyArray; readonly remoteBranches: ReadonlyArray; + readonly worktrees: ReadonlyArray; readonly hasPrimaryRemote: boolean; } @@ -247,15 +249,15 @@ function paginateBranches(input: { }; } -function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { - const worktreePaths = new Map(); +function parseWorktreeCheckouts(stdout: string): ReadonlyArray { + const worktrees: Array = []; let currentPath: string | null = null; let currentBranch: string | null = null; let currentPrunable = false; const flush = () => { - if (currentPath !== null && currentBranch !== null && !currentPrunable) { - worktreePaths.set(currentBranch, currentPath); + if (currentPath !== null && !currentPrunable) { + worktrees.push({ path: currentPath, refName: currentBranch }); } currentPath = null; currentBranch = null; @@ -275,7 +277,7 @@ function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { } flush(); - return worktreePaths; + return worktrees; } function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { @@ -1134,7 +1136,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { concurrency: 2 }, ); const worktreeRootOutput = worktreeRootResult.stdout.trim(); - const worktreeRoot = + const resolvedWorktreeRoot = worktreeRootResult.exitCode === 0 && worktreeRootOutput.length > 0 ? path.normalize( path.isAbsolute(worktreeRootOutput) @@ -1142,6 +1144,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : path.resolve(cwd, worktreeRootOutput), ) : null; + const worktreeRoot = + resolvedWorktreeRoot === null + ? null + : yield* fileSystem + .realPath(resolvedWorktreeRoot) + .pipe(Effect.orElseSucceed(() => resolvedWorktreeRoot)); const currentBranchOutput = currentBranchResult.stdout.trim(); const currentBranch = currentBranchResult.exitCode === 0 && currentBranchOutput.length > 0 @@ -2643,21 +2651,34 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : null; const parsedWorktreeEntries = worktreeListResult.exitCode === 0 - ? [...parseWorktreeBranchPaths(worktreeListResult.stdout)].map( - ([branchName, worktreePath]) => - [branchName, path.normalize(path.resolve(worktreePath))] as const, - ) + ? parseWorktreeCheckouts(worktreeListResult.stdout).map((worktree) => ({ + ...worktree, + path: path.normalize(path.resolve(worktree.path)), + })) : []; const existingWorktreeEntries = yield* Effect.filter( parsedWorktreeEntries, - ([, worktreePath]) => - fileSystem.stat(worktreePath).pipe( + (worktree) => + fileSystem.stat(worktree.path).pipe( Effect.as(true), Effect.orElseSucceed(() => false), ), { concurrency: 16 }, ); - const worktreeMap = new Map(existingWorktreeEntries); + const worktrees = yield* Effect.forEach( + existingWorktreeEntries, + (worktree) => + fileSystem.realPath(worktree.path).pipe( + Effect.map((canonicalPath) => ({ ...worktree, path: canonicalPath })), + Effect.orElseSucceed(() => worktree), + ), + { concurrency: 16 }, + ); + const worktreeMap = new Map( + worktrees.flatMap((worktree) => + worktree.refName === null ? [] : ([[worktree.refName, worktree.path]] as const), + ), + ); const localBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = []; const remoteBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = []; @@ -2711,6 +2732,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { localBranches: localBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), remoteBranches: remoteBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), + worktrees, hasPrimaryRemote: remoteNames.includes("origin"), } satisfies GitRefsSnapshot; }); @@ -2829,6 +2851,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (repositoryPaths === null) { return { refs: [], + worktrees: [], isRepo: false, hasPrimaryRemote: false, nextCursor: null, @@ -2873,6 +2896,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { refs: [...refs.refs], + worktrees: [...snapshot.worktrees], isRepo: true, hasPrimaryRemote: snapshot.hasPrimaryRemote, nextCursor: refs.nextCursor, diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index f48f8ea29645..befe539787ee 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -335,10 +335,12 @@ worktree, non-repository path, or branch mismatch is visible without changing ei ### `t3_worktree_list` -Lists the project root and existing branch-backed worktrees in the calling thread's current -project. Each entry includes its listed and actual branch, dirty state, and threads bound to the -checkout. Bindings keep each thread's recorded branch and worktree path separate from the actual -checkout. The tool does not create, remove, prune, or repair worktrees. +Lists the project root and every Git-registered worktree in the calling thread's current project, +including detached checkouts. Git's canonical common-directory inventory and real paths determine +repository membership, so symlinked paths and saved branch labels are not treated as proof. Each +entry includes its listed and actual branch, dirty state, and threads bound to the checkout. +Bindings keep each thread's recorded branch and worktree path separate from the actual checkout. +The tool does not create, remove, prune, or repair worktrees. ## Delegated Task Lifecycle diff --git a/docs/user/source-control.md b/docs/user/source-control.md index a45a6f9b374e..767d1676e973 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -73,10 +73,11 @@ uses the project's instructions and recent commit subjects. ### Let an agent inspect 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 the durable branch and worktree path recorded for other threads using each -checkout. 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 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 worktree inventory. These read paths apply only to the calling thread's current project and +do not create, remove, prune, or revive worktrees. ## Review and merge diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 4b63b877923f..1f8d92a11773 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -88,6 +88,12 @@ const VcsWorktree = Schema.Struct({ path: TrimmedNonEmptyStringSchema, refName: TrimmedNonEmptyStringSchema, }); + +export const VcsWorktreeCheckout = Schema.Struct({ + path: TrimmedNonEmptyStringSchema, + refName: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), +}); +export type VcsWorktreeCheckout = typeof VcsWorktreeCheckout.Type; const GitResolvedPullRequest = Schema.Struct({ number: PositiveInt, title: TrimmedNonEmptyStringSchema, @@ -266,6 +272,7 @@ export type VcsStatusStreamEvent = typeof VcsStatusStreamEvent.Type; export const VcsListRefsResult = Schema.Struct({ refs: Schema.Array(VcsRef), + worktrees: Schema.Array(VcsWorktreeCheckout), isRepo: Schema.Boolean, hasPrimaryRemote: Schema.Boolean, nextCursor: NonNegativeInt.pipe(Schema.NullOr), From c528f5553c6bda4392e0a003684ac08d1fe8c5b2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 15:21:34 -0700 Subject: [PATCH 03/16] fix(mcp): bound canonical worktree inventory --- .../server/src/git/GitWorkflowService.test.ts | 1 - apps/server/src/git/GitWorkflowService.ts | 8 +- .../server/src/mcp/WorktreeMcpService.test.ts | 107 ++++++++++- apps/server/src/mcp/WorktreeMcpService.ts | 175 ++++++++++-------- .../src/mcp/toolkits/worktree/handlers.ts | 4 +- .../server/src/mcp/toolkits/worktree/tools.ts | 4 +- apps/server/src/vcs/GitVcsDriver.ts | 12 ++ apps/server/src/vcs/GitVcsDriverCore.test.ts | 17 +- apps/server/src/vcs/GitVcsDriverCore.ts | 109 +++++++---- .../orchestrator-mcp-server.md | 9 +- docs/user/source-control.md | 6 +- packages/client-runtime/src/state/vcs.test.ts | 2 +- packages/contracts/src/git.test.ts | 22 +++ packages/contracts/src/git.ts | 6 - packages/contracts/src/worktreeMcp.ts | 16 +- 15 files changed, 353 insertions(+), 145 deletions(-) diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index cc086f96ae65..2ea14b951fe2 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -121,7 +121,6 @@ describe("GitWorkflowService", () => { assert.deepStrictEqual(refs, { refs: [], - worktrees: [], isRepo: false, hasPrimaryRemote: false, nextCursor: null, diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index ead0e7aa990e..0e634fbf1a82 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -62,6 +62,9 @@ export class GitWorkflowService extends Context.Service< readonly listRefs: ( input: VcsListRefsInput, ) => Effect.Effect; + readonly listWorktrees: ( + cwd: string, + ) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, ) => Effect.Effect; @@ -139,7 +142,6 @@ function nonRepositoryStatus(): VcsStatusResult { function nonRepositoryListRefs(): VcsListRefsResult { return { refs: [], - worktrees: [], isRepo: false, hasPrimaryRemote: false, nextCursor: null, @@ -312,6 +314,10 @@ export const make = Effect.gen(function* () { isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()), ), ), + listWorktrees: (cwd) => + ensureGitCommand("GitWorkflowService.listWorktrees", cwd).pipe( + Effect.andThen(git.listWorktrees(cwd)), + ), createWorktree: (input) => ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( Effect.andThen(git.createWorktree(input)), diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index e8d59552db56..077e4ddd3c16 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -121,7 +121,9 @@ interface HarnessOptions { readonly path: string; readonly refName: string | null; }>; + readonly projectWorktreeRoot?: string; readonly workspaceStatuses?: Readonly>; + readonly localStatusFailsOnCall?: number; readonly projectThreads?: ReadonlyArray<{ readonly id: ThreadId; readonly title: string; @@ -276,7 +278,9 @@ const makeHarness = (options: HarnessOptions = {}) => { const listRefs = vi.fn((input: { readonly query?: string | undefined }) => { const refs = options.refs !== undefined - ? options.refs.filter((ref) => input.query === undefined || ref.name.includes(input.query)) + ? options.refs.filter((ref) => + input.query === undefined ? true : ref.name.includes(input.query), + ) : options.existingBranchWorktreePath === undefined ? [] : [ @@ -289,11 +293,6 @@ const makeHarness = (options: HarnessOptions = {}) => { ]; return Effect.succeed({ refs, - worktrees: - options.worktrees ?? - refs.flatMap((ref) => - ref.worktreePath === null ? [] : [{ path: ref.worktreePath, refName: ref.name }], - ), isRepo: true, hasPrimaryRemote: true, nextCursor: null, @@ -301,6 +300,31 @@ const makeHarness = (options: HarnessOptions = {}) => { 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: + listedWorktrees.find((worktree) => worktree.path === cwd)?.path ?? projectWorktreeRoot, + worktrees: listedWorktrees, + }), + ); const workspaceStatuses = new Map( Object.entries( options.workspaceStatuses ?? { @@ -310,7 +334,12 @@ const makeHarness = (options: HarnessOptions = {}) => { }, ), ); + let localStatusCallCount = 0; const localStatus = vi.fn((input: { readonly cwd: string }) => { + localStatusCallCount += 1; + if (options.localStatusFailsOnCall === localStatusCallCount) { + return Effect.fail("simulated local status failure") as never; + } const current = workspaceStatuses.get(input.cwd); return Effect.succeed({ isRepo: options.notARepo !== true, @@ -386,6 +415,7 @@ const makeHarness = (options: HarnessOptions = {}) => { }), Layer.mock(GitWorkflowService.GitWorkflowService)({ listRefs, + listWorktrees, listLocalBranchNames, localStatus, invalidateLocalStatus, @@ -418,6 +448,7 @@ const makeHarness = (options: HarnessOptions = {}) => { deleteLocalBranch, localStatus, listRefs, + listWorktrees, listProjectThreads, runForThread, }; @@ -457,10 +488,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)); describe("t3_worktree_handoff", () => { @@ -1157,6 +1191,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, @@ -1165,6 +1203,8 @@ describe("t3_worktree_list", () => { isRepo: true, isProjectRoot: true, hasWorkingTreeChanges: false, + availability: "available", + statusError: null, bindings: [ { threadId, @@ -1176,6 +1216,7 @@ describe("t3_worktree_list", () => { callingThread: true, }, ], + bindingCount: 1, }, { path: worktreePath, @@ -1184,6 +1225,8 @@ describe("t3_worktree_list", () => { isRepo: true, isProjectRoot: false, hasWorkingTreeChanges: true, + availability: "available", + statusError: null, bindings: [ { threadId: otherThreadId, @@ -1195,6 +1238,7 @@ describe("t3_worktree_list", () => { callingThread: false, }, ], + bindingCount: 1, }, ]); }); @@ -1221,10 +1265,57 @@ describe("t3_worktree_list", () => { 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("WorktreeMcpHandoffInput schema", () => { diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 96f5201a42d2..29fdd6038de9 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -3,12 +3,11 @@ import { MessageId, type OrchestrationV2ThreadShell, type ProjectId, - type VcsRef, - type VcsWorktreeCheckout, WorktreeMcpFailure, type WorktreeMcpContinuationStatus, type WorktreeMcpHandoffInput, type WorktreeMcpHandoffResult, + type WorktreeMcpListInput, type WorktreeMcpListResult, type WorktreeMcpSetupScriptStatus, type WorktreeMcpStatusResult, @@ -17,6 +16,7 @@ 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 Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -42,6 +42,7 @@ export class WorktreeMcpService extends Context.Service< ) => Effect.Effect; readonly listWorktrees: ( scope: McpInvocationScope, + input: WorktreeMcpListInput, ) => Effect.Effect; } >()("t3/mcp/WorktreeMcpService") {} @@ -138,36 +139,12 @@ const make = Effect.gen(function* () { return yield* canonicalizePath(thread.worktreePath ?? projectWorkspaceRoot); }); - const loadRefs = Effect.fn("WorktreeMcpService.loadRefs")(function* ( + const loadWorktrees = Effect.fn("WorktreeMcpService.loadWorktrees")(function* ( projectWorkspaceRoot: string, ) { - const refs: Array = []; - let worktrees: ReadonlyArray = []; - let cursor: number | undefined; - let firstPage = true; - do { - const page = yield* gitWorkflow - .listRefs({ - cwd: projectWorkspaceRoot, - refKind: "local", - includeMatchingRemoteRefs: false, - 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); - if (firstPage) worktrees = page.worktrees; - cursor = page.nextCursor ?? undefined; - firstPage = false; - } while (cursor !== undefined); - return { refs, worktrees }; + return yield* gitWorkflow + .listWorktrees(projectWorkspaceRoot) + .pipe(asOperationFailed("Unable to list project worktrees")); }); const loadProjectThreads = ( @@ -532,26 +509,28 @@ 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, inventory] = yield* Effect.all( - [ - readDefaultStartFromOrigin, - readWorkspaceStatus(workspacePath), - loadRefs(projectWorkspaceRoot), - ], - { concurrency: 3 }, - ); - const knownWorkspacePaths = new Set([ - projectWorkspaceRoot, - ...inventory.worktrees.map((worktree) => worktree.path), - ]); + const [defaultStartFromOrigin, actual, projectInventory, workspaceInventory] = + yield* Effect.all( + [ + readDefaultStartFromOrigin, + readWorkspaceStatus(workspacePath), + loadWorktrees(projectWorkspaceRoot), + loadWorktrees(workspacePath), + ], + { concurrency: 4 }, + ); const canonicalWorkspacePath = yield* canonicalizePath(workspacePath); - const agreement = !knownWorkspacePaths.has(canonicalWorkspacePath) - ? "workspace_missing" - : !actual.isRepo - ? "not_repository" - : actual.refName !== projection.thread.branch - ? "branch_mismatch" - : "in_sync"; + 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, @@ -564,7 +543,7 @@ const make = Effect.gen(function* () { worktreePath: projection.thread.worktreePath, }, actualWorkspace: { - workspacePath: canonicalWorkspacePath, + workspacePath: physicalWorkspacePath ?? canonicalWorkspacePath, isRepo: actual.isRepo, branch: actual.refName, hasWorkingTreeChanges: actual.hasWorkingTreeChanges, @@ -577,63 +556,105 @@ const make = Effect.gen(function* () { const listWorktrees: WorktreeMcpService["Service"]["listWorktrees"] = Effect.fn( "WorktreeMcpService.listWorktrees", - )(function* (scope) { + )(function* (scope, input) { yield* requireCapability(scope); const projection = yield* loadThread(scope); const project = yield* loadProject(scope, projection.thread.projectId); const projectWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot); const [inventory, threads] = yield* Effect.all( - [loadRefs(projectWorkspaceRoot), loadProjectThreads(projection.thread.projectId)], + [loadWorktrees(projectWorkspaceRoot), loadProjectThreads(projection.thread.projectId)], { concurrency: 2 }, ); + const projectWorktreeRoot = inventory.currentWorktreeRoot ?? projectWorkspaceRoot; const branchByWorkspacePath = new Map(); for (const worktree of inventory.worktrees) { branchByWorkspacePath.set(worktree.path, worktree.refName); } - if (!branchByWorkspacePath.has(projectWorkspaceRoot)) { - branchByWorkspacePath.set(projectWorkspaceRoot, null); + if (!branchByWorkspacePath.has(projectWorktreeRoot)) { + branchByWorkspacePath.set(projectWorktreeRoot, null); } + const allWorktrees = [...branchByWorkspacePath.entries()].toSorted( + ([leftPath], [rightPath]) => + Number(rightPath === projectWorktreeRoot) - Number(leftPath === projectWorktreeRoot) || + leftPath.localeCompare(rightPath), + ); + const cursor = Math.min(input.cursor ?? 0, allWorktrees.length); + const limit = input.limit ?? 20; + const selectedWorktrees = allWorktrees.slice(cursor, cursor + limit); + const nextCursor = + cursor + selectedWorktrees.length < allWorktrees.length + ? cursor + selectedWorktrees.length + : null; + const bindingLimit = input.bindingLimit ?? 20; const threadWorkspaces = yield* Effect.forEach(threads, (thread) => - threadWorkspacePath(thread, projectWorkspaceRoot).pipe( + threadWorkspacePath(thread, projectWorktreeRoot).pipe( Effect.map((workspacePath) => [thread, workspacePath] as const), ), ); const worktrees = yield* Effect.forEach( - [...branchByWorkspacePath.entries()], + selectedWorktrees, ([workspacePath, branch]) => - readWorkspaceStatus(workspacePath).pipe( - Effect.map((actual) => ({ + Effect.gen(function* () { + const bindings = threadWorkspaces + .filter(([, threadPath]) => threadPath === workspacePath) + .map(([thread]) => ({ + threadId: thread.id, + title: thread.title, + status: thread.status, + recordedBranch: thread.branch, + recordedWorktreePath: thread.worktreePath, + active: thread.activeRunId !== null, + callingThread: thread.id === scope.threadId, + })); + const statusExit = yield* Effect.exit(readWorkspaceStatus(workspacePath)); + if (Exit.isFailure(statusExit)) { + const exists = yield* fileSystem + .exists(workspacePath) + .pipe(Effect.orElseSucceed(() => false)); + const detail = errorMessage(Cause.squash(statusExit.cause)); + yield* Effect.logWarning("unable to read listed worktree status", { + workspacePath, + detail, + }); + return { + path: workspacePath, + branch, + actualBranch: null, + isRepo: false, + isProjectRoot: workspacePath === projectWorktreeRoot, + hasWorkingTreeChanges: false, + availability: exists ? "unreadable" : "missing", + statusError: detail, + bindings: bindings.slice(0, bindingLimit), + bindingCount: bindings.length, + } as const; + } + const actual = statusExit.value; + return { path: workspacePath, branch, actualBranch: actual.refName, isRepo: actual.isRepo, - isProjectRoot: workspacePath === projectWorkspaceRoot, + isProjectRoot: workspacePath === projectWorktreeRoot, hasWorkingTreeChanges: actual.hasWorkingTreeChanges, - bindings: threadWorkspaces - .filter(([, threadPath]) => threadPath === workspacePath) - .map(([thread]) => ({ - threadId: thread.id, - title: thread.title, - status: thread.status, - recordedBranch: thread.branch, - recordedWorktreePath: thread.worktreePath, - active: thread.activeRunId !== null, - callingThread: thread.id === scope.threadId, - })), - })), - ), + availability: "available", + statusError: null, + bindings: bindings.slice(0, bindingLimit), + bindingCount: bindings.length, + } as const; + }), { concurrency: 8 }, ); return { projectWorkspaceRoot, - worktrees: worktrees.toSorted( - (left, right) => - Number(right.isProjectRoot) - Number(left.isProjectRoot) || - left.path.localeCompare(right.path), - ), + repositoryCommonDir: inventory.repositoryCommonDir, + projectWorktreeRoot, + worktrees, + nextCursor, + total: allWorktrees.length, } satisfies WorktreeMcpListResult; }); diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index 82aca4d36df7..8d2bc64988dd 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); }), } satisfies Parameters[0]; diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index 557395ff2b4b..092c88ab6c6b 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -2,6 +2,7 @@ import { WorktreeMcpFailure, WorktreeMcpHandoffInput, WorktreeMcpHandoffResult, + WorktreeMcpListInput, WorktreeMcpListResult, WorktreeMcpStatusResult, } from "@t3tools/contracts"; @@ -47,7 +48,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 Git-registered worktrees, including detached checkouts. Paths are canonicalized from Git's repository identity. Each entry includes the actual checked-out branch, dirty state, and threads bound to that checkout with their recorded branch and worktree path. 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/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 23eb29dc667c..37a45608c900 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -245,6 +245,17 @@ export interface GitRemoteStatusOptions { readonly refreshUpstream?: boolean; } +export interface GitWorktreeCheckout { + readonly path: string; + readonly refName: string | null; +} + +export interface GitWorktreeInventory { + readonly repositoryCommonDir: string; + readonly currentWorktreeRoot: string | null; + readonly worktrees: ReadonlyArray; +} + export class GitVcsDriver extends Context.Service< GitVcsDriver, { @@ -291,6 +302,7 @@ export class GitVcsDriver extends Context.Service< readonly listRefs: ( input: VcsListRefsInput, ) => Effect.Effect; + readonly listWorktrees: (cwd: string) => Effect.Effect; readonly pullCurrentBranch: (cwd: string) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 99e7b6181bd8..de431028464b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -523,8 +523,12 @@ it.effect("ignores worktree metadata for directories that no longer exist", () = ); const refs = yield* driver.listRefs({ cwd, refresh: true }); + const inventory = yield* driver.listWorktrees(cwd); assert.equal(refs.refs.find((ref) => ref.name === "stale-worktree")?.worktreePath, null); + assert.deepEqual(inventory.worktrees, [ + { path: missingWorktreePath, refName: "stale-worktree" }, + ]); }), ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), ); @@ -1569,19 +1573,28 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const detachedPath = pathService.join(worktreesRoot, "detached"); const linksRoot = yield* makeTmpDir("git-vcs-driver-links-"); const checkoutLink = pathService.join(linksRoot, "checkout"); + const nestedDirectory = pathService.join(cwd, "packages", "server"); + yield* fileSystem.makeDirectory(nestedDirectory, { recursive: true }); yield* git(cwd, ["worktree", "add", "--detach", detachedPath, "HEAD"]); yield* fileSystem.symlink(cwd, checkoutLink); const driver = yield* GitVcsDriver.GitVcsDriver; - const refs = yield* driver.listRefs({ cwd: checkoutLink, refresh: true }); + const inventory = yield* driver.listWorktrees( + pathService.join(checkoutLink, "packages", "server"), + ); assert.deepEqual( - refs.worktrees.toSorted((left, right) => left.path.localeCompare(right.path)), + inventory.worktrees.toSorted((left, right) => left.path.localeCompare(right.path)), [ { path: yield* fileSystem.realPath(cwd), refName: initialBranch }, { path: yield* fileSystem.realPath(detachedPath), refName: null }, ].toSorted((left, right) => left.path.localeCompare(right.path)), ); + assert.equal(inventory.currentWorktreeRoot, yield* fileSystem.realPath(cwd)); + assert.equal( + inventory.repositoryCommonDir, + yield* fileSystem.realPath(pathService.join(cwd, ".git")), + ); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 7aadfee971f0..722692b1d6bd 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -25,7 +25,6 @@ import { type ReviewDiffPreviewInput, type ReviewDiffPreviewSource, type VcsRef, - type VcsWorktreeCheckout, } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { compactTraceAttributes } from "@t3tools/shared/observability"; @@ -145,7 +144,7 @@ interface GitRepositoryPaths { interface GitRefsSnapshot { readonly localBranches: ReadonlyArray; readonly remoteBranches: ReadonlyArray; - readonly worktrees: ReadonlyArray; + readonly worktrees: ReadonlyArray; readonly hasPrimaryRemote: boolean; } @@ -249,19 +248,17 @@ function paginateBranches(input: { }; } -function parseWorktreeCheckouts(stdout: string): ReadonlyArray { - const worktrees: Array = []; +function parseWorktreeCheckouts(stdout: string): ReadonlyArray { + const worktrees: Array = []; let currentPath: string | null = null; let currentBranch: string | null = null; - let currentPrunable = false; const flush = () => { - if (currentPath !== null && !currentPrunable) { + if (currentPath !== null) { worktrees.push({ path: currentPath, refName: currentBranch }); } currentPath = null; currentBranch = null; - currentPrunable = false; }; for (const field of stdout.split("\0")) { @@ -271,8 +268,6 @@ function parseWorktreeCheckouts(stdout: string): ReadonlyArray (trimmed.length > 0 ? trimmed : null)), ); + const readGitWorktrees = Effect.fn("GitVcsDriver.readGitWorktrees")(function* ( + gitCommonDir: string, + tolerateFailure = false, + ) { + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + const worktreeListResult = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.listWorktrees", + fetchCwd, + ["--git-dir", gitCommonDir, "worktree", "list", "--porcelain", "-z"], + { + allowNonZeroExit: tolerateFailure, + timeoutMs: 30_000, + maxOutputBytes: 16 * 1024 * 1024, + fallbackErrorDetail: "Git worktree enumeration failed.", + }, + ); + if (worktreeListResult.exitCode !== 0) { + return []; + } + const parsedWorktreeEntries = parseWorktreeCheckouts(worktreeListResult.stdout).map( + (worktree) => ({ + ...worktree, + path: path.normalize(path.resolve(worktree.path)), + }), + ); + return yield* Effect.forEach( + parsedWorktreeEntries, + (worktree) => + fileSystem.realPath(worktree.path).pipe( + Effect.map((canonicalPath) => ({ ...worktree, path: canonicalPath })), + Effect.orElseSucceed(() => worktree), + ), + { concurrency: 16 }, + ); + }); + + const listWorktrees: GitVcsDriver.GitVcsDriver["Service"]["listWorktrees"] = Effect.fn( + "GitVcsDriver.listWorktrees", + )(function* (cwd) { + const repositoryPaths = yield* resolveRepositoryPaths(cwd, true); + if (repositoryPaths === null) { + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.listWorktrees", + cwd, + args: ["worktree", "list", "--porcelain", "-z"], + }), + detail: "The requested directory is not inside a Git repository.", + }); + } + return { + repositoryCommonDir: repositoryPaths.gitCommonDir, + currentWorktreeRoot: repositoryPaths.worktreeRoot, + worktrees: yield* readGitWorktrees(repositoryPaths.gitCommonDir), + }; + }); + const readGitRefsSnapshot = Effect.fn("readGitRefsSnapshot")(function* (gitCommonDir: string) { const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; const gitDirArgs = ["--git-dir", gitCommonDir] as const; - const [refsResult, defaultRefResult, worktreeListResult, remoteNamesResult] = yield* Effect.all( + const [refsResult, defaultRefResult, worktrees, remoteNamesResult] = yield* Effect.all( [ executeGitWithStableDiagnostics( "GitVcsDriver.listRefs.snapshotRefs", @@ -2620,16 +2673,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* allowNonZeroExit: true, }, ), - executeGit( - "GitVcsDriver.listRefs.worktreeList", - fetchCwd, - [...gitDirArgs, "worktree", "list", "--porcelain", "-z"], - { - timeoutMs: 30_000, - allowNonZeroExit: true, - maxOutputBytes: 16 * 1024 * 1024, - }, - ), + readGitWorktrees(gitCommonDir, true), executeGit("GitVcsDriver.listRefs.remoteNames", fetchCwd, [...gitDirArgs, "remote"], { timeoutMs: 5_000, allowNonZeroExit: true, @@ -2649,15 +2693,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* defaultRefResult.exitCode === 0 ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null; - const parsedWorktreeEntries = - worktreeListResult.exitCode === 0 - ? parseWorktreeCheckouts(worktreeListResult.stdout).map((worktree) => ({ - ...worktree, - path: path.normalize(path.resolve(worktree.path)), - })) - : []; - const existingWorktreeEntries = yield* Effect.filter( - parsedWorktreeEntries, + const existingWorktrees = yield* Effect.filter( + worktrees, (worktree) => fileSystem.stat(worktree.path).pipe( Effect.as(true), @@ -2665,17 +2702,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), { concurrency: 16 }, ); - const worktrees = yield* Effect.forEach( - existingWorktreeEntries, - (worktree) => - fileSystem.realPath(worktree.path).pipe( - Effect.map((canonicalPath) => ({ ...worktree, path: canonicalPath })), - Effect.orElseSucceed(() => worktree), - ), - { concurrency: 16 }, - ); const worktreeMap = new Map( - worktrees.flatMap((worktree) => + existingWorktrees.flatMap((worktree) => worktree.refName === null ? [] : ([[worktree.refName, worktree.path]] as const), ), ); @@ -2851,7 +2879,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (repositoryPaths === null) { return { refs: [], - worktrees: [], isRepo: false, hasPrimaryRemote: false, nextCursor: null, @@ -2896,7 +2923,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { refs: [...refs.refs], - worktrees: [...snapshot.worktrees], isRepo: true, hasPrimaryRemote: snapshot.hasPrimaryRemote, nextCursor: refs.nextCursor, @@ -3413,6 +3439,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* getReviewDiffFileContents, readConfigValue, listRefs, + listWorktrees, createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)), fetchPullRequestBranch: (input) => withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index befe539787ee..98483a357633 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -338,9 +338,14 @@ worktree, non-repository path, or branch mismatch is visible without changing ei Lists the project root and every Git-registered worktree in the calling thread's current project, including detached checkouts. Git's canonical common-directory inventory and real paths determine repository membership, so symlinked paths and saved branch labels are not treated as proof. Each -entry includes its listed and actual branch, dirty state, and threads bound to the checkout. +result distinguishes the project's configured execution directory from its canonical physical +worktree root and Git common directory. Each entry includes its listed and actual branch, dirty +state, and threads bound to the checkout. Bindings keep each thread's recorded branch and worktree path separate from the actual checkout. -The tool does not create, remove, prune, or repair worktrees. +Results are paginated before status reads, and each entry returns a bounded binding list with its +full binding count. A missing or unreadable checkout remains in the page with an availability and +error instead of failing discovery of the other worktrees. The tool does not create, remove, +prune, or repair worktrees. ## Delegated Task Lifecycle diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 767d1676e973..c6f68d1d0cde 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -76,8 +76,10 @@ Agents running through T3 Code can inspect the checkout recorded on their thread 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 worktree inventory. These read paths apply only to the calling thread's current project and -do not create, remove, prune, or revive worktrees. +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. ## Review and merge diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index d7a4692fc317..740dd97a6b08 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -612,7 +612,7 @@ describe("cached VCS refs", () => { ), ); - it.effect("emits persisted refs before a live refresh", () => + it.effect("emits a persisted legacy branch-list shape before a live refresh", () => Effect.scoped( Effect.gen(function* () { const client = { diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 5dda491b009b..08fc17d114b6 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import { VcsCreateWorktreeInput, + VcsListRefsResult, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, GitRunStackedActionResult, @@ -20,6 +21,27 @@ const decodePreparePullRequestThreadResult = Schema.decodeUnknownSync( const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput); const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult); const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); +const decodeListRefsResult = Schema.decodeUnknownSync(VcsListRefsResult); + +describe("VcsListRefsResult", () => { + it("decodes the established branch-list response without worktree inventory", () => { + expect( + decodeListRefsResult({ + refs: [], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 0, + }), + ).toEqual({ + refs: [], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 0, + }); + }); +}); describe("VcsCreateWorktreeInput", () => { it("accepts omitted newRefName for existing-refName worktrees", () => { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 1f8d92a11773..daee47bcc204 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -89,11 +89,6 @@ const VcsWorktree = Schema.Struct({ refName: TrimmedNonEmptyStringSchema, }); -export const VcsWorktreeCheckout = Schema.Struct({ - path: TrimmedNonEmptyStringSchema, - refName: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), -}); -export type VcsWorktreeCheckout = typeof VcsWorktreeCheckout.Type; const GitResolvedPullRequest = Schema.Struct({ number: PositiveInt, title: TrimmedNonEmptyStringSchema, @@ -272,7 +267,6 @@ export type VcsStatusStreamEvent = typeof VcsStatusStreamEvent.Type; export const VcsListRefsResult = Schema.Struct({ refs: Schema.Array(VcsRef), - worktrees: Schema.Array(VcsWorktreeCheckout), isRepo: Schema.Boolean, hasPrimaryRemote: Schema.Boolean, nextCursor: NonNegativeInt.pipe(Schema.NullOr), diff --git a/packages/contracts/src/worktreeMcp.ts b/packages/contracts/src/worktreeMcp.ts index 5d8408efd6e7..40788ef19e91 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"; /** * Input for the `t3_worktree_handoff` MCP tool. @@ -146,6 +146,13 @@ export const WorktreeMcpThreadBinding = Schema.Struct({ }); export type WorktreeMcpThreadBinding = typeof WorktreeMcpThreadBinding.Type; +export const WorktreeMcpListInput = Schema.Struct({ + cursor: Schema.optional(NonNegativeInt), + limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(50))), + bindingLimit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(50))), +}); +export type WorktreeMcpListInput = typeof WorktreeMcpListInput.Type; + export const WorktreeMcpListEntry = Schema.Struct({ path: TrimmedNonEmptyString, branch: Schema.NullOr(TrimmedNonEmptyString), @@ -153,13 +160,20 @@ export const WorktreeMcpListEntry = Schema.Struct({ isRepo: Schema.Boolean, isProjectRoot: Schema.Boolean, hasWorkingTreeChanges: Schema.Boolean, + availability: Schema.Literals(["available", "missing", "unreadable"]), + statusError: Schema.NullOr(Schema.String), bindings: Schema.Array(WorktreeMcpThreadBinding), + bindingCount: NonNegativeInt, }); export type WorktreeMcpListEntry = typeof WorktreeMcpListEntry.Type; export const WorktreeMcpListResult = Schema.Struct({ projectWorkspaceRoot: TrimmedNonEmptyString, + repositoryCommonDir: TrimmedNonEmptyString, + projectWorktreeRoot: TrimmedNonEmptyString, worktrees: Schema.Array(WorktreeMcpListEntry), + nextCursor: Schema.NullOr(NonNegativeInt), + total: NonNegativeInt, }); export type WorktreeMcpListResult = typeof WorktreeMcpListResult.Type; From 02a2383d12e9b2975d40a0c3ce64b9620beeef7b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 15:47:14 -0700 Subject: [PATCH 04/16] fix(mcp): preserve stale worktree discovery --- .../server/src/mcp/WorktreeMcpService.test.ts | 73 +++++++++++++++++-- apps/server/src/mcp/WorktreeMcpService.ts | 66 ++++++++++++----- 2 files changed, 113 insertions(+), 26 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 077e4ddd3c16..d040c31449c8 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -122,7 +122,10 @@ interface HarnessOptions { readonly refName: string | null; }>; readonly projectWorktreeRoot?: string; - readonly workspaceStatuses?: Readonly>; + readonly workspaceStatuses?: Readonly< + Record + >; + readonly worktreeInventoryFailsFor?: ReadonlySet; readonly localStatusFailsOnCall?: number; readonly projectThreads?: ReadonlyArray<{ readonly id: ThreadId; @@ -318,12 +321,14 @@ const makeHarness = (options: HarnessOptions = {}) => { ...configuredWorktrees, ]; const listWorktrees = vi.fn((cwd: string) => - Effect.succeed({ - repositoryCommonDir: "/repo/.git", - currentWorktreeRoot: - listedWorktrees.find((worktree) => worktree.path === cwd)?.path ?? projectWorktreeRoot, - worktrees: listedWorktrees, - }), + options.worktreeInventoryFailsFor?.has(cwd) === true + ? (Effect.fail("simulated worktree inventory failure") as never) + : Effect.succeed({ + repositoryCommonDir: "/repo/.git", + currentWorktreeRoot: + listedWorktrees.find((worktree) => worktree.path === cwd)?.path ?? projectWorktreeRoot, + worktrees: listedWorktrees, + }), ); const workspaceStatuses = new Map( Object.entries( @@ -342,7 +347,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: @@ -1133,6 +1138,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("fails when the worktree capability is missing", () => { const harness = makeHarness({ capabilities: new Set(["preview"]) }); return Effect.gen(function* () { @@ -1299,6 +1330,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"); diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 29fdd6038de9..300ad219e84a 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -509,28 +509,41 @@ 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), + 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 ? "workspace_missing" - : !actual.isRepo + : !actual.isRepo || Option.isNone(workspaceInventory) ? "not_repository" - : actual.refName !== projection.thread.branch - ? "branch_mismatch" - : "in_sync"; + : workspaceInventory.value.repositoryCommonDir !== + projectInventory.repositoryCommonDir || + physicalWorkspacePath === null || + !projectInventory.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, @@ -632,6 +645,23 @@ const make = Effect.gen(function* () { } as const; } const actual = statusExit.value; + if (!actual.isRepo) { + const exists = yield* fileSystem + .exists(workspacePath) + .pipe(Effect.orElseSucceed(() => false)); + return { + path: workspacePath, + branch, + actualBranch: actual.refName, + isRepo: false, + isProjectRoot: workspacePath === projectWorktreeRoot, + hasWorkingTreeChanges: actual.hasWorkingTreeChanges, + availability: exists ? "unreadable" : "missing", + statusError: exists ? "Path is not a Git worktree." : "Worktree path does not exist.", + bindings: bindings.slice(0, bindingLimit), + bindingCount: bindings.length, + } as const; + } return { path: workspacePath, branch, From bc7e434787f0e78b551c94139c01ed9369888094 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 15:55:29 -0700 Subject: [PATCH 05/16] fix(mcp): tolerate non-repository workspace status --- .../server/src/mcp/WorktreeMcpService.test.ts | 84 ++++++++++++++++--- apps/server/src/mcp/WorktreeMcpService.ts | 24 +++--- 2 files changed, 84 insertions(+), 24 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index d040c31449c8..2a88c25948d8 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -14,12 +14,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, @@ -33,6 +35,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"; @@ -122,6 +126,8 @@ interface HarnessOptions { readonly refName: string | null; }>; readonly projectWorktreeRoot?: string; + readonly projectWorkspaceRoot?: string; + readonly useRealNonRepositoryWorkflow?: boolean; readonly workspaceStatuses?: Readonly< Record >; @@ -210,12 +216,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) : Option.none(), ), ); @@ -403,6 +413,37 @@ 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( @@ -418,18 +459,7 @@ const makeHarness = (options: HarnessOptions = {}) => { ServerSettings.layerTest({ newWorktreesStartFromOrigin: options.newWorktreesStartFromOrigin ?? false, }), - Layer.mock(GitWorkflowService.GitWorkflowService)({ - listRefs, - listWorktrees, - listLocalBranchNames, - localStatus, - invalidateLocalStatus, - fetchRemote, - resolveRemoteTrackingCommit, - createWorktree, - removeWorktree, - deleteLocalBranch, - } satisfies Partial), + gitWorkflowLayer, Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ runForThread, } satisfies Partial), @@ -1086,6 +1116,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* () { diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 300ad219e84a..038d82ddd4c8 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -519,7 +519,7 @@ const make = Effect.gen(function* () { [ readDefaultStartFromOrigin, readWorkspaceStatus(workspacePath), - loadWorktrees(projectWorkspaceRoot), + Effect.option(loadWorktrees(projectWorkspaceRoot)), Effect.option(loadWorktrees(workspacePath)), fileSystem.exists(workspacePath).pipe(Effect.orElseSucceed(() => false)), ], @@ -532,18 +532,20 @@ const make = Effect.gen(function* () { const agreement = !actual.isRepo && !workspaceExists ? "workspace_missing" - : !actual.isRepo || Option.isNone(workspaceInventory) + : !actual.isRepo ? "not_repository" - : workspaceInventory.value.repositoryCommonDir !== - projectInventory.repositoryCommonDir || - physicalWorkspacePath === null || - !projectInventory.worktrees.some( - (worktree) => worktree.path === physicalWorkspacePath, - ) + : Option.isNone(projectInventory) || Option.isNone(workspaceInventory) ? "workspace_missing" - : actual.refName !== projection.thread.branch - ? "branch_mismatch" - : "in_sync"; + : 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, From cdac434229e3eb114b63935e3212d96b49064240 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:04:28 -0700 Subject: [PATCH 06/16] fix(mcp): resolve nested worktree bindings --- .../server/src/mcp/WorktreeMcpService.test.ts | 32 +++++++++++++++++++ apps/server/src/mcp/WorktreeMcpService.ts | 18 +++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 2a88c25948d8..c8224d6253da 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -1431,6 +1431,38 @@ describe("t3_worktree_list", () => { expect(result.worktrees[0]?.bindings).toHaveLength(1); }); }); + + 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(1); + }); + }); }); describe("WorktreeMcpHandoffInput schema", () => { diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 038d82ddd4c8..e23cebc252be 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -132,11 +132,25 @@ const make = Effect.gen(function* () { return fileSystem.realPath(normalized).pipe(Effect.orElseSucceed(() => normalized)); }; + const isPathInside = (candidate: string, root: string) => + candidate === root || + (candidate.startsWith(root) && + (root.endsWith("/") || + root.endsWith("\\") || + candidate[root.length] === "/" || + candidate[root.length] === "\\")); + const threadWorkspacePath = Effect.fn("WorktreeMcpService.threadWorkspacePath")(function* ( thread: Pick, projectWorkspaceRoot: string, + worktreeRoots: ReadonlyArray = [projectWorkspaceRoot], ) { - return yield* canonicalizePath(thread.worktreePath ?? projectWorkspaceRoot); + const recordedPath = yield* canonicalizePath(thread.worktreePath ?? projectWorkspaceRoot); + return ( + worktreeRoots + .filter((root) => isPathInside(recordedPath, root)) + .toSorted((left, right) => right.length - left.length)[0] ?? recordedPath + ); }); const loadWorktrees = Effect.fn("WorktreeMcpService.loadWorktrees")(function* ( @@ -603,7 +617,7 @@ const make = Effect.gen(function* () { : null; const bindingLimit = input.bindingLimit ?? 20; const threadWorkspaces = yield* Effect.forEach(threads, (thread) => - threadWorkspacePath(thread, projectWorktreeRoot).pipe( + threadWorkspacePath(thread, projectWorktreeRoot, [...branchByWorkspacePath.keys()]).pipe( Effect.map((workspacePath) => [thread, workspacePath] as const), ), ); From 927e36f17d1018654debedbcf4214b71b9b1a91b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:16:41 -0700 Subject: [PATCH 07/16] fix(mcp): resolve nested workspace identity --- .../server/src/mcp/WorktreeMcpService.test.ts | 63 ++++++++++++++++--- apps/server/src/mcp/WorktreeMcpService.ts | 54 ++++++++++------ apps/server/src/vcs/GitVcsDriverCore.test.ts | 23 +++++++ 3 files changed, 115 insertions(+), 25 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index c8224d6253da..4bf5a6065157 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -125,6 +125,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 projectWorkspaceRoot?: string; readonly useRealNonRepositoryWorkflow?: boolean; @@ -333,12 +346,15 @@ 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: - listedWorktrees.find((worktree) => worktree.path === cwd)?.path ?? projectWorktreeRoot, - worktrees: listedWorktrees, - }), + : 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( @@ -1460,7 +1476,40 @@ describe("t3_worktree_list", () => { ], }); expect(harness.localStatus).toHaveBeenCalledTimes(1); - expect(harness.listWorktrees).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); }); }); }); diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index e23cebc252be..fd0552719786 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -132,25 +132,11 @@ const make = Effect.gen(function* () { return fileSystem.realPath(normalized).pipe(Effect.orElseSucceed(() => normalized)); }; - const isPathInside = (candidate: string, root: string) => - candidate === root || - (candidate.startsWith(root) && - (root.endsWith("/") || - root.endsWith("\\") || - candidate[root.length] === "/" || - candidate[root.length] === "\\")); - const threadWorkspacePath = Effect.fn("WorktreeMcpService.threadWorkspacePath")(function* ( thread: Pick, projectWorkspaceRoot: string, - worktreeRoots: ReadonlyArray = [projectWorkspaceRoot], ) { - const recordedPath = yield* canonicalizePath(thread.worktreePath ?? projectWorkspaceRoot); - return ( - worktreeRoots - .filter((root) => isPathInside(recordedPath, root)) - .toSorted((left, right) => right.length - left.length)[0] ?? recordedPath - ); + return yield* canonicalizePath(thread.worktreePath ?? projectWorkspaceRoot); }); const loadWorktrees = Effect.fn("WorktreeMcpService.loadWorktrees")(function* ( @@ -616,10 +602,42 @@ const make = Effect.gen(function* () { ? cursor + selectedWorktrees.length : null; const bindingLimit = input.bindingLimit ?? 20; - const threadWorkspaces = yield* Effect.forEach(threads, (thread) => - threadWorkspacePath(thread, projectWorktreeRoot, [...branchByWorkspacePath.keys()]).pipe( - Effect.map((workspacePath) => [thread, workspacePath] as const), + const recordedThreadWorkspaces = yield* Effect.forEach(threads, (thread) => + threadWorkspacePath(thread, projectWorktreeRoot).pipe( + Effect.map((recordedPath) => [thread, recordedPath] as const), + ), + ); + const unresolvedRecordedPaths = [ + ...new Set( + recordedThreadWorkspaces + .map(([, recordedPath]) => recordedPath) + .filter((recordedPath) => !branchByWorkspacePath.has(recordedPath)), ), + ]; + const physicalRootByRecordedPath = new Map(); + yield* Effect.forEach( + unresolvedRecordedPaths, + (recordedPath) => + Effect.option(loadWorktrees(recordedPath)).pipe( + Effect.map((candidateInventory) => { + if ( + Option.isSome(candidateInventory) && + candidateInventory.value.repositoryCommonDir === inventory.repositoryCommonDir && + candidateInventory.value.currentWorktreeRoot !== null && + branchByWorkspacePath.has(candidateInventory.value.currentWorktreeRoot) + ) { + physicalRootByRecordedPath.set( + recordedPath, + candidateInventory.value.currentWorktreeRoot, + ); + } + }), + ), + { concurrency: 8 }, + ); + const threadWorkspaces = recordedThreadWorkspaces.map( + ([thread, recordedPath]) => + [thread, physicalRootByRecordedPath.get(recordedPath) ?? recordedPath] as const, ); const worktrees = yield* Effect.forEach( diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index de431028464b..b0bb6ee6ef0b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1598,6 +1598,29 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("resolves a nested repository independently from its containing checkout", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const nestedDirectory = pathService.join(cwd, "vendor", "independent"); + yield* fileSystem.makeDirectory(nestedDirectory, { recursive: true }); + yield* initRepoWithCommit(nestedDirectory); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const outerInventory = yield* driver.listWorktrees(cwd); + const nestedInventory = yield* driver.listWorktrees(nestedDirectory); + + assert.equal(outerInventory.currentWorktreeRoot, yield* fileSystem.realPath(cwd)); + assert.equal( + nestedInventory.currentWorktreeRoot, + yield* fileSystem.realPath(nestedDirectory), + ); + assert.notEqual(nestedInventory.repositoryCommonDir, outerInventory.repositoryCommonDir); + }), + ); + // NTFS rejects a newline in a file name, so there is nothing to preserve there. it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( "preserves newline characters in worktree paths when listing refs", From 96d40cf2b772dace47e63fbf364afbfcdb8d87b0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:31:52 -0700 Subject: [PATCH 08/16] fix(mcp): list archived workspace bindings --- .../server/src/mcp/WorktreeMcpService.test.ts | 101 ++++++++++++++---- apps/server/src/mcp/WorktreeMcpService.ts | 11 +- 2 files changed, 87 insertions(+), 25 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 4bf5a6065157..a5b47a8760b3 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -4,6 +4,7 @@ import { CommandId, EnvironmentId, type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadShell, type Project, ProjectId, ProviderInstanceId, @@ -153,6 +154,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; + }; } const makeHarness = (options: HarnessOptions = {}) => { @@ -242,31 +249,52 @@ const makeHarness = (options: HarnessOptions = {}) => { : Option.none(), ), ); - const listProjectThreads = vi.fn(() => - Effect.succeed( - ( - options.projectThreads ?? [ + const projectThreadShells = ( + 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.active === true ? "running" : "idle", + activeRunId: item.active === true ? "run-active" : null, + lineage: { relationshipToParent: "none" }, + }) as OrchestrationV2ThreadShell, + ); + const archivedThreadShells = + options.archivedProjectThread === undefined + ? [] + : ([ { - id: threadId, - title: "Worktree test thread", - branch: thread?.branch ?? null, - worktreePath: thread?.worktreePath ?? null, - }, - ] - ).map( - (item) => - ({ - id: item.id, + ...(projectThreadShells[0] ?? makeProjection({}).thread), + id: options.archivedProjectThread.id, projectId, - title: item.title, - branch: item.branch, - worktreePath: item.worktreePath, - status: item.active === true ? "running" : "idle", - activeRunId: item.active === true ? "run-active" : null, + 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 never, - ), - ), + }, + ] as ReadonlyArray); + const listProjectThreads = vi.fn(() => Effect.succeed(projectThreadShells)); + const getShellSnapshot = vi.fn(() => + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 1, + threads: projectThreadShells, + archivedThreads: archivedThreadShells, + } as never), ); const removeWorktree = vi.fn((_: unknown) => options.removeWorktreeFails @@ -465,6 +493,7 @@ const makeHarness = (options: HarnessOptions = {}) => { Layer.mergeAll( Layer.mock(ThreadManagementService)({ dispatch, + getShellSnapshot, getThreadProjection, listProjectThreads, sendToThread, @@ -1448,6 +1477,34 @@ describe("t3_worktree_list", () => { }); }); + 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({ diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index fd0552719786..b6651f3e2330 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -150,9 +150,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 From f1bb318e47cd88e328cd72248e24de18bf83d482 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:36:26 -0700 Subject: [PATCH 09/16] test(mcp): type archived workspace fixtures --- apps/server/src/mcp/WorktreeMcpService.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index a5b47a8760b3..3d8290c4564c 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -269,7 +269,7 @@ const makeHarness = (options: HarnessOptions = {}) => { status: item.active === true ? "running" : "idle", activeRunId: item.active === true ? "run-active" : null, lineage: { relationshipToParent: "none" }, - }) as OrchestrationV2ThreadShell, + }) as unknown as OrchestrationV2ThreadShell, ); const archivedThreadShells = options.archivedProjectThread === undefined @@ -286,7 +286,7 @@ const makeHarness = (options: HarnessOptions = {}) => { archivedAt: "2026-01-02T00:00:00.000Z", lineage: { relationshipToParent: "none" }, }, - ] as ReadonlyArray); + ] as unknown as ReadonlyArray); const listProjectThreads = vi.fn(() => Effect.succeed(projectThreadShells)); const getShellSnapshot = vi.fn(() => Effect.succeed({ From 2ffceffd47e881f71f90115b62b4740509019354 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:38:32 -0700 Subject: [PATCH 10/16] test(mcp): use complete thread shell fixtures --- .../server/src/mcp/WorktreeMcpService.test.ts | 91 +++++++++++++++---- 1 file changed, 72 insertions(+), 19 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 3d8290c4564c..d1ec9c39e285 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -8,11 +8,13 @@ import { type Project, ProjectId, ProviderInstanceId, + RunId, ThreadId, WorktreeMcpHandoffInput, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; @@ -79,6 +81,51 @@ const makeProjection = (overrides: ThreadFixture = {}): OrchestrationV2ThreadPro }, }) as OrchestrationV2ThreadProjection; +const shellFixture = ( + overrides: Partial, +): OrchestrationV2ThreadShell => { + const timestamp = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"); + return { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId, + title: "Worktree test thread", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "test-model", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: threadId, + }, + forkedFrom: null, + activeProviderThreadId: null, + latestRunId: null, + activeRunId: null, + status: "idle", + pendingRuntimeRequest: null, + latestVisibleMessage: null, + latestUserMessageAt: null, + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + ...overrides, + }; +}; + const project: Project = { id: projectId, title: "Worktree test project", @@ -258,35 +305,41 @@ 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.active === true ? "running" : "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.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 ? [] - : ([ - { - ...(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 ReadonlyArray); + 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({ From 7c3474e196c8783f5c8d7d2667a71499c2cf8ecf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 10:39:39 -0700 Subject: [PATCH 11/16] fix(mcp): bound worktree binding resolution --- .../server/src/mcp/WorktreeMcpService.test.ts | 41 +++++++++++++++++++ apps/server/src/mcp/WorktreeMcpService.ts | 9 +++- .../orchestrator-mcp-server.md | 9 ++-- packages/contracts/src/worktreeMcp.ts | 5 +++ 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index d1ec9c39e285..e53e6d40953e 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -1530,6 +1530,47 @@ describe("t3_worktree_list", () => { }); }); + it.effect("bounds nested binding identity reads by the requested page", () => { + const nestedOne = `${workspaceRoot}/packages/one`; + const nestedTwo = `${workspaceRoot}/packages/two`; + const nestedThree = `${workspaceRoot}/packages/three`; + 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, + }); + expect(result.worktrees[0]?.bindingCount).toBe(2); + expect(harness.listWorktrees).toHaveBeenCalledTimes(2); + }); + }); + it.effect("includes archived thread bindings retained on a physical checkout", () => { const archivedThreadId = ThreadId.make("thread-archived-list-owner"); const harness = makeHarness({ diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index b6651f3e2330..661518b2280a 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -619,9 +619,11 @@ const make = Effect.gen(function* () { .filter((recordedPath) => !branchByWorkspacePath.has(recordedPath)), ), ]; + const bindingPathResolutionLimit = Math.min(400, selectedWorktrees.length * bindingLimit); + const recordedPathsToResolve = unresolvedRecordedPaths.slice(0, bindingPathResolutionLimit); const physicalRootByRecordedPath = new Map(); yield* Effect.forEach( - unresolvedRecordedPaths, + recordedPathsToResolve, (recordedPath) => Effect.option(loadWorktrees(recordedPath)).pipe( Effect.map((candidateInventory) => { @@ -721,6 +723,11 @@ const make = Effect.gen(function* () { projectWorkspaceRoot, repositoryCommonDir: inventory.repositoryCommonDir, projectWorktreeRoot, + bindingPathResolution: { + totalCandidates: unresolvedRecordedPaths.length, + attemptedCandidates: recordedPathsToResolve.length, + truncated: recordedPathsToResolve.length < unresolvedRecordedPaths.length, + }, worktrees, nextCursor, total: allWorktrees.length, diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 98483a357633..b376d719ea36 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -343,9 +343,12 @@ worktree root and Git common directory. Each entry includes its listed and actua state, and threads bound to the checkout. Bindings keep each thread's recorded branch and worktree path separate from the actual checkout. Results are paginated before status reads, and each entry returns a bounded binding list with its -full binding count. A missing or unreadable checkout remains in the page with an availability and -error instead of failing discovery of the other worktrees. The tool does not create, remove, -prune, or repair worktrees. +full binding count when every nested or aliased recorded path was resolved. Path resolution is +bounded by the page and binding limits, with a hard ceiling of 400 lookups. The result reports how +many candidate paths were attempted and whether binding counts are lower bounds because that scan +was truncated. A missing or unreadable checkout remains in the page with an availability and error +instead of failing discovery of the other worktrees. The tool does not create, remove, prune, or +repair worktrees. ## Delegated Task Lifecycle diff --git a/packages/contracts/src/worktreeMcp.ts b/packages/contracts/src/worktreeMcp.ts index 40788ef19e91..cfae2f4cafa4 100644 --- a/packages/contracts/src/worktreeMcp.ts +++ b/packages/contracts/src/worktreeMcp.ts @@ -171,6 +171,11 @@ export const WorktreeMcpListResult = Schema.Struct({ projectWorkspaceRoot: TrimmedNonEmptyString, repositoryCommonDir: TrimmedNonEmptyString, projectWorktreeRoot: TrimmedNonEmptyString, + bindingPathResolution: Schema.Struct({ + totalCandidates: NonNegativeInt, + attemptedCandidates: NonNegativeInt, + truncated: Schema.Boolean, + }), worktrees: Schema.Array(WorktreeMcpListEntry), nextCursor: Schema.NullOr(NonNegativeInt), total: NonNegativeInt, From d95471ff7e8444a55e03705b04dd613041f7e477 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 10:59:12 -0700 Subject: [PATCH 12/16] fix(mcp): page worktree binding resolution --- .../server/src/mcp/WorktreeMcpService.test.ts | 91 +++++++++++++++++++ apps/server/src/mcp/WorktreeMcpService.ts | 58 ++++++++---- .../orchestrator-mcp-server.md | 11 ++- packages/contracts/src/worktreeMcp.ts | 1 + 4 files changed, 139 insertions(+), 22 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index e53e6d40953e..c116f21f9736 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -1565,12 +1565,103 @@ describe("t3_worktree_list", () => { 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({ diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 661518b2280a..a90aa5a05c3a 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -619,29 +619,48 @@ const make = Effect.gen(function* () { .filter((recordedPath) => !branchByWorkspacePath.has(recordedPath)), ), ]; + const selectedWorkspacePaths = new Set( + selectedWorktrees.map(([workspacePath]) => workspacePath), + ); + const isWithinWorkspace = (workspacePath: string, candidatePath: string) => { + const relative = path.relative(workspacePath, candidatePath); + return ( + relative === "" || + (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); + }; + const candidateRecordedPaths = unresolvedRecordedPaths.filter((recordedPath) => { + const nearestListedRoot = [...branchByWorkspacePath.keys()] + .filter((workspacePath) => isWithinWorkspace(workspacePath, recordedPath)) + .toSorted((left, right) => right.length - left.length)[0]; + return nearestListedRoot !== undefined && selectedWorkspacePaths.has(nearestListedRoot); + }); const bindingPathResolutionLimit = Math.min(400, selectedWorktrees.length * bindingLimit); - const recordedPathsToResolve = unresolvedRecordedPaths.slice(0, bindingPathResolutionLimit); + const recordedPathsToResolve = candidateRecordedPaths.slice(0, bindingPathResolutionLimit); const physicalRootByRecordedPath = new Map(); - yield* Effect.forEach( + const candidateResults = yield* Effect.forEach( recordedPathsToResolve, (recordedPath) => - Effect.option(loadWorktrees(recordedPath)).pipe( - Effect.map((candidateInventory) => { - if ( - Option.isSome(candidateInventory) && - candidateInventory.value.repositoryCommonDir === inventory.repositoryCommonDir && - candidateInventory.value.currentWorktreeRoot !== null && - branchByWorkspacePath.has(candidateInventory.value.currentWorktreeRoot) - ) { - physicalRootByRecordedPath.set( - recordedPath, - candidateInventory.value.currentWorktreeRoot, - ); - } - }), + Effect.exit(loadWorktrees(recordedPath)).pipe( + Effect.map((candidateExit) => ({ recordedPath, candidateExit })), ), { concurrency: 8 }, ); + let failedCandidateCount = 0; + for (const { recordedPath, candidateExit } of candidateResults) { + if (Exit.isFailure(candidateExit)) { + failedCandidateCount += 1; + continue; + } + const candidateInventory = candidateExit.value; + if ( + candidateInventory.repositoryCommonDir === inventory.repositoryCommonDir && + candidateInventory.currentWorktreeRoot !== null && + branchByWorkspacePath.has(candidateInventory.currentWorktreeRoot) + ) { + physicalRootByRecordedPath.set(recordedPath, candidateInventory.currentWorktreeRoot); + } + } const threadWorkspaces = recordedThreadWorkspaces.map( ([thread, recordedPath]) => [thread, physicalRootByRecordedPath.get(recordedPath) ?? recordedPath] as const, @@ -724,9 +743,12 @@ const make = Effect.gen(function* () { repositoryCommonDir: inventory.repositoryCommonDir, projectWorktreeRoot, bindingPathResolution: { - totalCandidates: unresolvedRecordedPaths.length, + totalCandidates: candidateRecordedPaths.length, attemptedCandidates: recordedPathsToResolve.length, - truncated: recordedPathsToResolve.length < unresolvedRecordedPaths.length, + truncated: recordedPathsToResolve.length < candidateRecordedPaths.length, + complete: + recordedPathsToResolve.length === candidateRecordedPaths.length && + failedCandidateCount === 0, }, worktrees, nextCursor, diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index b376d719ea36..87c75cf4bad6 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -343,10 +343,13 @@ worktree root and Git common directory. Each entry includes its listed and actua state, and threads bound to the checkout. Bindings keep each thread's recorded branch and worktree path separate from the actual checkout. Results are paginated before status reads, and each entry returns a bounded binding list with its -full binding count when every nested or aliased recorded path was resolved. Path resolution is -bounded by the page and binding limits, with a hard ceiling of 400 lookups. The result reports how -many candidate paths were attempted and whether binding counts are lower bounds because that scan -was truncated. A missing or unreadable checkout remains in the page with an availability and error +full binding count when every nested or aliased recorded path for that page was resolved. Path +resolution first selects recorded paths whose nearest listed physical checkout is on the requested +page, then verifies each candidate through Git repository and worktree identity. The work is bounded +by the page and binding limits, with a hard ceiling of 400 lookups. The result reports how many +page candidates were attempted, whether the candidate list was truncated, and whether resolution +completed without a Git inventory failure. Binding counts are lower bounds unless resolution is +complete. A missing or unreadable checkout remains in the page with an availability and error instead of failing discovery of the other worktrees. The tool does not create, remove, prune, or repair worktrees. diff --git a/packages/contracts/src/worktreeMcp.ts b/packages/contracts/src/worktreeMcp.ts index cfae2f4cafa4..df0528e89dc1 100644 --- a/packages/contracts/src/worktreeMcp.ts +++ b/packages/contracts/src/worktreeMcp.ts @@ -175,6 +175,7 @@ export const WorktreeMcpListResult = Schema.Struct({ totalCandidates: NonNegativeInt, attemptedCandidates: NonNegativeInt, truncated: Schema.Boolean, + complete: Schema.Boolean, }), worktrees: Schema.Array(WorktreeMcpListEntry), nextCursor: Schema.NullOr(NonNegativeInt), From d44611ddae49d9648b0eee232d9e798ea7461778 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:03:05 -0700 Subject: [PATCH 13/16] fix(mcp): preserve inventory interruption semantics --- apps/server/src/mcp/WorktreeMcpService.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index a90aa5a05c3a..f6c5ff1049a0 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -641,24 +641,24 @@ const make = Effect.gen(function* () { const candidateResults = yield* Effect.forEach( recordedPathsToResolve, (recordedPath) => - Effect.exit(loadWorktrees(recordedPath)).pipe( - Effect.map((candidateExit) => ({ recordedPath, candidateExit })), + Effect.option(loadWorktrees(recordedPath)).pipe( + Effect.map((candidateInventory) => ({ recordedPath, candidateInventory })), ), { concurrency: 8 }, ); let failedCandidateCount = 0; - for (const { recordedPath, candidateExit } of candidateResults) { - if (Exit.isFailure(candidateExit)) { + for (const { recordedPath, candidateInventory } of candidateResults) { + if (Option.isNone(candidateInventory)) { failedCandidateCount += 1; continue; } - const candidateInventory = candidateExit.value; + const candidate = candidateInventory.value; if ( - candidateInventory.repositoryCommonDir === inventory.repositoryCommonDir && - candidateInventory.currentWorktreeRoot !== null && - branchByWorkspacePath.has(candidateInventory.currentWorktreeRoot) + candidate.repositoryCommonDir === inventory.repositoryCommonDir && + candidate.currentWorktreeRoot !== null && + branchByWorkspacePath.has(candidate.currentWorktreeRoot) ) { - physicalRootByRecordedPath.set(recordedPath, candidateInventory.currentWorktreeRoot); + physicalRootByRecordedPath.set(recordedPath, candidate.currentWorktreeRoot); } } const threadWorkspaces = recordedThreadWorkspaces.map( From f906b3ed0e696583137aeed96252474cc3cdd6ff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:41:07 -0700 Subject: [PATCH 14/16] chore(vcs): drop unused cached worktree field --- apps/server/src/vcs/GitVcsDriverCore.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 722692b1d6bd..05bde710acb5 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -144,7 +144,6 @@ interface GitRepositoryPaths { interface GitRefsSnapshot { readonly localBranches: ReadonlyArray; readonly remoteBranches: ReadonlyArray; - readonly worktrees: ReadonlyArray; readonly hasPrimaryRemote: boolean; } @@ -2760,7 +2759,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { localBranches: localBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), remoteBranches: remoteBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), - worktrees, hasPrimaryRemote: remoteNames.includes("origin"), } satisfies GitRefsSnapshot; }); From 74e3505c39a6e35923c53a5c63c39a7a3ff078b9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:26:49 -0700 Subject: [PATCH 15/16] fix(mcp): preserve worktree status defects --- .../server/src/mcp/WorktreeMcpService.test.ts | 35 +++++++++++++++++++ apps/server/src/mcp/WorktreeMcpService.ts | 14 +++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index c116f21f9736..ac6ce3b1bbe6 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -194,6 +194,7 @@ interface HarnessOptions { >; readonly worktreeInventoryFailsFor?: ReadonlySet; readonly localStatusFailsOnCall?: number; + readonly localStatusFailure?: "typed" | "defect" | "interrupt"; readonly projectThreads?: ReadonlyArray<{ readonly id: ThreadId; readonly title: string; @@ -449,6 +450,15 @@ const makeHarness = (options: HarnessOptions = {}) => { let localStatusCallCount = 0; const localStatus = vi.fn((input: { readonly cwd: string }) => { localStatusCallCount += 1; + if (localStatusCallCount === 1 && options.localStatusFailure === "defect") { + return Effect.die(new Error("simulated local status defect")); + } + if (localStatusCallCount === 1 && options.localStatusFailure === "interrupt") { + return Effect.failCause(Cause.interrupt()) as never; + } + if (localStatusCallCount === 1 && options.localStatusFailure === "typed") { + return Effect.fail("simulated local status failure") as never; + } if (options.localStatusFailsOnCall === localStatusCallCount) { return Effect.fail("simulated local status failure") as never; } @@ -1486,6 +1496,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({ diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index f6c5ff1049a0..a0bce0c8503a 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -16,7 +16,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 Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -681,12 +680,17 @@ const make = Effect.gen(function* () { active: thread.activeRunId !== null, callingThread: thread.id === scope.threadId, })); - const statusExit = yield* Effect.exit(readWorkspaceStatus(workspacePath)); - if (Exit.isFailure(statusExit)) { + const statusResult = yield* readWorkspaceStatus(workspacePath).pipe( + Effect.match({ + onFailure: (error) => ({ _tag: "failure" as const, error }), + onSuccess: (status) => ({ _tag: "success" as const, status }), + }), + ); + if (statusResult._tag === "failure") { const exists = yield* fileSystem .exists(workspacePath) .pipe(Effect.orElseSucceed(() => false)); - const detail = errorMessage(Cause.squash(statusExit.cause)); + const detail = errorMessage(statusResult.error); yield* Effect.logWarning("unable to read listed worktree status", { workspacePath, detail, @@ -704,7 +708,7 @@ const make = Effect.gen(function* () { bindingCount: bindings.length, } as const; } - const actual = statusExit.value; + const actual = statusResult.status; if (!actual.isRepo) { const exists = yield* fileSystem .exists(workspacePath) From 718558bfe90355b4e072ef0279d4115d6f8e10fc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:33:59 -0700 Subject: [PATCH 16/16] test(mcp): use typed worktree status failures --- apps/server/src/mcp/WorktreeMcpService.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index ac6ce3b1bbe6..17534520a585 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, EnvironmentId, + GitManagerError, type OrchestrationV2ThreadProjection, type OrchestrationV2ThreadShell, type Project, @@ -454,10 +455,16 @@ const makeHarness = (options: HarnessOptions = {}) => { return Effect.die(new Error("simulated local status defect")); } if (localStatusCallCount === 1 && options.localStatusFailure === "interrupt") { - return Effect.failCause(Cause.interrupt()) as never; + return Effect.interrupt; } if (localStatusCallCount === 1 && options.localStatusFailure === "typed") { - return Effect.fail("simulated local status failure") as never; + return Effect.fail( + new GitManagerError({ + operation: "WorktreeMcpService.test.localStatus", + cwd: input.cwd, + detail: "simulated local status failure", + }), + ); } if (options.localStatusFailsOnCall === localStatusCallCount) { return Effect.fail("simulated local status failure") as never;