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

WorktreeMcpService.test.ts

733 lines27.7 KB

1import { describe, expect, it, vi } from "@effect/vitest";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import {
5 CommandId,
6 EnvironmentId,
7 type OrchestrationV2ThreadProjection,
8 type Project,
9 ProjectId,
10 ProviderInstanceId,
11 ThreadId,
12 WorktreeMcpHandoffInput,
13} from "@t3tools/contracts";
14import * as Cause from "effect/Cause";
15import * as Deferred from "effect/Deferred";
16import * as Effect from "effect/Effect";
17import * as Exit from "effect/Exit";
18import * as Fiber from "effect/Fiber";
19import * as FileSystem from "effect/FileSystem";
20import * as Layer from "effect/Layer";
21import * as Option from "effect/Option";
22import * as PlatformError from "effect/PlatformError";
23import * as Schema from "effect/Schema";
24​
25import * as GitWorkflowService from "../git/GitWorkflowService.ts";
26import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
27import * as ServerConfig from "../config.ts";
28import {
29 OrchestratorDispatchError,
30 OrchestratorProjectionError,
31} from "../orchestration-v2/Orchestrator.ts";
32import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
33import * as ProjectService from "../project/ProjectService.ts";
34import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
35import * as ServerSettings from "../serverSettings.ts";
36import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
37import type * as McpInvocationContext from "./McpInvocationContext.ts";
38import * as WorktreeMcpService from "./WorktreeMcpService.ts";
39​
40const environmentId = EnvironmentId.make("environment-worktree-test");
41const threadId = ThreadId.make("thread-worktree-test");
42const projectId = ProjectId.make("project-worktree-test");
43const workspaceRoot = "/repo/project";
44​
45const makeScope = (
46 capabilities: ReadonlySet<McpInvocationContext.McpCapability>,
47): McpInvocationContext.McpInvocationScope => ({
48 environmentId,
49 requestNamespace: "provider-session-worktree-test",
50 thread: {
51 threadId,
52 providerSessionId: "provider-session-worktree-test",
53 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
54 },
55 client: undefined,
56 capabilities,
57 issuedAt: 1,
58});
59​
60interface ThreadFixture {
61 readonly branch?: string | null;
62 readonly worktreePath?: string | null;
63 readonly archivedAt?: string | null;
64 readonly deletedAt?: string | null;
66​
67const makeProjection = (overrides: ThreadFixture = {}): OrchestrationV2ThreadProjection =>
68 ({
69 thread: {
70 id: threadId,
71 projectId,
72 title: "Worktree test thread",
73 branch: null,
74 worktreePath: null,
75 archivedAt: null,
76 deletedAt: null,
77 ...overrides,
78 },
79 }) as OrchestrationV2ThreadProjection;
80​
81const project: Project = {
82 id: projectId,
83 title: "Worktree test project",
84 workspaceRoot,
85 defaultModelSelection: null,
86 scripts: [],
87 createdAt: "2026-01-01T00:00:00.000Z",
88 updatedAt: "2026-01-01T00:00:00.000Z",
89 deletedAt: null,
90} as Project;
91​
92interface HarnessOptions {
93 readonly git?: Partial<GitWorkflowService.GitWorkflowService["Service"]>;
94 readonly thread?: ThreadFixture | null;
95 readonly threadReadError?: "projection" | "dispatch";
96 readonly capabilities?: ReadonlySet<McpInvocationContext.McpCapability>;
97 readonly currentBranch?: string | null;
98 readonly notARepo?: boolean;
99 readonly newWorktreesStartFromOrigin?: boolean;
100 readonly projectNewWorktreesStartFromOrigin?: boolean;
101 readonly setupScript?: "started" | "no-script" | "fails" | "dies";
102 readonly dispatchFails?: boolean;
103 readonly dispatchDies?: boolean;
104 readonly dispatchInterrupts?: boolean;
105 readonly dispatchGate?: Effect.Effect<void>;
106 readonly threadAttachedOnRecheck?: boolean;
107 readonly threadArchivedOnRecheck?: boolean;
108 readonly threadReadFailsOnRecheck?: boolean;
109 readonly projectMissing?: boolean;
110 readonly projectReadFails?: boolean;
111 readonly existingBranchWorktreePath?: string | null;
112 readonly createWorktreeFails?: boolean;
113 readonly fetchWorktreeBaseFails?: boolean;
114 readonly worktreeBaseMissing?: boolean;
115 readonly removeWorktreeFails?: boolean;
116 readonly deleteLocalBranchFails?: boolean;
117 readonly createWorktreeGate?: Effect.Effect<void>;
118 readonly existsFails?: boolean;
120​
121const makeHarness = (options: HarnessOptions = {}) => {
122 const thread = options.thread === undefined ? {} : options.thread;
123 const scope = makeScope(options.capabilities ?? new Set(["preview", "worktree"]));
124 const dispatch = vi.fn((_: unknown) =>
125 (options.dispatchGate ?? Effect.void).pipe(
126 Effect.andThen(
127 options.dispatchInterrupts
128 ? (Effect.failCause(Cause.interrupt()) as never)
129 : options.dispatchDies
130 ? Effect.die(new Error("dispatch defect"))
131 : options.dispatchFails
132 ? (Effect.fail("simulated dispatch failure") as never)
133 : Effect.succeed({ sequence: 1, storedEvents: [] }),
134 ),
135 ),
136 );
137 const listLocalBranchNames = vi.fn((_: string) =>
138 Effect.succeed(
139 options.existingBranchWorktreePath === undefined
140 ? ["dev"]
141 : ["dev", "feature/taken", "feature/taken-idle"],
142 ),
143 );
144 const getThreadRecords = vi.fn((id: ThreadId) => {
145 if (options.threadReadError === "dispatch") {
146 return Effect.fail(
147 new OrchestratorDispatchError({
148 commandId: CommandId.make("command:test:read"),
149 commandType: "thread.metadata.update",
150 }),
151 ) as never;
152 }
153 if (options.threadReadFailsOnRecheck === true && getThreadRecords.mock.calls.length > 1) {
154 return Effect.fail(
155 new OrchestratorDispatchError({
156 commandId: CommandId.make("command:test:recheck"),
157 commandType: "thread.metadata.update",
158 }),
159 ) as never;
160 }
161 if (
162 options.threadAttachedOnRecheck === true &&
163 getThreadRecords.mock.calls.length > 1 &&
164 thread !== null
165 ) {
166 return Effect.succeed(
167 makeProjection({ ...thread, worktreePath: "/worktrees/project/raced" }),
168 );
169 }
170 if (
171 options.threadArchivedOnRecheck === true &&
172 getThreadRecords.mock.calls.length > 1 &&
173 thread !== null
174 ) {
175 return Effect.succeed(makeProjection({ ...thread, archivedAt: "2026-01-02T00:00:00.000Z" }));
176 }
177 return id === threadId && thread !== null
178 ? Effect.succeed(makeProjection(thread))
179 : Effect.fail(new OrchestratorProjectionError({ threadId: id }));
180 });
181 const getById = vi.fn((id: ProjectId) =>
182 options.projectReadFails
183 ? (Effect.fail("simulated project read failure") as never)
184 : Effect.succeed(
185 id === projectId && options.projectMissing !== true
186 ? Option.some(project)
187 : Option.none(),
188 ),
189 );
190 const removeWorktree = vi.fn((_: unknown) =>
191 options.removeWorktreeFails
192 ? (Effect.fail("simulated worktree removal failure") as never)
193 : Effect.void,
194 );
195 const deleteLocalBranch = vi.fn((_: unknown) =>
196 options.deleteLocalBranchFails
197 ? (Effect.fail("simulated local branch deletion failure") as never)
198 : Effect.void,
199 );
200 const fetchWorktreeBaseCommit = vi.fn((_: unknown) =>
201 options.fetchWorktreeBaseFails
202 ? (Effect.fail("simulated fetch failure") as never)
203 : options.worktreeBaseMissing
204 ? Effect.succeed(null)
205 : Effect.succeed({ commitSha: "abc123", remoteRefName: "origin/dev" }),
206 );
207 const createWorktree = vi.fn(
208 (input: { readonly newRefName?: string | undefined; readonly path: string | null }) =>
209 options.createWorktreeFails
210 ? (Effect.fail("simulated worktree creation failure") as never)
211 : (options.createWorktreeGate ?? Effect.void).pipe(
212 Effect.andThen(
213 Effect.succeed({
214 worktree: {
215 path: input.path ?? `/worktrees/project/${input.newRefName}`,
216 refName: input.newRefName ?? "detached",
217 },
218 }),
219 ),
220 ),
221 );
222 const listRefs = vi.fn((input: { readonly cwd: string; readonly query?: string | undefined }) =>
223 Effect.succeed({
224 refs:
225 options.existingBranchWorktreePath === undefined
226 ? []
227 : [
228 {
229 name: input.query ?? "",
230 current: false,
231 isDefault: false,
232 worktreePath: options.existingBranchWorktreePath,
233 },
234 ],
235 isRepo: true,
236 hasPrimaryRemote: true,
237 nextCursor: null,
238 totalCount: options.existingBranchWorktreePath === undefined ? 0 : 1,
239 }),
240 );
241 const localStatus = vi.fn((_: unknown) =>
242 Effect.succeed({
243 isRepo: options.notARepo !== true,
244 hasPrimaryRemote: true,
245 isDefaultRef: false,
246 refName: options.currentBranch === undefined ? "dev" : options.currentBranch,
247 hasWorkingTreeChanges: false,
248 workingTree: { files: [], insertions: 0, deletions: 0 },
249 }),
250 );
251 const refreshStatus = vi.fn((_: string) => Effect.die("refreshStatus stub"));
252 const runForThread = vi.fn((input: { readonly worktreePath: string }) => {
253 switch (options.setupScript ?? "started") {
254 case "no-script":
255 return Effect.succeed({ status: "no-script" } as const);
256 case "dies":
257 return Effect.die(new Error("setup runner defect"));
258 case "fails":
259 return Effect.fail(
260 new ProjectSetupScriptRunner.ProjectSetupScriptProjectNotFoundError({
261 threadId,
262 worktreePath: input.worktreePath,
263 }),
264 );
265 default:
266 return Effect.succeed({
267 status: "started",
268 async: false,
269 scriptId: "setup",
270 scriptName: "Setup",
271 scriptCommand: "vp install",
272 terminalId: "setup-terminal",
273 cwd: input.worktreePath,
274 } as const);
275 }
276 });
277​
278 const layer = WorktreeMcpService.layer.pipe(
279 Layer.provide(
280 Layer.mergeAll(
281 Layer.mock(ThreadManagementService.ThreadManagementService)({
282 dispatch,
283 getThreadRecords,
284 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
285 Layer.mock(ProjectService.ProjectService)({
286 getById,
287 } satisfies Partial<ProjectService.ProjectService["Service"]>),
288 ServerSettings.layerTest({
289 newWorktreesStartFromOrigin: options.newWorktreesStartFromOrigin ?? false,
290 ...(options.projectNewWorktreesStartFromOrigin === undefined
291 ? {}
292 : {
293 projectSettingsOverrides: {
294 [projectId]: {
295 newWorktreesStartFromOrigin: options.projectNewWorktreesStartFromOrigin,
296 },
297 },
298 }),
299 }),
300 Layer.mock(GitWorkflowService.GitWorkflowService)({
301 listRefs,
302 listLocalBranchNames,
303 localStatus,
304 fetchWorktreeBaseCommit,
305 createWorktree,
306 removeWorktree,
307 deleteLocalBranch,
308 ...options.git,
309 } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>),
310 Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({
311 runForThread,
312 } satisfies Partial<ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]>),
313 Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({
314 refreshStatus,
315 } satisfies Partial<VcsStatusBroadcaster.VcsStatusBroadcaster["Service"]>),
316 NodeServices.layer,
317 NodeCrypto.layer,
318 options.existsFails
319 ? FileSystem.layerNoop({
320 exists: (path) =>
321 Effect.fail(
322 PlatformError.systemError({
323 _tag: "PermissionDenied",
324 module: "FileSystem",
325 method: "exists",
326 pathOrDescriptor: path,
327 }),
328 ),
329 })
330 : Layer.empty,
331 ),
332 ),
333 );
334​
335 return {
336 layer,
337 scope,
338 listRefs,
339 dispatch,
340 fetchWorktreeBaseCommit,
341 createWorktree,
342 removeWorktree,
343 deleteLocalBranch,
344 localStatus,
345 runForThread,
346 };
347};
348​
349const expectTypedFailure = (exit: Exit.Exit<unknown, unknown>, expected: object): void => {
350 if (!Exit.isFailure(exit)) {
351 expect.fail(`Expected a failure exit, got: ${JSON.stringify(exit)}`);
352 }
353 const reason = exit.cause.reasons[0];
354 if (reason?._tag !== "Fail") {
355 expect.fail(`Expected a typed Fail cause, got: ${reason?._tag ?? "no reason"}`);
356 }
357 expect(reason.error).toMatchObject(expected);
358};
359​
360// Resolve once so calls share the in-flight guard.
361const resolveService = (harness: ReturnType<typeof makeHarness>) =>
362 WorktreeMcpService.WorktreeMcpService.pipe(Effect.provide(harness.layer));
363​
364const runHandoff = (
365 harness: ReturnType<typeof makeHarness>,
366 input: Parameters<WorktreeMcpService.WorktreeMcpService["Service"]["handoff"]>[1],
367) =>
368 Effect.gen(function* () {
369 const service = yield* WorktreeMcpService.WorktreeMcpService;
370 return yield* service.handoff(harness.scope, input);
371 }).pipe(Effect.provide(harness.layer));
372​
373const runStatus = (harness: ReturnType<typeof makeHarness>) =>
374 Effect.gen(function* () {
375 const service = yield* WorktreeMcpService.WorktreeMcpService;
376 return yield* service.status(harness.scope);
377 }).pipe(Effect.provide(harness.layer));
378​
379const decodeHandoffInput = Schema.decodeUnknownEffect(WorktreeMcpHandoffInput);
380​
381describe("t3_worktree_handoff", () => {
382 // regression: #22971 a separate send let the detach settle the run before its follow-up existed
383 it.effect("commits the continuation with the thread binding", () => {
384 const harness = makeHarness();
385 return Effect.gen(function* () {
386 const result = yield* runHandoff(harness, {
387 branch: "feature/continue",
388 continuationPrompt: "Keep going.",
389 });
390 expect(result.continuation).toEqual({ status: "scheduled", delivery: "queued" });
391 expect(harness.dispatch).toHaveBeenCalledTimes(1);
392 expect(harness.dispatch).toHaveBeenCalledWith(
393 expect.objectContaining({
394 type: "thread.metadata.update",
395 worktreePath: "/worktrees/project/feature/continue",
396 continuation: expect.objectContaining({ text: "Keep going." }),
397 }),
398 );
399 });
400 });
401​
402 it.effect("uses the fetched commit and remote PR base for an explicit local alias", () =>
403 Effect.gen(function* () {
404 const fs = yield* FileSystem.FileSystem;
405 const git = yield* GitVcsDriver.GitVcsDriver;
406 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "handoff-remote-base-" });
407 const cwd = `${directory}/repo`;
408 const remote = `${directory}/remote.git`;
409 const worktreePath = `${directory}/worktree`;
410 yield* fs.makeDirectory(cwd);
411 const command = (at: string, args: string[]) =>
412 git.execute({ operation: "handoff-test", cwd: at, args });
413 yield* command(cwd, ["init", "-b", "stable"]);
414 yield* command(cwd, ["config", "user.name", "Test"]);
415 yield* command(cwd, ["config", "user.email", "test@example.test"]);
416 yield* command(cwd, ["commit", "--allow-empty", "-m", "remote base"]);
417 const remoteHead = (yield* command(cwd, ["rev-parse", "HEAD"])).stdout.trim();
418 yield* command(cwd, ["init", "--bare", remote]);
419 yield* command(cwd, ["remote", "add", "upstream", remote]);
420 yield* command(cwd, ["push", "-u", "upstream", "stable:release"]);
421 yield* command(cwd, ["commit", "--allow-empty", "-m", "local only"]);
422 const harness = makeHarness({
423 git: {
424 fetchWorktreeBaseCommit: (input) => git.fetchWorktreeBaseCommit({ ...input, cwd }),
425 createWorktree: (input) => git.createWorktree({ ...input, cwd, path: worktreePath }),
426 },
427 });
428 const result = yield* runHandoff(harness, {
429 branch: "feature/from-remote",
430 baseRef: "stable",
431 startFromOrigin: true,
432 runSetupScript: false,
433 });
434​
435 expect(result.worktreePath).toBe(worktreePath);
436 expect(result.startedFromOrigin).toBe(true);
437 expect(result.setupScript).toEqual({ status: "skipped" });
438 expect((yield* command(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(remoteHead);
439 const preview = yield* git.getReviewDiffPreview({ cwd: worktreePath });
440 const comparisonBase = preview.sources.find(
441 (source) => source.kind === "branch-range",
442 )!.baseRef;
443 expect(
444 (yield* command(worktreePath, ["rev-parse", `${comparisonBase}^{commit}`])).stdout.trim(),
445 ).toBe(remoteHead);
446 }).pipe(
447 Effect.provide(
448 GitVcsDriver.layer.pipe(
449 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "handoff-remote-git-" })),
450 Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
451 ),
452 ),
453 ),
454 );
455​
456 it.effect("fails when the thread is already attached to a worktree", () => {
457 const harness = makeHarness({
458 thread: { branch: "feature/existing", worktreePath: "/worktrees/project/existing" },
459 });
460 return Effect.gen(function* () {
461 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/second" }));
462 expectTypedFailure(exit, {
463 _tag: "WorktreeMcpFailure",
464 code: "already_in_worktree",
465 });
466 expect(harness.createWorktree).not.toHaveBeenCalled();
467 });
468 });
469​
470 it.effect("rejects an existing branch with an actionable error naming its checkout", () => {
471 const harness = makeHarness({ existingBranchWorktreePath: "/elsewhere/checkout" });
472 return Effect.gen(function* () {
473 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/taken" }));
474 if (!Exit.isFailure(exit)) {
475 return expect.fail("expected a failure exit");
476 }
477 const reason = exit.cause.reasons[0];
478 expect(reason?._tag).toBe("Fail");
479 const error = (reason as { readonly error: { code: string; message: string } }).error;
480 expect(error.code).toBe("invalid_request");
481 expect(error.message).toContain("feature/taken");
482 expect(error.message).toContain("already exists");
483 expect(error.message).toContain("/elsewhere/checkout");
484 expect(harness.createWorktree).not.toHaveBeenCalled();
485 expect(harness.fetchWorktreeBaseCommit).not.toHaveBeenCalled();
486 });
487 });
488​
489 it.effect("does not trip the pre-flight on similarly named branches", () => {
490 const harness = makeHarness({ existingBranchWorktreePath: null });
491 return Effect.gen(function* () {
492 const result = yield* runHandoff(harness, { branch: "feature/take" });
493 expect(result.branch).toBe("feature/take");
494 expect(harness.createWorktree).toHaveBeenCalledTimes(1);
495 });
496 });
497​
498 it.effect("propagates an interrupted dispatch without rolling back the worktree", () => {
499 const harness = makeHarness({ dispatchInterrupts: true });
500 return Effect.gen(function* () {
501 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/dispatch-int" }));
502 if (!Exit.isFailure(exit)) {
503 return expect.fail("expected a failure exit");
504 }
505 expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true);
506 expect(harness.removeWorktree).not.toHaveBeenCalled();
507 });
508 });
509​
510 it.effect("falls back to the local base when no remote holds it", () => {
511 const harness = makeHarness({ worktreeBaseMissing: true });
512 return Effect.gen(function* () {
513 const result = yield* runHandoff(harness, {
514 branch: "feature/local-fallback",
515 baseRef: "dev",
516 startFromOrigin: true,
517 });
518 expect(harness.createWorktree).toHaveBeenCalledWith(
519 expect.objectContaining({ refName: "dev", baseRefName: "dev" }),
520 );
521 expect(result.startedFromOrigin).toBe(false);
522 });
523 });
524​
525 it.effect("defaults startFromOrigin to the project's override", () => {
526 const harness = makeHarness({ projectNewWorktreesStartFromOrigin: true });
527 return Effect.gen(function* () {
528 const result = yield* runHandoff(harness, { branch: "feature/project-origin" });
529 expect(harness.fetchWorktreeBaseCommit).toHaveBeenCalled();
530 expect(result.startedFromOrigin).toBe(true);
531 });
532 });
533​
534 it.effect("removes the created worktree when the thread update dies with a defect", () => {
535 const harness = makeHarness({ dispatchDies: true });
536 return Effect.gen(function* () {
537 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/dispatch-defect" }));
538 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" });
539 expect(harness.removeWorktree).toHaveBeenCalledWith({
540 cwd: workspaceRoot,
541 path: "/worktrees/project/feature/dispatch-defect",
542 force: true,
543 });
544 });
545 });
546​
547 it.effect("re-checks attachment after creating the worktree and backs out on a race", () => {
548 const harness = makeHarness({ threadAttachedOnRecheck: true });
549 return Effect.gen(function* () {
550 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/raced" }));
551 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "already_in_worktree" });
552 expect(harness.createWorktree).toHaveBeenCalledTimes(1);
553 expect(harness.removeWorktree).toHaveBeenCalledWith({
554 cwd: workspaceRoot,
555 path: "/worktrees/project/feature/raced",
556 force: true,
557 });
558 expect(harness.deleteLocalBranch).toHaveBeenCalledWith({
559 cwd: workspaceRoot,
560 refName: "feature/raced",
561 force: true,
562 });
563 expect(harness.dispatch).not.toHaveBeenCalled();
564 });
565 });
566​
567 it.effect("removes the created worktree when the recheck read fails", () => {
568 const harness = makeHarness({ threadReadFailsOnRecheck: true });
569 return Effect.gen(function* () {
570 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/recheck-fails" }));
571 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" });
572 expect(harness.removeWorktree).toHaveBeenCalledWith({
573 cwd: workspaceRoot,
574 path: "/worktrees/project/feature/recheck-fails",
575 force: true,
576 });
577 expect(harness.dispatch).not.toHaveBeenCalled();
578 });
579 });
580​
581 it.effect("backs out when the thread is archived during worktree creation", () => {
582 const harness = makeHarness({ threadArchivedOnRecheck: true });
583 return Effect.gen(function* () {
584 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/archived-race" }));
585 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "invalid_request" });
586 expect(harness.dispatch).not.toHaveBeenCalled();
587 expect(harness.removeWorktree).toHaveBeenCalledWith({
588 cwd: workspaceRoot,
589 path: "/worktrees/project/feature/archived-race",
590 force: true,
591 });
592 });
593 });
594​
595 it.effect("runs the setup script even when interrupted during the binding dispatch", () =>
596 Effect.gen(function* () {
597 const gate = yield* Deferred.make<void>();
598 const harness = makeHarness({ dispatchGate: Deferred.await(gate) });
599​
600 const fiber = yield* Effect.forkChild(
601 runHandoff(harness, {
602 branch: "feature/interrupted",
603 continuationPrompt: "Keep going in the worktree.",
604 }),
605 );
606 yield* Effect.yieldNow;
607 const interruption = yield* Effect.forkChild(Fiber.interrupt(fiber));
608 yield* Effect.yieldNow;
609 yield* Deferred.succeed(gate, undefined);
610 yield* Fiber.await(fiber);
611 yield* Fiber.join(interruption);
612​
613 expect(harness.dispatch).toHaveBeenCalledTimes(1);
614 expect(harness.runForThread).toHaveBeenCalledTimes(1);
615 }),
616 );
617​
618 it.effect("creates the worktree in the managed directory even when a call names a path", () => {
619 const harness = makeHarness();
620 return Effect.gen(function* () {
621 const input = yield* decodeHandoffInput({
622 branch: "feature/elsewhere",
623 path: "/home/user/.ssh/checkout",
624 });
625 const result = yield* runHandoff(harness, input);
626 expect(harness.createWorktree).toHaveBeenCalledWith(expect.objectContaining({ path: null }));
627 expect(result.worktreePath).toBe("/worktrees/project/feature/elsewhere");
628 });
629 });
630​
631 it.effect("releases the per-thread guard after a failed handoff", () => {
632 const harness = makeHarness({
633 thread: { worktreePath: "/worktrees/project/existing" },
634 });
635 return Effect.gen(function* () {
636 const service = yield* resolveService(harness);
637​
638 const first = yield* Effect.exit(
639 service.handoff(harness.scope, { branch: "feature/guard-1" }),
640 );
641 expectTypedFailure(first, { _tag: "WorktreeMcpFailure", code: "already_in_worktree" });
642 const second = yield* Effect.exit(
643 service.handoff(harness.scope, { branch: "feature/guard-2" }),
644 );
645 expectTypedFailure(second, { _tag: "WorktreeMcpFailure", code: "already_in_worktree" });
646 });
647 });
648​
649 it.effect("serializes concurrent handoffs for the same thread", () =>
650 Effect.gen(function* () {
651 const gate = yield* Deferred.make<void>();
652 const harness = makeHarness({ createWorktreeGate: Deferred.await(gate) });
653​
654 const service = yield* resolveService(harness);
655​
656 const first = yield* Effect.forkChild(
657 Effect.exit(service.handoff(harness.scope, { branch: "feature/race-1" })),
658 );
659 yield* Effect.yieldNow;
660​
661 const second = yield* Effect.exit(
662 service.handoff(harness.scope, { branch: "feature/race-2" }),
663 );
664 expectTypedFailure(second, { _tag: "WorktreeMcpFailure", code: "handoff_in_progress" });
665​
666 yield* Deferred.succeed(gate, undefined);
667 const firstExit = yield* Fiber.join(first);
668 expect(Exit.isSuccess(firstExit)).toBe(true);
669 expect(harness.createWorktree).toHaveBeenCalledTimes(1);
670 }),
671 );
672​
673 it.effect("reports a setup script defect without failing the handoff", () => {
674 const harness = makeHarness({ setupScript: "dies" });
675 return Effect.gen(function* () {
676 const result = yield* runHandoff(harness, { branch: "feature/setup-dies" });
677 expect(result.setupScript).toEqual({ status: "failed", detail: "setup runner defect" });
678 expect(harness.dispatch).toHaveBeenCalled();
679 });
680 });
681});
682​
683describe("t3_worktree_status", () => {
684 it.effect("reports an attached thread's worktree and branch", () => {
685 const harness = makeHarness({
686 thread: {
687 worktreePath: "/worktrees/project/existing",
688 branch: "feature/existing",
689 },
690 projectNewWorktreesStartFromOrigin: true,
691 });
692 return Effect.gen(function* () {
693 const result = yield* runStatus(harness);
694 expect(result).toMatchObject({
695 attached: true,
696 worktreePath: "/worktrees/project/existing",
697 branch: "feature/existing",
698 defaultStartFromOrigin: true,
699 });
700 });
701 });
702});
703​
704describe("t3_worktree_list", () => {
705 const listRefs = (harness: ReturnType<typeof makeHarness>, worktreePath: string | null) =>
706 Effect.gen(function* () {
707 const service = yield* WorktreeMcpService.WorktreeMcpService;
708 return yield* service.listRefs({ worktreePath, workspaceRoot });
709 }).pipe(Effect.provide(harness.layer));
710​
711 it.effect("lists refs from the thread's worktree, or the project root once it was removed", () =>
712 Effect.gen(function* () {
713 const fs = yield* FileSystem.FileSystem;
714 const worktreePath = yield* fs.makeTempDirectoryScoped({ prefix: "worktree-list-" });
715 const harness = makeHarness();
716 yield* listRefs(harness, worktreePath);
717 yield* listRefs(harness, `${worktreePath}/removed`);
718 expect(harness.listRefs.mock.calls.map(([input]) => input.cwd)).toEqual([
719 worktreePath,
720 workspaceRoot,
721 ]);
722 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
723 );
724​
725 it.effect("fails instead of falling back when the worktree cannot be checked", () => {
726 const harness = makeHarness({ existsFails: true });
727 return Effect.gen(function* () {
728 const exit = yield* Effect.exit(listRefs(harness, "/worktrees/project/unreadable"));
729 expectTypedFailure(exit, { _tag: "PlatformError" });
730 expect(harness.listRefs).not.toHaveBeenCalled();
731 });
732 });
733});