Skip to content
Source
Sign inSign up
Open History to see the last commit

WorktreeMcpService.ts

474 lines18.4 KB

1import {
2 CommandId,
3 type GitCommandError,
4 MessageId,
5 type ProjectId,
6 WorktreeMcpFailure,
7 type WorktreeMcpContinuationStatus,
8 type WorktreeMcpHandoffInput,
9 type WorktreeMcpHandoffResult,
10 type WorktreeMcpSetupScriptStatus,
11 type WorktreeMcpStatusResult,
12 type VcsListRefsInput,
13 type VcsListRefsResult,
14} from "@t3tools/contracts";
15import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
16import * as Cause from "effect/Cause";
17import * as Context from "effect/Context";
18import * as SecureRandom from "effect/crypto/SecureRandom";
19import * as Effect from "effect/Effect";
20import * as FileSystem from "effect/FileSystem";
21import * as Layer from "effect/Layer";
22import * as Option from "effect/Option";
23import type * as PlatformError from "effect/PlatformError";
24​
25import * as GitWorkflowService from "../git/GitWorkflowService.ts";
26import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
27import * as ProjectService from "../project/ProjectService.ts";
28import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
29import * as ServerSettings from "../serverSettings.ts";
30import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
31import type { McpInvocationScope, McpThreadInvocationScope } from "./McpInvocationContext.ts";
32​
33export class WorktreeMcpService extends Context.Service<
34 WorktreeMcpService,
35 {
36 readonly handoff: (
37 scope: McpInvocationScope,
38 input: WorktreeMcpHandoffInput,
39 ) => Effect.Effect<WorktreeMcpHandoffResult, WorktreeMcpFailure>;
40 readonly status: (
41 scope: McpInvocationScope,
42 ) => Effect.Effect<WorktreeMcpStatusResult, WorktreeMcpFailure>;
43 readonly listRefs: (
44 input: Omit<VcsListRefsInput, "cwd"> & {
45 readonly worktreePath: string | null;
46 readonly workspaceRoot: string;
47 },
48 ) => Effect.Effect<VcsListRefsResult, GitCommandError | PlatformError.PlatformError>;
49 }
50>()("t3/mcp/WorktreeMcpService") {}
51​
52function failure(code: WorktreeMcpFailure["code"], message: string): WorktreeMcpFailure {
53 return new WorktreeMcpFailure({ code, message });
55​
56function errorMessage(error: unknown): string {
57 if (error instanceof Error) return error.message;
58 if (typeof error === "object" && error !== null && "message" in error) {
59 return String((error as { message: unknown }).message);
60 }
61 return String(error);
63​
64const asOperationFailed = (prefix: string) =>
65 Effect.mapError((error: unknown) =>
66 failure("operation_failed", `${prefix}: ${errorMessage(error)}`),
67 );
68​
69const make = Effect.gen(function* () {
70 const random = yield* SecureRandom.SecureRandom;
71 const fileSystem = yield* FileSystem.FileSystem;
72 const threadManagement = yield* ThreadManagementService.ThreadManagementService;
73 const projects = yield* ProjectService.ProjectService;
74 const serverSettings = yield* ServerSettings.ServerSettingsService;
75 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;
76 const setupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
77 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
78​
79 // Serialize handoffs per thread to avoid creating two worktrees and orphaning one.
80 const handoffThreadsInFlight = new Set<string>();
81​
82 const requireThreadScope = (scope: McpInvocationScope) =>
83 scope.thread === undefined
84 ? Effect.fail(
85 failure(
86 "thread_credential_required",
87 "Worktree handoff and status act as the calling T3 thread, so they need an agent running inside T3 Code.",
88 ),
89 )
90 : Effect.succeed(scope as McpThreadInvocationScope);
91​
92 const requireCapability = (scope: McpInvocationScope) =>
93 scope.capabilities.has("worktree")
94 ? Effect.void
95 : Effect.fail(
96 failure("capability_denied", "This MCP credential does not grant worktree capabilities."),
97 );
98​
99 const loadThread = (scope: McpThreadInvocationScope) =>
100 threadManagement.getThreadRecords(scope.thread.threadId, []).pipe(
101 Effect.mapError((error) =>
102 error._tag === "OrchestratorProjectionError"
103 ? failure("thread_not_found", `Thread '${scope.thread.threadId}' was not found.`)
104 : failure(
105 "operation_failed",
106 `Unable to read thread ${scope.thread.threadId}: ${errorMessage(error)}`,
107 ),
108 ),
109 Effect.filterOrFail(
110 (projection) => projection.thread.deletedAt === null,
111 () => failure("thread_not_found", `Thread '${scope.thread.threadId}' was not found.`),
112 ),
113 );
114​
115 const loadProject = (scope: McpThreadInvocationScope, projectId: ProjectId) =>
116 projects.getById(projectId).pipe(
117 asOperationFailed(`Unable to read project ${projectId}`),
118 Effect.flatMap(
119 Option.match({
120 onNone: () =>
121 Effect.fail(
122 failure(
123 "project_not_found",
124 `Project '${projectId}' was not found for thread '${scope.thread.threadId}'.`,
125 ),
126 ),
127 onSome: Effect.succeed,
128 }),
129 ),
130 );
131​
132 const readDefaultStartFromOrigin = (projectId: ProjectId) =>
133 serverSettings.getSettings.pipe(
134 Effect.map(
135 (settings) =>
136 resolveProjectSettings(settings, projectId).settings.newWorktreesStartFromOrigin,
137 ),
138 asOperationFailed("Unable to read server settings"),
139 );
140​
141 const handoffIds = (scope: McpThreadInvocationScope) =>
142 random.uuidV4().pipe(
143 Effect.map((uuid) => {
144 const part = (kind: string, operation: string) =>
145 [kind, "mcp", encodeURIComponent(scope.thread.providerSessionId), operation, uuid].join(
146 ":",
147 );
148 return {
149 commandId: CommandId.make(part("command", "worktree-handoff")),
150 continuationCommandId: CommandId.make(part("command", "worktree-continuation")),
151 continuationMessageId: MessageId.make(part("message", "worktree-continuation")),
152 };
153 }),
154 Effect.orDie,
155 );
156​
157 const performHandoff = Effect.fn("WorktreeMcpService.performHandoff")(function* (
158 scope: McpThreadInvocationScope,
159 input: WorktreeMcpHandoffInput,
160 ) {
161 const alreadyInWorktree = (worktreePath: string) =>
162 failure(
163 "already_in_worktree",
164 `Thread '${scope.thread.threadId}' is already attached to worktree '${worktreePath}'.`,
165 );
166​
167 const projection = yield* loadThread(scope);
168 if (projection.thread.worktreePath !== null) {
169 return yield* alreadyInWorktree(projection.thread.worktreePath);
170 }
171 if (projection.thread.archivedAt !== null) {
172 return yield* failure(
173 "invalid_request",
174 `Thread '${scope.thread.threadId}' is archived and cannot be handed off to a worktree.`,
175 );
176 }
177​
178 const project = yield* loadProject(scope, projection.thread.projectId);
179 const projectCwd = project.workspaceRoot;
180​
181 const localStatus = yield* gitWorkflow
182 .localStatus({ cwd: projectCwd })
183 .pipe(asOperationFailed("Unable to read git status"));
184 if (!localStatus.isRepo) {
185 return yield* failure(
186 "invalid_request",
187 `Project workspace '${projectCwd}' is not a git repository.`,
188 );
189 }
190​
191 const localBranchNames = yield* gitWorkflow
192 .listLocalBranchNames(projectCwd)
193 .pipe(asOperationFailed("Unable to list branches"));
194 if (localBranchNames.includes(input.branch)) {
195 const existingRef = yield* gitWorkflow
196 .listRefs({ cwd: projectCwd, query: input.branch, refKind: "local" })
197 .pipe(
198 Effect.map((result) =>
199 result.refs.find((ref) => ref.name === input.branch && ref.isRemote !== true),
200 ),
201 Effect.orElseSucceed(() => undefined),
202 );
203 const checkoutPath = existingRef?.worktreePath ?? null;
204 return yield* failure(
205 "invalid_request",
206 `Branch '${input.branch}' already exists${
207 checkoutPath === null ? "" : ` and is checked out at '${checkoutPath}'`
208 }. Choose a different branch name, or delete the existing branch${
209 checkoutPath === null ? "" : " and its worktree"
210 } first.`,
211 );
212 }
213​
214 let baseRef = input.baseRef;
215 if (baseRef === undefined) {
216 if (localStatus.refName === null) {
217 return yield* failure(
218 "invalid_request",
219 "Could not determine the current branch of the project workspace (detached HEAD?). Pass baseRef explicitly.",
220 );
221 }
222 baseRef = localStatus.refName;
223 }
224​
225 const startFromOrigin =
226 input.startFromOrigin ?? (yield* readDefaultStartFromOrigin(project.id));
227​
228 let worktreeBaseRef = baseRef;
229 let baseRefName = baseRef;
230 let startedFromOrigin = false;
231 if (startFromOrigin) {
232 const resolvedRemoteBase = yield* gitWorkflow
233 .fetchWorktreeBaseCommit({ cwd: projectCwd, refName: baseRef })
234 .pipe(asOperationFailed(`Unable to fetch the remote base of '${baseRef}'`));
235 if (resolvedRemoteBase !== null) {
236 worktreeBaseRef = resolvedRemoteBase.commitSha;
237 baseRefName = `refs/remotes/${resolvedRemoteBase.remoteRefName}`;
238 startedFromOrigin = true;
239 }
240 }
241​
242 const ids = yield* handoffIds(scope);
243​
244 // Mask interruption after worktree creation through setup; binding can detach and interrupt the caller.
245 return yield* Effect.uninterruptibleMask((restore) =>
246 Effect.gen(function* () {
247 const worktree = yield* restore(
248 gitWorkflow
249 .createWorktree({
250 cwd: projectCwd,
251 refName: worktreeBaseRef,
252 newRefName: input.branch,
253 baseRefName,
254 path: null,
255 })
256 .pipe(asOperationFailed("Unable to create the worktree")),
257 );
258 const worktreePath = worktree.worktree.path;
259​
260 const reportFailed = (logMessage: string) =>
261 Effect.catchCause((cause: Cause.Cause<unknown>) => {
262 const detail = errorMessage(Cause.squash(cause));
263 return Effect.logWarning(logMessage, {
264 threadId: scope.thread.threadId,
265 worktreePath,
266 detail,
267 }).pipe(Effect.as({ status: "failed", detail } as const));
268 });
269​
270 const removeCreatedWorktree = Effect.suspend(() =>
271 gitWorkflow.removeWorktree({ cwd: projectCwd, path: worktreePath, force: true }).pipe(
272 Effect.andThen(
273 Effect.suspend(() =>
274 gitWorkflow.deleteLocalBranch({
275 cwd: projectCwd,
276 refName: worktree.worktree.refName,
277 force: true,
278 }),
279 ),
280 ),
281 ),
282 ).pipe(Effect.ignoreCause({ log: true }));
283​
284 const recheckAndBind = Effect.gen(function* () {
285 // A concurrent UI binding may have landed during git work; do not orphan a second worktree.
286 const recheck = yield* loadThread(scope);
287 if (recheck.thread.worktreePath !== null) {
288 return yield* alreadyInWorktree(recheck.thread.worktreePath);
289 }
290 if (recheck.thread.archivedAt !== null) {
291 return yield* failure(
292 "invalid_request",
293 `Thread '${scope.thread.threadId}' was archived while the worktree was being created; the handoff was rolled back.`,
294 );
295 }
296 yield* threadManagement
297 .dispatch({
298 type: "thread.metadata.update",
299 commandId: ids.commandId,
300 threadId: scope.thread.threadId,
301 branch: worktree.worktree.refName,
302 worktreePath,
303 expectedWorktreePath: null,
304 })
305 .pipe(
306 Effect.catchCause((cause) =>
307 Cause.hasInterruptsOnly(cause)
308 ? Effect.failCause(cause as Cause.Cause<never>)
309 : Effect.fail(
310 failure(
311 "operation_failed",
312 `Unable to re-point the thread at the worktree: ${errorMessage(Cause.squash(cause))}`,
313 ),
314 ),
315 ),
316 );
317 }).pipe(
318 // Do not remove on interruption alone; the thread binding may already have committed.
319 Effect.onError((cause) =>
320 Cause.hasInterruptsOnly(cause) ? Effect.void : removeCreatedWorktree,
321 ),
322 );
323​
324 // Queue continuation immediately after binding; detachment terminates the caller, so relaunch needs a durable message.
325 const queueContinuation: Effect.Effect<WorktreeMcpContinuationStatus, WorktreeMcpFailure> =
326 Effect.suspend(() =>
327 input.continuationPrompt === undefined
328 ? Effect.succeed<WorktreeMcpContinuationStatus>({ status: "skipped" })
329 : threadManagement
330 .sendToThread({
331 projectId: projection.thread.projectId,
332 commandId: ids.continuationCommandId,
333 threadId: scope.thread.threadId,
334 messageId: ids.continuationMessageId,
335 text: input.continuationPrompt,
336 attachments: [],
337 mode: "queue",
338 createdBy: "agent",
339 creationSource: "mcp",
340 })
341 .pipe(
342 Effect.map((sendResult): WorktreeMcpContinuationStatus => ({
343 status: "scheduled",
344 delivery: sendResult.delivery,
345 })),
346 reportFailed("worktree handoff continuation failed to queue"),
347 ),
348 );
349​
350 const continuation = yield* recheckAndBind.pipe(Effect.andThen(queueContinuation));
351​
352 yield* vcsStatusBroadcaster
353 .refreshStatus(worktreePath)
354 .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach);
355​
356 let setupScript: WorktreeMcpSetupScriptStatus = { status: "skipped" };
357 if (input.runSetupScript ?? true) {
358 setupScript = yield* setupScriptRunner
359 .runForThread({
360 threadId: scope.thread.threadId,
361 projectId: projection.thread.projectId,
362 projectCwd,
363 worktreePath,
364 project: {
365 id: project.id,
366 workspaceRoot: project.workspaceRoot,
367 scripts: project.scripts,
368 },
369 })
370 .pipe(
371 Effect.map((result): WorktreeMcpSetupScriptStatus =>
372 result.status === "started"
373 ? {
374 status: "started",
375 scriptName: result.scriptName,
376 terminalId: result.terminalId,
377 }
378 : result.status === "skipped-for-link"
379 ? { status: "skipped" }
380 : { status: "no-script" },
381 ),
382 reportFailed("worktree handoff setup script failed"),
383 );
384 }
385​
386 const result: WorktreeMcpHandoffResult = {
387 worktreePath,
388 branch: worktree.worktree.refName,
389 baseRef,
390 startedFromOrigin,
391 setupScript,
392 continuation,
393 note:
394 continuation.status === "scheduled"
395 ? "Handoff recorded. Changing the workspace detaches this provider session, so the current turn ends shortly after this call; the queued continuation prompt then starts the next turn inside the worktree with the conversation preserved. The worktree is not removed automatically when the thread is deleted."
396 : "Handoff recorded. Changing the workspace detaches this provider session, so the current turn ends shortly after this call; the conversation continues inside the worktree when the thread receives its next message. Pass continuationPrompt to resume automatically. The worktree is not removed automatically when the thread is deleted.",
397 };
398 return result;
399 }),
400 );
401 });
402​
403 const handoff: WorktreeMcpService["Service"]["handoff"] = Effect.fn("WorktreeMcpService.handoff")(
404 function* (callerScope, input) {
405 yield* requireCapability(callerScope);
406 const scope = yield* requireThreadScope(callerScope);
407 // Acquire and register cleanup atomically so cancellation cannot strand the guard until restart.
408 return yield* Effect.uninterruptibleMask((restore) =>
409 Effect.suspend(() => {
410 if (handoffThreadsInFlight.has(scope.thread.threadId)) {
411 return Effect.fail(
412 failure(
413 "handoff_in_progress",
414 `A worktree handoff is already in progress for thread '${scope.thread.threadId}'.`,
415 ),
416 );
417 }
418 handoffThreadsInFlight.add(scope.thread.threadId);
419 return restore(performHandoff(scope, input)).pipe(
420 Effect.ensuring(
421 Effect.sync(() => handoffThreadsInFlight.delete(scope.thread.threadId)),
422 ),
423 );
424 }),
425 );
426 },
427 );
428​
429 const status: WorktreeMcpService["Service"]["status"] = Effect.fn("WorktreeMcpService.status")(
430 function* (callerScope) {
431 yield* requireCapability(callerScope);
432 const scope = yield* requireThreadScope(callerScope);
433 const projection = yield* loadThread(scope);
434 const project = yield* loadProject(scope, projection.thread.projectId);
435​
436 const defaultStartFromOrigin = yield* readDefaultStartFromOrigin(project.id);
437​
438 const result: WorktreeMcpStatusResult = {
439 attached: projection.thread.worktreePath !== null,
440 worktreePath: projection.thread.worktreePath,
441 branch: projection.thread.branch,
442 projectWorkspaceRoot: project.workspaceRoot,
443 defaultStartFromOrigin,
444 };
445 return result;
446 },
447 );
448​
449 const listRefs: WorktreeMcpService["Service"]["listRefs"] = Effect.fn(
450 "WorktreeMcpService.listRefs",
451 )(function* ({ worktreePath, workspaceRoot, ...refs }) {
452 // Storage cleanup removes a worktree but keeps its path on the thread.
453 const cwd =
454 worktreePath !== null && (yield* fileSystem.exists(worktreePath))
455 ? worktreePath
456 : workspaceRoot;
457 return yield* gitWorkflow.listRefs({ ...refs, cwd });
458 });
459​
460 return WorktreeMcpService.of({ handoff, status, listRefs });
461});
462​
463export const layer: Layer.Layer<
464 WorktreeMcpService,
465 never,
466 | SecureRandom.SecureRandom
467 | FileSystem.FileSystem
468 | ThreadManagementService.ThreadManagementService
469 | ProjectService.ProjectService
470 | ServerSettings.ServerSettingsService
471 | GitWorkflowService.GitWorkflowService
472 | ProjectSetupScriptRunner.ProjectSetupScriptRunner
473 | VcsStatusBroadcaster.VcsStatusBroadcaster
474> = Layer.effect(WorktreeMcpService, make);