Skip to content
Source
Sign inSign up

WorktreeMcpService.test.ts

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