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

WorktreeMcpService.ts

467 lines17.9 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 continuationMessageId: MessageId.make(part("message", "worktree-continuation")),
151 };
152 }),
153 Effect.orDie,
154 );
155​
156 const performHandoff = Effect.fn("WorktreeMcpService.performHandoff")(function* (
157 scope: McpThreadInvocationScope,
158 input: WorktreeMcpHandoffInput,
159 ) {
160 const alreadyInWorktree = (worktreePath: string) =>
161 failure(
162 "already_in_worktree",
163 `Thread '${scope.thread.threadId}' is already attached to worktree '${worktreePath}'.`,
164 );
165​
166 const projection = yield* loadThread(scope);
167 if (projection.thread.worktreePath !== null) {
168 return yield* alreadyInWorktree(projection.thread.worktreePath);
169 }
170 if (projection.thread.archivedAt !== null) {
171 return yield* failure(
172 "invalid_request",
173 `Thread '${scope.thread.threadId}' is archived and cannot be handed off to a worktree.`,
174 );
175 }
176​
177 const project = yield* loadProject(scope, projection.thread.projectId);
178 const projectCwd = project.workspaceRoot;
179​
180 const localStatus = yield* gitWorkflow
181 .localStatus({ cwd: projectCwd })
182 .pipe(asOperationFailed("Unable to read git status"));
183 if (!localStatus.isRepo) {
184 return yield* failure(
185 "invalid_request",
186 `Project workspace '${projectCwd}' is not a git repository.`,
187 );
188 }
189​
190 const localBranchNames = yield* gitWorkflow
191 .listLocalBranchNames(projectCwd)
192 .pipe(asOperationFailed("Unable to list branches"));
193 if (localBranchNames.includes(input.branch)) {
194 const existingRef = yield* gitWorkflow
195 .listRefs({ cwd: projectCwd, query: input.branch, refKind: "local" })
196 .pipe(
197 Effect.map((result) =>
198 result.refs.find((ref) => ref.name === input.branch && ref.isRemote !== true),
199 ),
200 Effect.orElseSucceed(() => undefined),
201 );
202 const checkoutPath = existingRef?.worktreePath ?? null;
203 return yield* failure(
204 "invalid_request",
205 `Branch '${input.branch}' already exists${
206 checkoutPath === null ? "" : ` and is checked out at '${checkoutPath}'`
207 }. Choose a different branch name, or delete the existing branch${
208 checkoutPath === null ? "" : " and its worktree"
209 } first.`,
210 );
211 }
212​
213 let baseRef = input.baseRef;
214 if (baseRef === undefined) {
215 if (localStatus.refName === null) {
216 return yield* failure(
217 "invalid_request",
218 "Could not determine the current branch of the project workspace (detached HEAD?). Pass baseRef explicitly.",
219 );
220 }
221 baseRef = localStatus.refName;
222 }
223​
224 const startFromOrigin =
225 input.startFromOrigin ?? (yield* readDefaultStartFromOrigin(project.id));
226​
227 let worktreeBaseRef = baseRef;
228 let baseRefName = baseRef;
229 let startedFromOrigin = false;
230 if (startFromOrigin) {
231 const resolvedRemoteBase = yield* gitWorkflow
232 .fetchWorktreeBaseCommit({ cwd: projectCwd, refName: baseRef })
233 .pipe(asOperationFailed(`Unable to fetch the remote base of '${baseRef}'`));
234 if (resolvedRemoteBase !== null) {
235 worktreeBaseRef = resolvedRemoteBase.commitSha;
236 baseRefName = `refs/remotes/${resolvedRemoteBase.remoteRefName}`;
237 startedFromOrigin = true;
238 }
239 }
240​
241 const ids = yield* handoffIds(scope);
242​
243 // Mask interruption after worktree creation through setup; binding can detach and interrupt the caller.
244 return yield* Effect.uninterruptibleMask((restore) =>
245 Effect.gen(function* () {
246 const worktree = yield* restore(
247 gitWorkflow
248 .createWorktree({
249 cwd: projectCwd,
250 refName: worktreeBaseRef,
251 newRefName: input.branch,
252 baseRefName,
253 path: null,
254 })
255 .pipe(asOperationFailed("Unable to create the worktree")),
256 );
257 const worktreePath = worktree.worktree.path;
258​
259 const reportFailed = (logMessage: string) =>
260 Effect.catchCause((cause: Cause.Cause<unknown>) => {
261 const detail = errorMessage(Cause.squash(cause));
262 return Effect.logWarning(logMessage, {
263 threadId: scope.thread.threadId,
264 worktreePath,
265 detail,
266 }).pipe(Effect.as({ status: "failed", detail } as const));
267 });
268​
269 const removeCreatedWorktree = Effect.suspend(() =>
270 gitWorkflow.removeWorktree({ cwd: projectCwd, path: worktreePath, force: true }).pipe(
271 Effect.andThen(
272 Effect.suspend(() =>
273 gitWorkflow.deleteLocalBranch({
274 cwd: projectCwd,
275 refName: worktree.worktree.refName,
276 force: true,
277 }),
278 ),
279 ),
280 ),
281 ).pipe(Effect.ignoreCause({ log: true }));
282​
283 const recheckAndBind = Effect.gen(function* () {
284 // A concurrent UI binding may have landed during git work; do not orphan a second worktree.
285 const recheck = yield* loadThread(scope);
286 if (recheck.thread.worktreePath !== null) {
287 return yield* alreadyInWorktree(recheck.thread.worktreePath);
288 }
289 if (recheck.thread.archivedAt !== null) {
290 return yield* failure(
291 "invalid_request",
292 `Thread '${scope.thread.threadId}' was archived while the worktree was being created; the handoff was rolled back.`,
293 );
294 }
295 return yield* threadManagement
296 .dispatch({
297 type: "thread.metadata.update",
298 commandId: ids.commandId,
299 threadId: scope.thread.threadId,
300 branch: worktree.worktree.refName,
301 worktreePath,
302 expectedWorktreePath: null,
303 // Binding detaches the session and ends the caller; a later send could lose the race.
304 ...(input.continuationPrompt === undefined
305 ? {}
306 : {
307 continuation: {
308 messageId: ids.continuationMessageId,
309 text: input.continuationPrompt,
310 },
311 }),
312 })
313 .pipe(
314 Effect.catchCause((cause) =>
315 Cause.hasInterruptsOnly(cause)
316 ? Effect.failCause(cause as Cause.Cause<never>)
317 : Effect.fail(
318 failure(
319 "operation_failed",
320 `Unable to re-point the thread at the worktree: ${errorMessage(Cause.squash(cause))}`,
321 ),
322 ),
323 ),
324 );
325 }).pipe(
326 // Do not remove on interruption alone; the thread binding may already have committed.
327 Effect.onError((cause) =>
328 Cause.hasInterruptsOnly(cause) ? Effect.void : removeCreatedWorktree,
329 ),
330 );
331​
332 const bound = yield* recheckAndBind;
333 const continuation: WorktreeMcpContinuationStatus =
334 input.continuationPrompt === undefined
335 ? { status: "skipped" }
336 : {
337 status: "scheduled",
338 delivery: bound.storedEvents.some(
339 ({ event }) => event.type === "run.created" && event.payload.status !== "queued",
340 )
341 ? "started"
342 : "queued",
343 };
344​
345 yield* vcsStatusBroadcaster
346 .refreshStatus(worktreePath)
347 .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach);
348​
349 let setupScript: WorktreeMcpSetupScriptStatus = { status: "skipped" };
350 if (input.runSetupScript ?? true) {
351 setupScript = yield* setupScriptRunner
352 .runForThread({
353 threadId: scope.thread.threadId,
354 projectId: projection.thread.projectId,
355 projectCwd,
356 worktreePath,
357 project: {
358 id: project.id,
359 workspaceRoot: project.workspaceRoot,
360 scripts: project.scripts,
361 },
362 })
363 .pipe(
364 Effect.map((result): WorktreeMcpSetupScriptStatus =>
365 result.status === "started"
366 ? {
367 status: "started",
368 scriptName: result.scriptName,
369 terminalId: result.terminalId,
370 }
371 : result.status === "skipped-for-link"
372 ? { status: "skipped" }
373 : { status: "no-script" },
374 ),
375 reportFailed("worktree handoff setup script failed"),
376 );
377 }
378​
379 const result: WorktreeMcpHandoffResult = {
380 worktreePath,
381 branch: worktree.worktree.refName,
382 baseRef,
383 startedFromOrigin,
384 setupScript,
385 continuation,
386 note:
387 continuation.status === "scheduled"
388 ? "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."
389 : "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.",
390 };
391 return result;
392 }),
393 );
394 });
395​
396 const handoff: WorktreeMcpService["Service"]["handoff"] = Effect.fn("WorktreeMcpService.handoff")(
397 function* (callerScope, input) {
398 yield* requireCapability(callerScope);
399 const scope = yield* requireThreadScope(callerScope);
400 // Acquire and register cleanup atomically so cancellation cannot strand the guard until restart.
401 return yield* Effect.uninterruptibleMask((restore) =>
402 Effect.suspend(() => {
403 if (handoffThreadsInFlight.has(scope.thread.threadId)) {
404 return Effect.fail(
405 failure(
406 "handoff_in_progress",
407 `A worktree handoff is already in progress for thread '${scope.thread.threadId}'.`,
408 ),
409 );
410 }
411 handoffThreadsInFlight.add(scope.thread.threadId);
412 return restore(performHandoff(scope, input)).pipe(
413 Effect.ensuring(
414 Effect.sync(() => handoffThreadsInFlight.delete(scope.thread.threadId)),
415 ),
416 );
417 }),
418 );
419 },
420 );
421​
422 const status: WorktreeMcpService["Service"]["status"] = Effect.fn("WorktreeMcpService.status")(
423 function* (callerScope) {
424 yield* requireCapability(callerScope);
425 const scope = yield* requireThreadScope(callerScope);
426 const projection = yield* loadThread(scope);
427 const project = yield* loadProject(scope, projection.thread.projectId);
428​
429 const defaultStartFromOrigin = yield* readDefaultStartFromOrigin(project.id);
430​
431 const result: WorktreeMcpStatusResult = {
432 attached: projection.thread.worktreePath !== null,
433 worktreePath: projection.thread.worktreePath,
434 branch: projection.thread.branch,
435 projectWorkspaceRoot: project.workspaceRoot,
436 defaultStartFromOrigin,
437 };
438 return result;
439 },
440 );
441​
442 const listRefs: WorktreeMcpService["Service"]["listRefs"] = Effect.fn(
443 "WorktreeMcpService.listRefs",
444 )(function* ({ worktreePath, workspaceRoot, ...refs }) {
445 // Storage cleanup removes a worktree but keeps its path on the thread.
446 const cwd =
447 worktreePath !== null && (yield* fileSystem.exists(worktreePath))
448 ? worktreePath
449 : workspaceRoot;
450 return yield* gitWorkflow.listRefs({ ...refs, cwd });
451 });
452​
453 return WorktreeMcpService.of({ handoff, status, listRefs });
454});
455​
456export const layer: Layer.Layer<
457 WorktreeMcpService,
458 never,
459 | SecureRandom.SecureRandom
460 | FileSystem.FileSystem
461 | ThreadManagementService.ThreadManagementService
462 | ProjectService.ProjectService
463 | ServerSettings.ServerSettingsService
464 | GitWorkflowService.GitWorkflowService
465 | ProjectSetupScriptRunner.ProjectSetupScriptRunner
466 | VcsStatusBroadcaster.VcsStatusBroadcaster
467> = Layer.effect(WorktreeMcpService, make);