From f0115110f1805768780bc90e3f7a95b0cfdf42b0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 14:54:59 -0700 Subject: [PATCH 01/14] 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 c287f68b3bc1..f12e599f761f 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) => { @@ -463,21 +521,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 35a3eb55a7e8..4975a7980003 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 f1ac2fbe2afd..c819cb4d246e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -782,13 +782,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 3aa9a6cb401a..8e7741b3b718 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -140,7 +140,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` @@ -302,6 +302,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 @@ -345,6 +358,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 3aa856cf9923..ffe9ede456cf 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -65,6 +65,14 @@ The **Source Control settings** page shows you exactly what's connected: Run a quick **Rescan** after setting up a new machine or changing credentials. +### Let an agent inspect its checkout + +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. + ## Getting Started ### For GitHub (Recommended for most users) 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 06ef3147dcd1..cb7fd37dc35f 100644 --- a/packages/shared/src/t3McpToolPresentation.test.ts +++ b/packages/shared/src/t3McpToolPresentation.test.ts @@ -33,6 +33,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 77498835757c..4e08d57c5167 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -53,6 +53,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 7fc3937a86a480c7d55cbda8df2258feed50ffa7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 15:02:45 -0700 Subject: [PATCH 02/14] 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 | 26 +++++++ 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, 180 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 3098c5aeaf83..a05fa4932a3a 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -134,6 +134,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 f12e599f761f..fa78e7b0be7c 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 = ( @@ -521,9 +533,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), @@ -533,13 +545,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" @@ -558,7 +567,7 @@ const make = Effect.gen(function* () { worktreePath: projection.thread.worktreePath, }, actualWorkspace: { - workspacePath, + workspacePath: canonicalWorkspacePath, isRepo: actual.isRepo, branch: actual.refName, hasWorkingTreeChanges: actual.hasWorkingTreeChanges, @@ -575,17 +584,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()], @@ -598,11 +614,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, @@ -633,6 +647,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 fb3a7155c9c3..4b86dc993163 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1348,6 +1348,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)), + ); + }), + ); + it.effect("preserves newline characters in worktree paths when listing refs", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index eab927d3d95c..ab8de3a4e690 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"; @@ -139,6 +140,7 @@ interface GitRepositoryPaths { interface GitRefsSnapshot { readonly localBranches: ReadonlyArray; readonly remoteBranches: ReadonlyArray; + readonly worktrees: ReadonlyArray; readonly hasPrimaryRemote: boolean; } @@ -242,15 +244,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; @@ -270,7 +272,7 @@ function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { } flush(); - return worktreePaths; + return worktrees; } function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { @@ -1090,7 +1092,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) @@ -1098,6 +1100,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 @@ -2582,21 +2590,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 }> = []; @@ -2650,6 +2671,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; }); @@ -2768,6 +2790,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (repositoryPaths === null) { return { refs: [], + worktrees: [], isRepo: false, hasPrimaryRemote: false, nextCursor: null, @@ -2812,6 +2835,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 8e7741b3b718..d0d6adf5c6b6 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -310,10 +310,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 ffe9ede456cf..9c32118024ef 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -68,10 +68,11 @@ Run a quick **Rescan** after setting up a new machine or changing credentials. ### 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. ## Getting Started diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 915c3627c9b9..7c2a91de21f5 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -87,6 +87,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, @@ -263,6 +269,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 a10eb38ffcfe9d8650f602ddfd3e778adb36ecf0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 15:21:34 -0700 Subject: [PATCH 03/14] 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 a05fa4932a3a..9b4c90db159a 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; @@ -134,7 +137,6 @@ function nonRepositoryStatus(): VcsStatusResult { function nonRepositoryListRefs(): VcsListRefsResult { return { refs: [], - worktrees: [], isRepo: false, hasPrimaryRemote: false, nextCursor: null, @@ -307,6 +309,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 fa78e7b0be7c..15302038aae9 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 = ( @@ -535,26 +512,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, @@ -567,7 +546,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, @@ -580,63 +559,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 9e8b9615ac9d..6942f2fe4b55 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -232,6 +232,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, { @@ -275,6 +286,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 4b86dc993163..4790a159d816 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -516,8 +516,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)))), ); @@ -1358,19 +1362,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 ab8de3a4e690..912fda27b592 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"; @@ -140,7 +139,7 @@ interface GitRepositoryPaths { interface GitRefsSnapshot { readonly localBranches: ReadonlyArray; readonly remoteBranches: ReadonlyArray; - readonly worktrees: ReadonlyArray; + readonly worktrees: ReadonlyArray; readonly hasPrimaryRemote: boolean; } @@ -244,19 +243,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")) { @@ -266,8 +263,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", @@ -2559,16 +2612,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, @@ -2588,15 +2632,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), @@ -2604,17 +2641,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), ), ); @@ -2790,7 +2818,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (repositoryPaths === null) { return { refs: [], - worktrees: [], isRepo: false, hasPrimaryRemote: false, nextCursor: null, @@ -2835,7 +2862,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { refs: [...refs.refs], - worktrees: [...snapshot.worktrees], isRepo: true, hasPrimaryRemote: snapshot.hasPrimaryRemote, nextCursor: refs.nextCursor, @@ -3340,6 +3366,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 d0d6adf5c6b6..8fe6b7dddc1f 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -313,9 +313,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 9c32118024ef..b8e55e2f4a00 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -71,8 +71,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. ## Getting Started diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index 0a6264c62078..feb4cd377cec 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -611,7 +611,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 4ea86670ff8f..fce7c2a31662 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, GitRunStackedActionResult, GitRunStackedActionInput, @@ -16,6 +17,27 @@ const decodePreparePullRequestThreadInput = 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 7c2a91de21f5..a7b367e1a472 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -88,11 +88,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, @@ -269,7 +264,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 ebf4f71ea8be25a3a4632c69dfaa6935eb3420f0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 15:47:14 -0700 Subject: [PATCH 04/14] 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 15302038aae9..b34e83f748e7 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -512,28 +512,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, @@ -635,6 +648,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 fd3c63288ec4afd822fb8504be8e0aaa82d8b744 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 15:55:29 -0700 Subject: [PATCH 05/14] 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 b34e83f748e7..4da6505264aa 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -522,7 +522,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)), ], @@ -535,18 +535,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 66b1a061dc7a3e5fd766f000f570a07f6af4eac3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:04:28 -0700 Subject: [PATCH 06/14] 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 4da6505264aa..57f885935c2f 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* ( @@ -606,7 +620,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 e258c5420817e70d04e8d227272fdc8b6aeb3486 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:16:41 -0700 Subject: [PATCH 07/14] 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 57f885935c2f..67e27bd2a692 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* ( @@ -619,10 +605,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 4790a159d816..3a7a1f1fa27b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1387,6 +1387,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); + }), + ); + it.effect("preserves newline characters in worktree paths when listing refs", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); From 60b42a55f43510f2ae4046ce4e05c35351a1a334 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:31:52 -0700 Subject: [PATCH 08/14] 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 67e27bd2a692..34f2f7bf1ac6 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 bbbcfe3fcf6d4b80f598cfdf36f7ee608d3b658f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:36:26 -0700 Subject: [PATCH 09/14] 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 9c4f9f8f069d581068ba66893311580349e26ac4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:38:32 -0700 Subject: [PATCH 10/14] 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 aac9654f5ee2b4e3bd4188d20961d12c34799df9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 10:39:39 -0700 Subject: [PATCH 11/14] 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 34f2f7bf1ac6..3c5aaa40fbb1 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -622,9 +622,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) => { @@ -724,6 +726,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 8fe6b7dddc1f..b4b8067a3da2 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -318,9 +318,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 e671030b33f9f3f9f52fb11743a9a5542c41f415 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 10:59:12 -0700 Subject: [PATCH 12/14] 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 3c5aaa40fbb1..f24ce67f6080 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -622,29 +622,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, @@ -727,9 +746,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 b4b8067a3da2..9e39bd5579e4 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -318,10 +318,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 fb0f572f44ab742c31f040b0fe5d755ec5030a5d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:03:05 -0700 Subject: [PATCH 13/14] 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 f24ce67f6080..38d10b58902c 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -644,24 +644,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 9eaeae02a118a77cd3449be72c516b1d2921b0e7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:41:07 -0700 Subject: [PATCH 14/14] 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 912fda27b592..9b599d85e4d9 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -139,7 +139,6 @@ interface GitRepositoryPaths { interface GitRefsSnapshot { readonly localBranches: ReadonlyArray; readonly remoteBranches: ReadonlyArray; - readonly worktrees: ReadonlyArray; readonly hasPrimaryRemote: boolean; } @@ -2699,7 +2698,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; });