History

fix(mcp): serialize physical workspace checkout

Julius Marminge committed 2 months ago

Commit 8da96691 parent bdbd1bd4 files · +1339 −379Browse files

Unified · Split

4 files
  1. apps/server/src/mcp/WorktreeMcpService.test.ts +576 −70
  2. apps/server/src/mcp/WorktreeMcpService.ts +572 −309
  3. apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +164 −0
  4. apps/server/src/vcs/GitVcsDriverCore.test.ts +27 −0
apps/server/src/mcp/WorktreeMcpService.test.ts+576−70
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
55 EnvironmentId,
66 GitManagerError,
77 type OrchestrationV2ThreadProjection,
8+ type OrchestrationV2ThreadShell,
89 type Project,
910 ProjectId,
1011 ProviderInstanceId,
@@ -104,7 +105,10 @@ interface HarnessOptions { · 93 unmodified lines
104105 readonly dispatchGate?: Effect.Effect<void>;
105106 readonly threadAttachedOnRecheck?: boolean;
106107 readonly threadArchivedOnRecheck?: boolean;
108+ readonly threadArchivedOnCall?: number;
109+ readonly threadDeletedOnCall?: number;
107110 readonly threadReadFailsOnRecheck?: boolean;
111+ readonly threadReadFailsAfterDispatch?: boolean;
108112 readonly threadReadFailsOnCall?: number;
109113 readonly continuation?: "queued" | "fails" | "dies";
110114 readonly projectMissing?: boolean;
@@ -124,6 +128,12 @@ interface HarnessOptions { · 13 unmodified lines
124128 readonly isRemote?: boolean;
125129 readonly worktreePath: string | null;
126130 }>;
131+ readonly worktrees?: ReadonlyArray<{
132+ readonly path: string;
133+ readonly refName: string | null;
134+ }>;
135+ readonly projectWorktreeRoot?: string;
136+ readonly workspaceAliases?: Readonly<Record<string, string>>;
127137 readonly workspaceStatuses?: Readonly<Record<string, { branch: string | null; dirty?: boolean }>>;
128138 readonly localStatusFailsOnCall?: number;
129139 readonly localStatusFailure?: "typed" | "defect" | "interrupt";
@@ -135,9 +145,21 @@ interface HarnessOptions { · 5 unmodified lines
135145 readonly status?: "idle" | "running";
136146 readonly active?: boolean;
137147 }>;
148+ readonly otherProjectThread?: {
149+ readonly projectId: ProjectId;
150+ readonly workspaceRoot: string;
151+ readonly id: ThreadId;
152+ readonly title: string;
153+ readonly branch: string | null;
154+ readonly worktreePath: string | null;
155+ readonly active?: boolean;
156+ };
138157 readonly switchRefFails?: boolean;
139158 readonly switchRefFailsAfterMutation?: boolean;
140159 readonly switchRefRollbackFails?: boolean;
160+ readonly switchRefGate?: Effect.Effect<void>;
161+ readonly switchRefResultBranch?: string | null;
162+ readonly refChangeAfterSwitch?: string | null;
141163 readonly createRefFails?: boolean;
142164 }
143165
@@ -189,6 +211,28 @@ const makeHarness = (options: HarnessOptions = {}) => { · 45 unmodified lines
189211 }),
190212 ) as never;
191213 }
214+ if (options.threadReadFailsAfterDispatch === true && dispatch.mock.calls.length > 0) {
215+ return Effect.fail(
216+ new OrchestratorDispatchError({
217+ commandId: CommandId.make("command:test:post-dispatch-read"),
218+ commandType: "thread.metadata.update",
219+ }),
220+ ) as never;
221+ }
222+ if (
223+ options.threadArchivedOnCall !== undefined &&
224+ getThreadProjection.mock.calls.length >= options.threadArchivedOnCall &&
225+ thread !== null
226+ ) {
227+ return Effect.succeed(makeProjection({ ...thread, archivedAt: "2026-01-02T00:00:00.000Z" }));
228+ }
229+ if (
230+ options.threadDeletedOnCall !== undefined &&
231+ getThreadProjection.mock.calls.length >= options.threadDeletedOnCall &&
232+ thread !== null
233+ ) {
234+ return Effect.succeed(makeProjection({ ...thread, deletedAt: "2026-01-02T00:00:00.000Z" }));
235+ }
192236 if (
193237 options.threadAttachedOnRecheck === true &&
194238 getThreadProjection.mock.calls.length > 1 &&
@@ -212,9 +256,26 @@ const makeHarness = (options: HarnessOptions = {}) => { · 17 unmodified lines
212256 ) {
213257 return Effect.succeed(makeProjection({ ...thread, ...options.threadAfterFailedDispatch }));
214258 }
215− return id === threadId && thread !== null
216− ? Effect.succeed(makeProjection(thread))
217− : Effect.fail(new OrchestratorProjectionError({ threadId: id }));
259+ if (id === threadId && thread !== null) {
260+ return Effect.succeed(makeProjection(thread));
261+ }
262+ const projectThread = options.projectThreads?.find((item) => item.id === id);
263+ if (projectThread !== undefined) {
264+ const projection = makeProjection({
265+ branch: projectThread.branch,
266+ worktreePath: projectThread.worktreePath,
267+ });
268+ return Effect.succeed({
269+ ...projection,
270+ thread: {
271+ ...projection.thread,
272+ id: projectThread.id,
273+ title: projectThread.title,
274+ activeRunId: projectThread.active === true ? "run-active" : null,
275+ },
276+ });
277+ }
278+ return Effect.fail(new OrchestratorProjectionError({ threadId: id }));
218279 });
219280 const sendToThread = vi.fn((_: unknown) => {
220281 switch (options.continuation ?? "queued") {
@@ -236,34 +297,59 @@ const makeHarness = (options: HarnessOptions = {}) => { · 15 unmodified lines
236297 : Effect.succeed(
237298 id === projectId && options.projectMissing !== true
238299 ? Option.some(project)
239− : Option.none(),
300+ : id === options.otherProjectThread?.projectId
301+ ? Option.some({
302+ ...project,
303+ id,
304+ workspaceRoot: options.otherProjectThread.workspaceRoot,
305+ })
306+ : Option.none(),
240307 ),
241308 );
242− const listProjectThreads = vi.fn(() =>
243− Effect.succeed(
244− (
245− options.projectThreads ?? [
246− {
247− id: threadId,
248− title: "Worktree test thread",
249− branch: thread?.branch ?? null,
250− worktreePath: thread?.worktreePath ?? null,
251− },
252− ]
253− ).map(
254− (item) =>
255− ({
256− id: item.id,
257− projectId,
258− title: item.title,
259− branch: item.branch,
260− worktreePath: item.worktreePath,
261− status: item.status ?? "idle",
262− activeRunId: item.active === true ? "run-active" : null,
263− lineage: { relationshipToParent: "none" },
264− }) as never,
265− ),
266− ),
309+ const projectThreadShells: Array<OrchestrationV2ThreadShell> = (
310+ options.projectThreads ?? [
311+ {
312+ id: threadId,
313+ title: "Worktree test thread",
314+ branch: thread?.branch ?? null,
315+ worktreePath: thread?.worktreePath ?? null,
316+ },
317+ ]
318+ ).map(
319+ (item) =>
320+ ({
321+ id: item.id,
322+ projectId,
323+ title: item.title,
324+ branch: item.branch,
325+ worktreePath: item.worktreePath,
326+ status: item.status ?? "idle",
327+ activeRunId: item.active === true ? "run-active" : null,
328+ lineage: { relationshipToParent: "none" },
329+ }) as unknown as OrchestrationV2ThreadShell,
330+ );
331+ if (options.otherProjectThread !== undefined) {
332+ projectThreadShells.push({
333+ ...(projectThreadShells[0] ?? makeProjection({}).thread),
334+ id: options.otherProjectThread.id,
335+ projectId: options.otherProjectThread.projectId,
336+ title: options.otherProjectThread.title,
337+ branch: options.otherProjectThread.branch,
338+ worktreePath: options.otherProjectThread.worktreePath,
339+ activeRunId: options.otherProjectThread.active === true ? "run-active" : null,
340+ lineage: { relationshipToParent: "none" },
341+ } as unknown as OrchestrationV2ThreadShell);
342+ }
343+ const listProjectThreads = vi.fn((input: { readonly projectId: ProjectId }) =>
344+ Effect.succeed(projectThreadShells.filter((item) => item.projectId === input.projectId)),
345+ );
346+ const getShellSnapshot = vi.fn(() =>
347+ Effect.succeed({
348+ schemaVersion: 1,
349+ snapshotSequence: 1,
350+ threads: projectThreadShells,
351+ archivedThreads: [],
352+ } as never),
267353 );
268354 const removeWorktree = vi.fn((_: unknown) =>
269355 options.removeWorktreeFails
@@ -312,28 +398,56 @@ const makeHarness = (options: HarnessOptions = {}) => { · 42 unmodified lines
312398 ),
313399 ),
314400 );
315− const listRefs = vi.fn((input: { readonly query?: string | undefined }) =>
316− Effect.succeed({
317− refs:
318− options.refs !== undefined
319− ? options.refs.filter(
320− (ref) => input.query === undefined || ref.name.includes(input.query),
321− )
322− : options.existingBranchWorktreePath === undefined
323− ? []
324− : [
325− {
326− name: input.query ?? "",
327− current: false,
328− isDefault: false,
329− worktreePath: options.existingBranchWorktreePath,
330− },
331− ],
401+ const listRefs = vi.fn((input: { readonly query?: string | undefined }) => {
402+ const refs =
403+ options.refs !== undefined
404+ ? options.refs.filter((ref) =>
405+ input.query === undefined ? true : ref.name.includes(input.query),
406+ )
407+ : options.existingBranchWorktreePath === undefined
408+ ? []
409+ : [
410+ {
411+ name: input.query ?? "",
412+ current: false,
413+ isDefault: false,
414+ worktreePath: options.existingBranchWorktreePath,
415+ },
416+ ];
417+ return Effect.succeed({
418+ refs,
332419 isRepo: true,
333420 hasPrimaryRemote: true,
334421 nextCursor: null,
335422 totalCount:
336423 options.refs?.length ?? (options.existingBranchWorktreePath === undefined ? 0 : 1),
424+ });
425+ });
426+ const configuredWorktrees =
427+ options.worktrees ??
428+ (options.refs ?? []).flatMap((ref) =>
429+ ref.worktreePath === null ? [] : [{ path: ref.worktreePath, refName: ref.name }],
430+ );
431+ const projectWorktreeRoot = options.projectWorktreeRoot ?? workspaceRoot;
432+ const listedWorktrees = configuredWorktrees.some(
433+ (worktree) => worktree.path === projectWorktreeRoot,
434+ )
435+ ? configuredWorktrees
436+ : [
437+ {
438+ path: projectWorktreeRoot,
439+ refName: options.currentBranch === undefined ? "dev" : options.currentBranch,
440+ },
441+ ...configuredWorktrees,
442+ ];
443+ const listWorktrees = vi.fn((cwd: string) =>
444+ Effect.succeed({
445+ repositoryCommonDir: "/repo/.git",
446+ currentWorktreeRoot:
447+ options.workspaceAliases?.[cwd] ??
448+ listedWorktrees.find((worktree) => worktree.path === cwd)?.path ??
449+ (cwd === workspaceRoot ? projectWorktreeRoot : cwd),
450+ worktrees: listedWorktrees,
337451 }),
338452 );
339453 let localStatusCallCount = 0;
@@ -363,27 +477,47 @@ const makeHarness = (options: HarnessOptions = {}) => { · 23 unmodified lines
363477 hasPrimaryRemote: true,
364478 isDefaultRef: false,
365479 refName:
366− current?.branch ?? (options.currentBranch === undefined ? "dev" : options.currentBranch),
480+ current === undefined
481+ ? options.currentBranch === undefined
482+ ? "dev"
483+ : options.currentBranch
484+ : current.branch,
367485 hasWorkingTreeChanges: current?.dirty ?? false,
368486 workingTree: { files: [], insertions: 0, deletions: 0 },
369487 });
370488 });
371489 let switchCallCount = 0;
372− const switchRef = vi.fn((input: { readonly cwd: string; readonly refName: string }) => {
373− switchCallCount += 1;
374− if (options.switchRefFailsAfterMutation === true && switchCallCount === 1) {
375− workspaceStatuses.set(input.cwd, { branch: input.refName, dirty: false });
376− return Effect.fail("simulated switch failure after mutation") as never;
377− }
378− if (
379− options.switchRefFails === true ||
380− (options.switchRefRollbackFails === true && switchCallCount > 1)
381− ) {
382− return Effect.fail("simulated switch failure") as never;
383− }
384− workspaceStatuses.set(input.cwd, { branch: input.refName, dirty: false });
385− return Effect.succeed({ refName: input.refName });
386− });
490+ const switchRef = vi.fn((input: { readonly cwd: string; readonly refName: string }) =>
491+ (options.switchRefGate ?? Effect.void).pipe(
492+ Effect.andThen(
493+ Effect.suspend(() => {
494+ switchCallCount += 1;
495+ if (options.switchRefFailsAfterMutation === true && switchCallCount === 1) {
496+ workspaceStatuses.set(input.cwd, { branch: input.refName, dirty: false });
497+ return Effect.fail("simulated switch failure after mutation") as never;
498+ }
499+ if (
500+ options.switchRefFails === true ||
501+ (options.switchRefRollbackFails === true && switchCallCount > 1)
502+ ) {
503+ return Effect.fail("simulated switch failure") as never;
504+ }
505+ const resolvedBranch =
506+ options.switchRefResultBranch === undefined
507+ ? input.refName
508+ : options.switchRefResultBranch;
509+ workspaceStatuses.set(input.cwd, {
510+ branch:
511+ options.refChangeAfterSwitch === undefined
512+ ? resolvedBranch
513+ : options.refChangeAfterSwitch,
514+ dirty: false,
515+ });
516+ return Effect.succeed({ refName: resolvedBranch });
517+ }),
518+ ),
519+ ),
520+ );
387521 const createRef = vi.fn((_: unknown) =>
388522 options.createRefFails === true
389523 ? (Effect.fail("simulated create ref failure") as never)
@@ -418,8 +552,8 @@ const makeHarness = (options: HarnessOptions = {}) => { · 28 unmodified lines
418552 // Optional deterministic Path semantics: providing this BEFORE the general
419553 // mocks means the service resolves Path here rather than from NodeServices,
420554 // so absolute-path validation is testable independently of the host OS. The
421− // service only calls isAbsolute; the minimal per-platform semantics are
422− // inlined so the test does not depend on the host's path module.
555+ // The minimal per-platform semantics are inlined so the test does not
556+ // depend on the host's path module.
423557 const win32IsAbsolute = (value: string) => /^(?:[a-zA-Z]:[\\/]|[\\/])/.test(value);
424558 const posixIsAbsolute = (value: string) => value.startsWith("/");
425559 const serviceLayer =
@@ -429,6 +563,8 @@ const makeHarness = (options: HarnessOptions = {}) => { · 3 unmodified lines
429563 Layer.provide(
430564 Layer.succeed(Path.Path, {
431565 isAbsolute: options.pathSemantics === "win32" ? win32IsAbsolute : posixIsAbsolute,
566+ normalize: (value: string) => value,
567+ resolve: (value: string) => value,
432568 } as unknown as Path.Path),
433569 ),
434570 );
@@ -437,6 +573,7 @@ const makeHarness = (options: HarnessOptions = {}) => { · 2 unmodified lines
437573 Layer.mergeAll(
438574 Layer.mock(ThreadManagementService)({
439575 dispatch,
576+ getShellSnapshot,
440577 getThreadProjection,
441578 listProjectThreads,
442579 sendToThread,
@@ -449,6 +586,7 @@ const makeHarness = (options: HarnessOptions = {}) => { · 6 unmodified lines
449586 }),
450587 Layer.mock(GitWorkflowService.GitWorkflowService)({
451588 listRefs,
589+ listWorktrees,
452590 listLocalBranchNames,
453591 localStatus,
454592 fetchRemote,
@@ -483,6 +621,7 @@ const makeHarness = (options: HarnessOptions = {}) => { · 28 unmodified lines
483621 deleteLocalBranch,
484622 localStatus,
485623 listRefs,
624+ listWorktrees,
486625 listProjectThreads,
487626 switchRef,
488627 createRef,
@@ -525,10 +664,13 @@ const runStatus = (harness: ReturnType<typeof makeHarness>) => · 36 unmodified lines
525664 return yield* service.status(harness.scope);
526665 }).pipe(Effect.provide(harness.layer));
527666
528−const runList = (harness: ReturnType<typeof makeHarness>) =>
667+const runList = (
668+ harness: ReturnType<typeof makeHarness>,
669+ input: Parameters<WorktreeMcpService["Service"]["listWorktrees"]>[1] = {},
670+) =>
529671 Effect.gen(function* () {
530672 const service = yield* WorktreeMcpService;
531− return yield* service.listWorktrees(harness.scope);
673+ return yield* service.listWorktrees(harness.scope, input);
532674 }).pipe(Effect.provide(harness.layer));
533675
534676 const runCheckout = (
@@ -987,7 +1129,12 @@ describe("t3_worktree_handoff", () => { · 452 unmodified lines
9871129 it.effect("queues the continuation even when interrupted during the binding dispatch", () =>
9881130 Effect.gen(function* () {
9891131 const gate = yield* Deferred.make<void>();
990− const harness = makeHarness({ dispatchGate: Deferred.await(gate) });
1132+ const dispatchStarted = yield* Deferred.make<void>();
1133+ const harness = makeHarness({
1134+ dispatchGate: Deferred.succeed(dispatchStarted, undefined).pipe(
1135+ Effect.andThen(Deferred.await(gate)),
1136+ ),
1137+ });
9911138
9921139 // Interrupt arrives while the metadata dispatch is in flight; the
9931140 // binding-plus-continuation section must run to completion anyway so the
@@ -998,7 +1145,7 @@ describe("t3_worktree_handoff", () => { · 4 unmodified lines
9981145 continuationPrompt: "Keep going in the worktree.",
9991146 }),
10001147 );
1001− yield* Effect.yieldNow;
1148+ yield* Deferred.await(dispatchStarted);
10021149 const interruption = yield* Effect.forkChild(Fiber.interrupt(fiber));
10031150 yield* Effect.yieldNow;
10041151 yield* Deferred.succeed(gate, undefined);
@@ -1335,6 +1482,10 @@ describe("t3_worktree_list", () => { · 330 unmodified lines
13351482 return Effect.gen(function* () {
13361483 const result = yield* runList(harness);
13371484 expect(result.projectWorkspaceRoot).toBe(workspaceRoot);
1485+ expect(result.repositoryCommonDir).toBe("/repo/.git");
1486+ expect(result.projectWorktreeRoot).toBe(workspaceRoot);
1487+ expect(result.nextCursor).toBeNull();
1488+ expect(result.total).toBe(2);
13381489 expect(result.worktrees).toEqual([
13391490 {
13401491 path: workspaceRoot,
@@ -1343,6 +1494,8 @@ describe("t3_worktree_list", () => { · 2 unmodified lines
13431494 isRepo: true,
13441495 isProjectRoot: true,
13451496 hasWorkingTreeChanges: false,
1497+ availability: "available",
1498+ statusError: null,
13461499 bindings: [
13471500 {
13481501 threadId,
@@ -1354,6 +1507,7 @@ describe("t3_worktree_list", () => { · 5 unmodified lines
13541507 callingThread: true,
13551508 },
13561509 ],
1510+ bindingCount: 1,
13571511 },
13581512 {
13591513 path: worktreePath,
@@ -1362,6 +1516,8 @@ describe("t3_worktree_list", () => { · 2 unmodified lines
13621516 isRepo: true,
13631517 isProjectRoot: false,
13641518 hasWorkingTreeChanges: true,
1519+ availability: "available",
1520+ statusError: null,
13651521 bindings: [
13661522 {
13671523 threadId: otherThreadId,
@@ -1373,10 +1529,84 @@ describe("t3_worktree_list", () => { · 5 unmodified lines
13731529 callingThread: false,
13741530 },
13751531 ],
1532+ bindingCount: 1,
13761533 },
13771534 ]);
13781535 });
13791536 });
1537+
1538+ it.effect("includes detached worktrees without inventing a branch label", () => {
1539+ const detachedPath = "/worktrees/project/detached";
1540+ const harness = makeHarness({
1541+ worktrees: [
1542+ { path: workspaceRoot, refName: "dev" },
1543+ { path: detachedPath, refName: null },
1544+ ],
1545+ workspaceStatuses: {
1546+ [workspaceRoot]: { branch: "dev" },
1547+ [detachedPath]: { branch: null },
1548+ },
1549+ });
1550+ return Effect.gen(function* () {
1551+ const result = yield* runList(harness);
1552+ expect(result.worktrees).toContainEqual({
1553+ path: detachedPath,
1554+ branch: null,
1555+ actualBranch: null,
1556+ isRepo: true,
1557+ isProjectRoot: false,
1558+ hasWorkingTreeChanges: false,
1559+ availability: "available",
1560+ statusError: null,
1561+ bindings: [],
1562+ bindingCount: 0,
1563+ });
1564+ });
1565+ });
1566+
1567+ it.effect("pages before status reads and keeps a missing checkout discoverable", () => {
1568+ const firstPath = "/worktrees/project/a-missing";
1569+ const secondPath = "/worktrees/project/b";
1570+ const harness = makeHarness({
1571+ worktrees: [
1572+ { path: workspaceRoot, refName: "dev" },
1573+ { path: firstPath, refName: "feature/a" },
1574+ { path: secondPath, refName: "feature/b" },
1575+ ],
1576+ localStatusFailsOnCall: 1,
1577+ });
1578+ return Effect.gen(function* () {
1579+ const result = yield* runList(harness, { cursor: 1, limit: 1 });
1580+ expect(result).toMatchObject({ total: 3, nextCursor: 2 });
1581+ expect(result.worktrees).toEqual([
1582+ expect.objectContaining({
1583+ path: firstPath,
1584+ availability: "missing",
1585+ actualBranch: null,
1586+ isRepo: false,
1587+ }),
1588+ ]);
1589+ expect(harness.localStatus).toHaveBeenCalledTimes(1);
1590+ });
1591+ });
1592+
1593+ it.effect("bounds returned bindings while reporting the total", () => {
1594+ const otherOne = ThreadId.make("thread-binding-one");
1595+ const otherTwo = ThreadId.make("thread-binding-two");
1596+ const harness = makeHarness({
1597+ worktrees: [{ path: workspaceRoot, refName: "dev" }],
1598+ projectThreads: [
1599+ { id: threadId, title: "Caller", branch: "dev", worktreePath: null },
1600+ { id: otherOne, title: "Other one", branch: "dev", worktreePath: null },
1601+ { id: otherTwo, title: "Other two", branch: "dev", worktreePath: null },
1602+ ],
1603+ });
1604+ return Effect.gen(function* () {
1605+ const result = yield* runList(harness, { bindingLimit: 1 });
1606+ expect(result.worktrees[0]?.bindingCount).toBe(3);
1607+ expect(result.worktrees[0]?.bindings).toHaveLength(1);
1608+ });
1609+ });
13801610 });
13811611
13821612 describe("t3_thread_checkout", () => {
@@ -1430,6 +1660,101 @@ describe("t3_thread_checkout", () => { · 47 unmodified lines
14301660 });
14311661 });
14321662
1663+ it.effect(
1664+ "asks Git to resolve a remote ref even when a same-named local branch is current",
1665+ () => {
1666+ const harness = makeHarness({
1667+ thread: { branch: "feature", worktreePath: null },
1668+ refs: [
1669+ {
1670+ name: "feature",
1671+ current: true,
1672+ isDefault: false,
1673+ worktreePath: workspaceRoot,
1674+ },
1675+ {
1676+ name: "origin/feature",
1677+ current: false,
1678+ isDefault: false,
1679+ isRemote: true,
1680+ worktreePath: null,
1681+ },
1682+ ],
1683+ workspaceStatuses: { [workspaceRoot]: { branch: "feature" } },
1684+ switchRefResultBranch: "feature",
1685+ });
1686+ return Effect.gen(function* () {
1687+ const result = yield* runCheckout(harness, {
1688+ target: { type: "branch", branch: "origin/feature" },
1689+ });
1690+ expect(result.checkoutAction).toBe("switched");
1691+ expect(result.current.actualBranch).toBe("feature");
1692+ expect(harness.switchRef).toHaveBeenCalledWith({
1693+ cwd: workspaceRoot,
1694+ refName: "origin/feature",
1695+ });
1696+ });
1697+ },
1698+ );
1699+
1700+ it.effect("refuses to bind when Git changes again after resolving the requested ref", () => {
1701+ const harness = makeHarness({
1702+ thread: { branch: "dev", worktreePath: null },
1703+ refs: rootRefs,
1704+ workspaceStatuses: { [workspaceRoot]: { branch: "dev" } },
1705+ switchRefResultBranch: "feature/checkout",
1706+ refChangeAfterSwitch: "feature/intervening",
1707+ });
1708+ return Effect.gen(function* () {
1709+ const exit = yield* Effect.exit(
1710+ runCheckout(harness, {
1711+ target: { type: "branch", branch: "feature/checkout" },
1712+ }),
1713+ );
1714+ expectTypedFailure(exit, {
1715+ _tag: "WorktreeMcpFailure",
1716+ code: "partial_failure",
1717+ partial: {
1718+ actualBranch: "feature/intervening",
1719+ rollback: "not_possible",
1720+ },
1721+ });
1722+ expect(harness.dispatch).not.toHaveBeenCalled();
1723+ expect(harness.switchRef).toHaveBeenCalledTimes(1);
1724+ });
1725+ });
1726+
1727+ for (const [state, option] of [
1728+ ["archived", { threadArchivedOnCall: 3 }],
1729+ ["deleted", { threadDeletedOnCall: 3 }],
1730+ ] as const) {
1731+ it.effect(`rolls Git back when the thread is ${state} before binding`, () => {
1732+ const harness = makeHarness({
1733+ thread: { branch: "dev", worktreePath: null },
1734+ refs: rootRefs,
1735+ workspaceStatuses: { [workspaceRoot]: { branch: "dev" } },
1736+ ...option,
1737+ });
1738+ return Effect.gen(function* () {
1739+ const exit = yield* Effect.exit(
1740+ runCheckout(harness, {
1741+ target: { type: "branch", branch: "feature/checkout" },
1742+ }),
1743+ );
1744+ expectTypedFailure(exit, {
1745+ _tag: "WorktreeMcpFailure",
1746+ code: "checkout_in_progress",
1747+ });
1748+ expect(harness.switchRef).toHaveBeenNthCalledWith(2, {
1749+ cwd: workspaceRoot,
1750+ refName: "dev",
1751+ });
1752+ expect(harness.dispatch).not.toHaveBeenCalled();
1753+ expect(harness.sendToThread).not.toHaveBeenCalled();
1754+ });
1755+ });
1756+ }
1757+
14331758 it.effect("creates and checks out a new branch in the current workspace", () => {
14341759 const harness = makeHarness({
14351760 thread: { branch: "dev", worktreePath: null },
@@ -1566,6 +1891,51 @@ describe("t3_thread_checkout", () => { · 130 unmodified lines
15661891 });
15671892 });
15681893
1894+ it.effect("does not turn a completed new-worktree handoff into a status-read failure", () => {
1895+ const harness = makeHarness({
1896+ thread: { branch: "dev", worktreePath: null },
1897+ workspaceStatuses: { [workspaceRoot]: { branch: "dev" } },
1898+ localStatusFailsOnCall: 3,
1899+ });
1900+ return Effect.gen(function* () {
1901+ const result = yield* runCheckout(harness, {
1902+ target: { type: "new_worktree", branch: "feature/completed-handoff" },
1903+ });
1904+ expect(result.current.actualBranch).toBe("feature/completed-handoff");
1905+ expect(harness.localStatus).toHaveBeenCalledTimes(2);
1906+ });
1907+ });
1908+
1909+ it.effect("reuses a detached worktree without inventing a branch", () => {
1910+ const detachedPath = "/worktrees/project/detached";
1911+ const harness = makeHarness({
1912+ thread: { branch: "dev", worktreePath: null },
1913+ refs: [rootRefs[0]],
1914+ worktrees: [
1915+ { path: workspaceRoot, refName: "dev" },
1916+ { path: detachedPath, refName: null },
1917+ ],
1918+ workspaceStatuses: {
1919+ [workspaceRoot]: { branch: "dev" },
1920+ [detachedPath]: { branch: null },
1921+ },
1922+ });
1923+ return Effect.gen(function* () {
1924+ const result = yield* runCheckout(harness, {
1925+ target: { type: "worktree", path: detachedPath },
1926+ });
1927+ expect(result.current).toMatchObject({
1928+ workspacePath: detachedPath,
1929+ recordedBranch: null,
1930+ recordedWorktreePath: detachedPath,
1931+ actualBranch: null,
1932+ });
1933+ expect(harness.dispatch).toHaveBeenCalledWith(
1934+ expect.objectContaining({ branch: null, worktreePath: detachedPath }),
1935+ );
1936+ });
1937+ });
1938+
15691939 it.effect("rejects dirty files before switching branches", () => {
15701940 const harness = makeHarness({
15711941 thread: { branch: "dev", worktreePath: null },
@@ -1844,6 +2214,44 @@ describe("t3_thread_checkout", () => { · 272 unmodified lines
18442214 });
18452215 });
18462216
2217+ it.effect("rejects a physical worktree bound through another project alias", () => {
2218+ const targetPath = "/worktrees/project/cross-project";
2219+ const otherProjectRoot = "/aliases/other-project";
2220+ const otherProjectId = ProjectId.make("project-other");
2221+ const harness = makeHarness({
2222+ thread: { branch: "dev", worktreePath: null },
2223+ refs: [
2224+ rootRefs[0],
2225+ {
2226+ name: "feature/cross-project",
2227+ current: false,
2228+ isDefault: false,
2229+ worktreePath: targetPath,
2230+ },
2231+ ],
2232+ workspaceStatuses: {
2233+ [workspaceRoot]: { branch: "dev" },
2234+ [targetPath]: { branch: "feature/cross-project" },
2235+ },
2236+ workspaceAliases: { [otherProjectRoot]: targetPath },
2237+ otherProjectThread: {
2238+ projectId: otherProjectId,
2239+ workspaceRoot: otherProjectRoot,
2240+ id: ThreadId.make("thread-other-project-owner"),
2241+ title: "Other project owner",
2242+ branch: "feature/cross-project",
2243+ worktreePath: null,
2244+ },
2245+ });
2246+ return Effect.gen(function* () {
2247+ const exit = yield* Effect.exit(
2248+ runCheckout(harness, { target: { type: "worktree", path: targetPath } }),
2249+ );
2250+ expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "workspace_shared" });
2251+ expect(harness.dispatch).not.toHaveBeenCalled();
2252+ });
2253+ });
2254+
18472255 it.effect("rejects switching the shared project root while another thread is active", () => {
18482256 const harness = makeHarness({
18492257 thread: { branch: "dev", worktreePath: null },
@@ -1912,6 +2320,25 @@ describe("t3_thread_checkout", () => { · 62 unmodified lines
19122320 });
19132321 });
19142322
2323+ it.effect("rechecks the durable binding before changing Git", () => {
2324+ const harness = makeHarness({
2325+ thread: { branch: "dev", worktreePath: null },
2326+ refs: rootRefs,
2327+ workspaceStatuses: { [workspaceRoot]: { branch: "dev" } },
2328+ threadAttachedOnRecheck: true,
2329+ });
2330+ return Effect.gen(function* () {
2331+ const exit = yield* Effect.exit(
2332+ runCheckout(harness, {
2333+ target: { type: "branch", branch: "feature/checkout" },
2334+ }),
2335+ );
2336+ expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "checkout_in_progress" });
2337+ expect(harness.switchRef).not.toHaveBeenCalled();
2338+ expect(harness.dispatch).not.toHaveBeenCalled();
2339+ });
2340+ });
2341+
19152342 it.effect("rolls the git branch back when the durable binding fails", () => {
19162343 const harness = makeHarness({
19172344 thread: { branch: "dev", worktreePath: null },
@@ -1960,7 +2387,7 @@ describe("t3_thread_checkout", () => { · 42 unmodified lines
19602387 refs: rootRefs,
19612388 workspaceStatuses: { [workspaceRoot]: { branch: "dev" } },
19622389 dispatchFails: true,
1963− threadReadFailsOnRecheck: true,
2390+ threadReadFailsAfterDispatch: true,
19642391 });
19652392 return Effect.gen(function* () {
19662393 const exit = yield* Effect.exit(
@@ -1982,6 +2409,32 @@ describe("t3_thread_checkout", () => { · 15 unmodified lines
19822409 });
19832410 });
19842411
2412+ it.effect("does not roll Git back over another actor's durable binding", () => {
2413+ const harness = makeHarness({
2414+ thread: { branch: "dev", worktreePath: null },
2415+ refs: rootRefs,
2416+ workspaceStatuses: { [workspaceRoot]: { branch: "dev" } },
2417+ dispatchFails: true,
2418+ threadAfterFailedDispatch: { branch: "feature/other-actor", worktreePath: null },
2419+ });
2420+ return Effect.gen(function* () {
2421+ const exit = yield* Effect.exit(
2422+ runCheckout(harness, {
2423+ target: { type: "branch", branch: "feature/checkout" },
2424+ }),
2425+ );
2426+ expectTypedFailure(exit, {
2427+ _tag: "WorktreeMcpFailure",
2428+ code: "partial_failure",
2429+ partial: {
2430+ recordedBranch: "feature/other-actor",
2431+ rollback: "not_possible",
2432+ },
2433+ });
2434+ expect(harness.switchRef).toHaveBeenCalledTimes(1);
2435+ });
2436+ });
2437+
19852438 it.effect("rolls back when checkout reports failure after changing the branch", () => {
19862439 const harness = makeHarness({
19872440 thread: { branch: "dev", worktreePath: null },
@@ -2009,7 +2462,7 @@ describe("t3_thread_checkout", () => { · 21 unmodified lines
20092462 thread: { branch: "dev", worktreePath: null },
20102463 refs: rootRefs,
20112464 workspaceStatuses: { [workspaceRoot]: { branch: "dev" } },
2012− localStatusFailsOnCall: 2,
2465+ localStatusFailsOnCall: 3,
20132466 });
20142467 return Effect.gen(function* () {
20152468 const exit = yield* Effect.exit(
@@ -2091,6 +2544,59 @@ describe("t3_thread_checkout", () => { · 75 unmodified lines
20912544 yield* Fiber.join(first);
20922545 }),
20932546 );
2547+
2548+ it.effect("serializes two threads targeting alias paths for the same physical worktree", () =>
2549+ Effect.gen(function* () {
2550+ const targetPath = "/worktrees/project/shared-target";
2551+ const aliasPath = "/aliases/shared-target";
2552+ const otherThreadId = ThreadId.make("thread-worktree-concurrent");
2553+ const dispatchGate = yield* Deferred.make<void>();
2554+ const dispatchStarted = yield* Deferred.make<void>();
2555+ const harness = makeHarness({
2556+ thread: { branch: "dev", worktreePath: null },
2557+ refs: [
2558+ rootRefs[0],
2559+ {
2560+ name: "feature/shared-target",
2561+ current: false,
2562+ isDefault: false,
2563+ worktreePath: targetPath,
2564+ },
2565+ ],
2566+ workspaceStatuses: {
2567+ [workspaceRoot]: { branch: "dev" },
2568+ [targetPath]: { branch: "feature/shared-target" },
2569+ },
2570+ workspaceAliases: { [aliasPath]: targetPath },
2571+ projectThreads: [
2572+ { id: threadId, title: "Caller", branch: "dev", worktreePath: null },
2573+ { id: otherThreadId, title: "Other", branch: "dev", worktreePath: null },
2574+ ],
2575+ dispatchGate: Deferred.succeed(dispatchStarted, undefined).pipe(
2576+ Effect.andThen(Deferred.await(dispatchGate)),
2577+ ),
2578+ });
2579+ const service = yield* resolveService(harness);
2580+ const first = yield* Effect.forkChild(
2581+ service.checkout(harness.scope, {
2582+ target: { type: "worktree", path: aliasPath },
2583+ }),
2584+ );
2585+ yield* Deferred.await(dispatchStarted);
2586+ const second = yield* Effect.exit(
2587+ service.checkout(
2588+ { ...harness.scope, threadId: otherThreadId },
2589+ { target: { type: "worktree", path: targetPath } },
2590+ ),
2591+ );
2592+ expectTypedFailure(second, {
2593+ _tag: "WorktreeMcpFailure",
2594+ code: "checkout_in_progress",
2595+ });
2596+ yield* Deferred.succeed(dispatchGate, undefined);
2597+ yield* Fiber.join(first);
2598+ }),
2599+ );
20942600 });
20952601
20962602 describe("WorktreeMcpHandoffInput schema", () => {
apps/server/src/mcp/WorktreeMcpService.ts+572−309
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
1111 type WorktreeMcpContinuationStatus,
1212 type WorktreeMcpHandoffInput,
1313 type WorktreeMcpHandoffResult,
14+ type WorktreeMcpListInput,
1415 type WorktreeMcpListResult,
1516 type WorktreeMcpSetupScriptStatus,
1617 type WorktreeMcpStatusResult,
@@ -21,6 +22,7 @@ import * as Crypto from "effect/Crypto"; · 4 unmodified lines
2122 import * as Effect from "effect/Effect";
2223 import * as FileSystem from "effect/FileSystem";
2324 import * as Exit from "effect/Exit";
25+import * as FileSystem from "effect/FileSystem";
2426 import * as Layer from "effect/Layer";
2527 import * as Option from "effect/Option";
2628 import * as Path from "effect/Path";
@@ -45,6 +47,7 @@ export class WorktreeMcpService extends Context.Service< · 18 unmodified lines
4547 ) => Effect.Effect<WorktreeMcpStatusResult, WorktreeMcpFailure>;
4648 readonly listWorktrees: (
4749 scope: McpInvocationScope,
50+ input: WorktreeMcpListInput,
4851 ) => Effect.Effect<WorktreeMcpListResult, WorktreeMcpFailure>;
4952 readonly checkout: (
5053 scope: McpInvocationScope,
@@ -76,6 +79,7 @@ const asOperationFailed = (prefix: string) => · 25 unmodified lines
7679
7780 const make = Effect.gen(function* () {
7881 const crypto = yield* Crypto.Crypto;
82+ const fileSystem = yield* FileSystem.FileSystem;
7983 const path = yield* Path.Path;
8084 const threadManagement = yield* ThreadManagementService;
8185 const projects = yield* ProjectService.ProjectService;
@@ -135,10 +139,17 @@ const make = Effect.gen(function* () { · 53 unmodified lines
135139
136140 const normalizePath = (value: string) => path.normalize(path.resolve(value));
137141
138− const threadWorkspacePath = (
142+ const canonicalizePath = (value: string) => {
143+ const normalized = normalizePath(value);
144+ return fileSystem.realPath(normalized).pipe(Effect.orElseSucceed(() => normalized));
145+ };
146+
147+ const threadWorkspacePath = Effect.fn("WorktreeMcpService.threadWorkspacePath")(function* (
139148 thread: Pick<OrchestrationV2ThreadShell, "worktreePath">,
140149 projectWorkspaceRoot: string,
141− ) => normalizePath(thread.worktreePath ?? projectWorkspaceRoot);
150+ ) {
151+ return yield* canonicalizePath(thread.worktreePath ?? projectWorkspaceRoot);
152+ });
142153
143154 const loadRefs = Effect.fn("WorktreeMcpService.loadRefs")(function* (
144155 projectWorkspaceRoot: string,
@@ -146,14 +157,13 @@ const make = Effect.gen(function* () { · 1 unmodified line
146157 ) {
147158 const refs: Array<VcsRef> = [];
148159 let cursor: number | undefined;
149− let firstPage = true;
150160 do {
151161 const page = yield* gitWorkflow
152162 .listRefs({
153163 cwd: projectWorkspaceRoot,
154164 refKind,
155165 includeMatchingRemoteRefs: refKind === "all",
156− refresh: firstPage,
166+ refresh: cursor === undefined,
157167 limit: 200,
158168 ...(cursor === undefined ? {} : { cursor }),
159169 })
@@ -166,11 +176,18 @@ const make = Effect.gen(function* () { · 6 unmodified lines
166176 }
167177 refs.push(...page.refs);
168178 cursor = page.nextCursor ?? undefined;
169− firstPage = false;
170179 } while (cursor !== undefined);
171180 return refs;
172181 });
173182
183+ const loadWorktrees = Effect.fn("WorktreeMcpService.loadWorktrees")(function* (
184+ projectWorkspaceRoot: string,
185+ ) {
186+ return yield* gitWorkflow
187+ .listWorktrees(projectWorkspaceRoot)
188+ .pipe(asOperationFailed("Unable to list project worktrees"));
189+ });
190+
174191 const loadProjectThreads = (
175192 projectId: ProjectId,
176193 ): Effect.Effect<ReadonlyArray<OrchestrationV2ThreadShell>, WorktreeMcpFailure> =>
@@ -178,6 +195,61 @@ const make = Effect.gen(function* () { · 1 unmodified line
178195 .listProjectThreads({ projectId, includeSubagents: true })
179196 .pipe(asOperationFailed(`Unable to list threads in project ${projectId}`));
180197
198+ const loadActiveWorkspaceBindings = Effect.fn("WorktreeMcpService.loadActiveWorkspaceBindings")(
199+ function* (repositoryCommonDir: string) {
200+ const snapshot = yield* threadManagement
201+ .getShellSnapshot({ location: "active" })
202+ .pipe(asOperationFailed("Unable to inspect active thread workspace bindings"));
203+ const byProject = new Map<ProjectId, Array<OrchestrationV2ThreadShell>>();
204+ for (const thread of snapshot.threads) {
205+ const projectThreads = byProject.get(thread.projectId) ?? [];
206+ projectThreads.push(thread);
207+ byProject.set(thread.projectId, projectThreads);
208+ }
209+
210+ return yield* Effect.forEach(
211+ [...byProject.entries()],
212+ ([projectId, projectThreads]) =>
213+ projects.getById(projectId).pipe(
214+ asOperationFailed(
215+ `Unable to read project ${projectId} while checking workspace owners`,
216+ ),
217+ Effect.flatMap(
218+ Option.match({
219+ onNone: () => Effect.succeed([]),
220+ onSome: (project) =>
221+ loadWorktrees(project.workspaceRoot).pipe(
222+ Effect.flatMap((projectInventory) => {
223+ if (projectInventory.repositoryCommonDir !== repositoryCommonDir) {
224+ return Effect.succeed([]);
225+ }
226+ return Effect.forEach(projectThreads, (thread) => {
227+ if (thread.worktreePath === null) {
228+ return Effect.succeed(
229+ projectInventory.currentWorktreeRoot === null
230+ ? []
231+ : [[thread, projectInventory.currentWorktreeRoot] as const],
232+ );
233+ }
234+ return loadWorktrees(thread.worktreePath).pipe(
235+ Effect.map((threadInventory) =>
236+ threadInventory.repositoryCommonDir === repositoryCommonDir &&
237+ threadInventory.currentWorktreeRoot !== null
238+ ? [[thread, threadInventory.currentWorktreeRoot] as const]
239+ : [],
240+ ),
241+ );
242+ }).pipe(Effect.map((bindings) => bindings.flat()));
243+ }),
244+ ),
245+ }),
246+ ),
247+ ),
248+ { concurrency: 4 },
249+ ).pipe(Effect.map((bindings) => bindings.flat()));
250+ },
251+ );
252+
181253 const readWorkspaceStatus = (workspacePath: string) =>
182254 gitWorkflow
183255 .invalidateLocalStatus(workspacePath)
@@ -265,17 +337,13 @@ const make = Effect.gen(function* () { · 81 unmodified lines
265337 }
266338
267339 const project = yield* loadProject(scope, projection.thread.projectId);
268− const projectCwd = project.workspaceRoot;
269− const sourceCwd = projection.thread.worktreePath ?? projectCwd;
340+ const projectCwd = yield* canonicalizePath(project.workspaceRoot);
341+ const sourceCwd = yield* canonicalizePath(projection.thread.worktreePath ?? projectCwd);
270342
271343 if (projection.thread.worktreePath !== null) {
272− const projectRefs = yield* loadRefs(projectCwd, "local");
273− const projectWorktreePaths = new Set(
274− projectRefs.flatMap((ref) =>
275− ref.worktreePath === null ? [] : [normalizePath(ref.worktreePath)],
276− ),
277− );
278− if (!projectWorktreePaths.has(normalizePath(projection.thread.worktreePath))) {
344+ const inventory = yield* loadWorktrees(projectCwd);
345+ const projectWorktreePaths = new Set(inventory.worktrees.map((worktree) => worktree.path));
346+ if (!projectWorktreePaths.has(sourceCwd)) {
279347 return yield* failure(
280348 "scope_mismatch",
281349 `Thread worktree '${projection.thread.worktreePath}' is not registered in project '${projection.thread.projectId}'.`,
@@ -631,31 +699,30 @@ const make = Effect.gen(function* () { · 349 unmodified lines
631699 yield* requireCapability(scope);
632700 const projection = yield* loadThread(scope);
633701 const project = yield* loadProject(scope, projection.thread.projectId);
634− const projectWorkspaceRoot = normalizePath(project.workspaceRoot);
702+ const projectWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot);
635703 const workspacePath = normalizePath(projection.thread.worktreePath ?? projectWorkspaceRoot);
636− const [defaultStartFromOrigin, actual, refs] = yield* Effect.all(
637− [
638− readDefaultStartFromOrigin,
639− readWorkspaceStatus(workspacePath),
640− loadRefs(projectWorkspaceRoot, "local"),
641− ],
642− { concurrency: 3 },
643− );
644− const knownWorkspacePaths = new Set([
645− projectWorkspaceRoot,
646− ...refs.flatMap((ref) =>
647− ref.isRemote === true || ref.worktreePath === null
648− ? []
649− : [normalizePath(ref.worktreePath)],
650− ),
651− ]);
652− const agreement = !knownWorkspacePaths.has(workspacePath)
653− ? "workspace_missing"
654− : !actual.isRepo
655− ? "not_repository"
656− : actual.refName !== projection.thread.branch
657− ? "branch_mismatch"
658− : "in_sync";
704+ const [defaultStartFromOrigin, actual, projectInventory, workspaceInventory] =
705+ yield* Effect.all(
706+ [
707+ readDefaultStartFromOrigin,
708+ readWorkspaceStatus(workspacePath),
709+ loadWorktrees(projectWorkspaceRoot),
710+ loadWorktrees(workspacePath),
711+ ],
712+ { concurrency: 4 },
713+ );
714+ const canonicalWorkspacePath = yield* canonicalizePath(workspacePath);
715+ const physicalWorkspacePath = workspaceInventory.currentWorktreeRoot;
716+ const agreement =
717+ workspaceInventory.repositoryCommonDir !== projectInventory.repositoryCommonDir ||
718+ physicalWorkspacePath === null ||
719+ !projectInventory.worktrees.some((worktree) => worktree.path === physicalWorkspacePath)
720+ ? "workspace_missing"
721+ : !actual.isRepo
722+ ? "not_repository"
723+ : actual.refName !== projection.thread.branch
724+ ? "branch_mismatch"
725+ : "in_sync";
659726
660727 const result: WorktreeMcpStatusResult = {
661728 attached: projection.thread.worktreePath !== null,
@@ -668,7 +735,7 @@ const make = Effect.gen(function* () { · 6 unmodified lines
668735 worktreePath: projection.thread.worktreePath,
669736 },
670737 actualWorkspace: {
671− workspacePath,
738+ workspacePath: physicalWorkspacePath ?? canonicalWorkspacePath,
672739 isRepo: actual.isRepo,
673740 branch: actual.refName,
674741 hasWorkingTreeChanges: actual.hasWorkingTreeChanges,
@@ -879,12 +946,12 @@ const make = Effect.gen(function* () { · 204 unmodified lines
879946 }
880947
881948 const project = yield* loadProject(scope, projection.thread.projectId);
882− const projectWorkspaceRoot = normalizePath(project.workspaceRoot);
883− const currentWorkspacePath = normalizePath(
949+ const projectWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot);
950+ const recordedWorkspacePath = yield* canonicalizePath(
884951 projection.thread.worktreePath ?? projectWorkspaceRoot,
885952 );
886953 if (input.target.type === "new_worktree") {
887− const previousActual = yield* readWorkspaceStatus(currentWorkspacePath);
954+ const previousActual = yield* readWorkspaceStatus(recordedWorkspacePath);
888955 const handoff = yield* performHandoff(
889956 scope,
890957 {
@@ -903,10 +970,9 @@ const make = Effect.gen(function* () { · 12 unmodified lines
903970 },
904971 projection,
905972 );
906− const actual = yield* readWorkspaceStatus(handoff.worktreePath);
907973 return {
908974 previous: {
909− workspacePath: currentWorkspacePath,
975+ workspacePath: recordedWorkspacePath,
910976 recordedBranch: projection.thread.branch,
911977 recordedWorktreePath: projection.thread.worktreePath,
912978 actualBranch: previousActual.refName,
@@ -915,17 +981,35 @@ const make = Effect.gen(function* () { · 2 unmodified lines
915981 workspacePath: handoff.worktreePath,
916982 recordedBranch: handoff.branch,
917983 recordedWorktreePath: handoff.worktreePath,
918− actualBranch: actual.refName,
984+ actualBranch: handoff.branch,
919985 },
920986 checkoutAction: "created",
921987 workspaceChanged: true,
922− branchChanged: previousActual.refName !== actual.refName,
988+ branchChanged: previousActual.refName !== handoff.branch,
923989 continuation: handoff.continuation,
924990 setupScript: handoff.setupScript,
925991 callerTurnEnds: true,
926992 note: handoff.note,
927993 } satisfies WorktreeMcpCheckoutResult;
928994 }
995+ const [inventory, currentInventory] = yield* Effect.all(
996+ [loadWorktrees(projectWorkspaceRoot), loadWorktrees(recordedWorkspacePath)],
997+ { concurrency: 2 },
998+ );
999+ if (inventory.repositoryCommonDir !== currentInventory.repositoryCommonDir) {
1000+ return yield* failure(
1001+ "scope_mismatch",
1002+ `Thread workspace '${recordedWorkspacePath}' does not belong to the calling thread's project repository.`,
1003+ );
1004+ }
1005+ const projectWorktreeRoot = inventory.currentWorktreeRoot;
1006+ const currentWorkspacePath = currentInventory.currentWorktreeRoot;
1007+ if (projectWorktreeRoot === null || currentWorkspacePath === null) {
1008+ return yield* failure(
1009+ "invalid_request",
1010+ "Git could not resolve the physical project or thread checkout.",
1011+ );
1012+ }
9291013 const [refs, threads, previousActual] = yield* Effect.all(
9301014 [
9311015 loadRefs(projectWorkspaceRoot),
@@ -936,12 +1020,7 @@ const make = Effect.gen(function* () { · 4 unmodified lines
9361020 );
9371021
9381022 const localRefs = refs.filter((ref) => ref.isRemote !== true);
939− const workspacePaths = new Set([
940− projectWorkspaceRoot,
941− ...localRefs.flatMap((ref) =>
942− ref.worktreePath === null ? [] : [normalizePath(ref.worktreePath)],
943− ),
944− ]);
1023+ const workspacePaths = new Set(inventory.worktrees.map((worktree) => worktree.path));
9451024 const localRefByName = new Map(localRefs.map((ref) => [ref.name, ref]));
9461025 const remoteRefByName = new Map(
9471026 refs.filter((ref) => ref.isRemote === true).map((ref) => [ref.name, ref]),
@@ -954,14 +1033,17 @@ const make = Effect.gen(function* () { · 6 unmodified lines
9541033
9551034 switch (input.target.type) {
9561035 case "worktree": {
957− targetWorkspacePath = normalizePath(input.target.path);
1036+ const targetInventory = yield* loadWorktrees(input.target.path);
1037+ targetWorkspacePath =
1038+ targetInventory.currentWorktreeRoot ?? (yield* canonicalizePath(input.target.path));
9581039 if (
959− targetWorkspacePath === projectWorkspaceRoot ||
1040+ targetInventory.repositoryCommonDir !== inventory.repositoryCommonDir ||
1041+ targetWorkspacePath === projectWorktreeRoot ||
9601042 !workspacePaths.has(targetWorkspacePath)
9611043 ) {
9621044 return yield* failure(
9631045 "scope_mismatch",
964− targetWorkspacePath === projectWorkspaceRoot
1046+ targetWorkspacePath === projectWorktreeRoot
9651047 ? "Use target.type='project_root' to return to the project's main checkout."
9661048 : `Worktree '${input.target.path}' does not belong to project '${projection.thread.projectId}'. Call t3_worktree_list and choose one of its paths.`,
9671049 );
@@ -969,7 +1051,7 @@ const make = Effect.gen(function* () { · 1 unmodified line
9691051 break;
9701052 }
9711053 case "project_root": {
972− targetWorkspacePath = projectWorkspaceRoot;
1054+ targetWorkspacePath = projectWorktreeRoot;
9731055 requestedBranch = input.target.branch;
9741056 createBranch = input.target.create ?? false;
9751057 if (createBranch && requestedBranch === undefined) {
@@ -1000,15 +1082,15 @@ const make = Effect.gen(function* () { · 24 unmodified lines
10001082 const selectedWorktreePath =
10011083 selectedRef?.isRemote === true || selectedRef?.worktreePath == null
10021084 ? null
1003− : normalizePath(selectedRef.worktreePath);
1085+ : selectedRef.worktreePath;
10041086 targetWorkspacePath =
10051087 workspace === "project_root"
1006− ? projectWorkspaceRoot
1088+ ? projectWorktreeRoot
10071089 : workspace === "current"
10081090 ? currentWorkspacePath
10091091 : (selectedWorktreePath ??
10101092 (projection.thread.worktreePath !== null && selectedRef?.isDefault === true
1011− ? projectWorkspaceRoot
1093+ ? projectWorktreeRoot
10121094 : currentWorkspacePath));
10131095 break;
10141096 }
@@ -1043,7 +1125,7 @@ const make = Effect.gen(function* () { · 28 unmodified lines
10431125 const selectedWorktreePath =
10441126 selectedRef?.isRemote === true || selectedRef?.worktreePath == null
10451127 ? null
1046− : normalizePath(selectedRef.worktreePath);
1128+ : selectedRef.worktreePath;
10471129 if (
10481130 !createBranch &&
10491131 requestedBranch !== undefined &&
@@ -1058,16 +1140,18 @@ const make = Effect.gen(function* () { · 8 unmodified lines
10581140
10591141 const shouldMutateCheckout =
10601142 requestedBranch !== undefined &&
1061− (createBranch ||
1062− (selectedRef?.isRemote === true
1063− ? targetBefore.refName !== requestedBranch &&
1064− targetBefore.refName !== requestedBranch.replace(/^[^/]+\//, "")
1065− : targetBefore.refName !== requestedBranch));
1066− const otherBindings = threads.filter(
1067− (thread) =>
1068− thread.id !== scope.threadId &&
1069− threadWorkspacePath(thread, projectWorkspaceRoot) === targetWorkspacePath,
1143+ (createBranch || selectedRef?.isRemote === true || targetBefore.refName !== requestedBranch);
1144+ const threadWorkspaces = yield* Effect.forEach(threads, (thread) =>
1145+ threadWorkspacePath(thread, projectWorktreeRoot).pipe(
1146+ Effect.map((workspacePath) => [thread, workspacePath] as const),
1147+ ),
10701148 );
1149+ const otherBindings = threadWorkspaces
1150+ .filter(
1151+ ([thread, workspacePath]) =>
1152+ thread.id !== scope.threadId && workspacePath === targetWorkspacePath,
1153+ )
1154+ .map(([thread]) => thread);
10711155 const activeBinding = otherBindings.find((thread) => thread.activeRunId !== null);
10721156 if ((targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) && activeBinding) {
10731157 return yield* failure(
@@ -1076,7 +1160,7 @@ const make = Effect.gen(function* () { · 2 unmodified lines
10761160 );
10771161 }
10781162 if (
1079− targetWorkspacePath !== projectWorkspaceRoot &&
1163+ targetWorkspacePath !== projectWorktreeRoot &&
10801164 (targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) &&
10811165 otherBindings.length > 0
10821166 ) {
@@ -1086,7 +1170,7 @@ const make = Effect.gen(function* () { · 3 unmodified lines
10861170 );
10871171 }
10881172 if (
1089− targetWorkspacePath === projectWorkspaceRoot &&
1173+ targetWorkspacePath === projectWorktreeRoot &&
10901174 shouldMutateCheckout &&
10911175 otherBindings.length > 0
10921176 ) {
@@ -1103,285 +1187,463 @@ const make = Effect.gen(function* () { · 10 unmodified lines
11031187 }
11041188
11051189 const ids = yield* transitionIds(scope, "checkout");
1190+ const workspaceGuardKey = `workspace:${inventory.repositoryCommonDir}:${targetWorkspacePath}`;
11061191 return yield* Effect.uninterruptibleMask(() =>
1107− Effect.gen(function* () {
1108− let checkoutAction: WorktreeMcpCheckoutResult["checkoutAction"] =
1109− targetWorkspacePath === currentWorkspacePath ? "unchanged" : "reused";
1110− let createdBranch: string | null = null;
1111−
1112− if (shouldMutateCheckout && requestedBranch !== undefined) {
1113− if (createBranch) {
1114− yield* gitWorkflow
1115− .createRef({
1116− cwd: targetWorkspacePath,
1117− refName: requestedBranch,
1118− switchRef: false,
1119− })
1120− .pipe(asOperationFailed(`Unable to create branch '${requestedBranch}'`));
1121− createdBranch = requestedBranch;
1192+ Effect.suspend(() => {
1193+ if (workspaceTransitionsInFlight.has(workspaceGuardKey)) {
1194+ return Effect.fail(
1195+ failure(
1196+ "checkout_in_progress",
1197+ `Another workspace transition is already in progress for '${targetWorkspacePath}'.`,
1198+ ),
1199+ );
1200+ }
1201+ workspaceTransitionsInFlight.add(workspaceGuardKey);
1202+ return Effect.gen(function* () {
1203+ const [latestProjection, latestInventory, latestBindings, latestTargetBefore] =
1204+ yield* Effect.all(
1205+ [
1206+ loadThread(scope),
1207+ loadWorktrees(projectWorkspaceRoot),
1208+ loadActiveWorkspaceBindings(inventory.repositoryCommonDir),
1209+ readWorkspaceStatus(targetWorkspacePath),
1210+ ],
1211+ { concurrency: 4 },
1212+ );
1213+ if (
1214+ latestProjection.thread.branch !== projection.thread.branch ||
1215+ latestProjection.thread.worktreePath !== projection.thread.worktreePath ||
1216+ latestProjection.thread.archivedAt !== projection.thread.archivedAt
1217+ ) {
1218+ return yield* failure(
1219+ "checkout_in_progress",
1220+ `Thread '${scope.threadId}' changed workspace state while checkout was being prepared. Retry from its current binding.`,
1221+ );
1222+ }
1223+ if (
1224+ latestInventory.repositoryCommonDir !== inventory.repositoryCommonDir ||
1225+ !latestInventory.worktrees.some((worktree) => worktree.path === targetWorkspacePath)
1226+ ) {
1227+ return yield* failure(
1228+ "scope_mismatch",
1229+ `Checkout target '${targetWorkspacePath}' is no longer registered in the calling thread's Git repository.`,
1230+ );
1231+ }
1232+ if (
1233+ latestTargetBefore.refName !== targetBefore.refName ||
1234+ latestTargetBefore.hasWorkingTreeChanges !== targetBefore.hasWorkingTreeChanges
1235+ ) {
1236+ return yield* failure(
1237+ "checkout_in_progress",
1238+ `Checkout '${targetWorkspacePath}' changed while the transition was being prepared. Retry from its current Git state.`,
1239+ );
1240+ }
1241+ if (shouldMutateCheckout && latestTargetBefore.hasWorkingTreeChanges) {
1242+ return yield* failure(
1243+ "dirty_workspace",
1244+ `Checkout '${targetWorkspacePath}' has uncommitted files. Commit or discard them before switching branches.`,
1245+ );
11221246 }
1123− const switchExit = yield* Effect.exit(
1124− gitWorkflow.switchRef({ cwd: targetWorkspacePath, refName: requestedBranch }),
1247+ const latestOtherBindings = latestBindings
1248+ .filter(
1249+ ([thread, workspacePath]) =>
1250+ thread.id !== scope.threadId && workspacePath === targetWorkspacePath,
1251+ )
1252+ .map(([thread]) => thread);
1253+ const latestActiveBinding = latestOtherBindings.find(
1254+ (thread) => thread.activeRunId !== null,
11251255 );
1126− if (Exit.isFailure(switchExit)) {
1127− const afterFailedSwitchExit = yield* Effect.exit(
1128− readWorkspaceStatus(targetWorkspacePath),
1256+ if (
1257+ (targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) &&
1258+ latestActiveBinding
1259+ ) {
1260+ return yield* failure(
1261+ "workspace_in_use",
1262+ `Checkout '${targetWorkspacePath}' is in use by active thread '${latestActiveBinding.id}' (${latestActiveBinding.title}).`,
1263+ );
1264+ }
1265+ if (
1266+ targetWorkspacePath !== projectWorktreeRoot &&
1267+ (targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) &&
1268+ latestOtherBindings.length > 0
1269+ ) {
1270+ return yield* failure(
1271+ "workspace_shared",
1272+ `Worktree '${targetWorkspacePath}' is already bound to thread '${latestOtherBindings[0]!.id}'. Reusing it would make two threads share one mutable checkout.`,
1273+ );
1274+ }
1275+ if (
1276+ targetWorkspacePath === projectWorktreeRoot &&
1277+ shouldMutateCheckout &&
1278+ latestOtherBindings.length > 0
1279+ ) {
1280+ return yield* failure(
1281+ "workspace_shared",
1282+ `The project root is also bound to thread '${latestOtherBindings[0]!.id}'. Switching its branch would make that thread's recorded branch disagree with Git.`,
1283+ );
1284+ }
1285+
1286+ let checkoutAction: WorktreeMcpCheckoutResult["checkoutAction"] =
1287+ targetWorkspacePath === currentWorkspacePath ? "unchanged" : "reused";
1288+ let createdBranch: string | null = null;
1289+ let resolvedBranch: string | null = targetBefore.refName;
1290+
1291+ if (shouldMutateCheckout && requestedBranch !== undefined) {
1292+ if (createBranch) {
1293+ yield* gitWorkflow
1294+ .createRef({
1295+ cwd: targetWorkspacePath,
1296+ refName: requestedBranch,
1297+ switchRef: false,
1298+ })
1299+ .pipe(asOperationFailed(`Unable to create branch '${requestedBranch}'`));
1300+ createdBranch = requestedBranch;
1301+ }
1302+ const switchExit = yield* Effect.exit(
1303+ gitWorkflow.switchRef({ cwd: targetWorkspacePath, refName: requestedBranch }),
11291304 );
1130− if (Exit.isFailure(afterFailedSwitchExit)) {
1305+ if (Exit.isFailure(switchExit)) {
1306+ const afterFailedSwitchExit = yield* Effect.exit(
1307+ readWorkspaceStatus(targetWorkspacePath),
1308+ );
1309+ if (Exit.isFailure(afterFailedSwitchExit)) {
1310+ return yield* failure(
1311+ "partial_failure",
1312+ `Branch checkout failed and the resulting Git state could not be verified: ${errorMessage(Cause.squash(switchExit.cause))}`,
1313+ {
1314+ workspacePath: targetWorkspacePath,
1315+ recordedBranch: projection.thread.branch,
1316+ actualBranch: null,
1317+ rollback: "not_possible",
1318+ },
1319+ );
1320+ }
1321+ const afterFailedSwitch = afterFailedSwitchExit.value;
1322+ const checkoutChanged = afterFailedSwitch.refName !== targetBefore.refName;
1323+ if (checkoutChanged && targetBefore.refName === null) {
1324+ return yield* failure(
1325+ "partial_failure",
1326+ `Branch checkout failed after changing Git state and the previous detached ref cannot be restored automatically: ${errorMessage(Cause.squash(switchExit.cause))}`,
1327+ {
1328+ workspacePath: targetWorkspacePath,
1329+ recordedBranch: projection.thread.branch,
1330+ actualBranch: afterFailedSwitch.refName,
1331+ rollback: "not_possible",
1332+ },
1333+ );
1334+ }
1335+ const rollback = checkoutChanged
1336+ ? gitWorkflow.switchRef({
1337+ cwd: targetWorkspacePath,
1338+ refName: targetBefore.refName!,
1339+ })
1340+ : Effect.void;
1341+ const cleanup = rollback.pipe(
1342+ Effect.andThen(
1343+ createdBranch === null
1344+ ? Effect.void
1345+ : gitWorkflow.deleteLocalBranch({
1346+ cwd: targetWorkspacePath,
1347+ refName: createdBranch,
1348+ force: true,
1349+ }),
1350+ ),
1351+ );
1352+ const cleanupExit = yield* Effect.exit(cleanup);
1353+ if (Exit.isFailure(cleanupExit)) {
1354+ const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath);
1355+ return yield* failure(
1356+ "partial_failure",
1357+ `Branch checkout failed and rollback also failed: ${errorMessage(Cause.squash(switchExit.cause))}`,
1358+ {
1359+ workspacePath: targetWorkspacePath,
1360+ recordedBranch: projection.thread.branch,
1361+ actualBranch,
1362+ rollback: "failed",
1363+ },
1364+ );
1365+ }
11311366 return yield* failure(
1132− "partial_failure",
1133− `Branch checkout failed and the resulting Git state could not be verified: ${errorMessage(Cause.squash(switchExit.cause))}`,
1134− {
1135− workspacePath: targetWorkspacePath,
1136− recordedBranch: projection.thread.branch,
1137− actualBranch: null,
1138− rollback: "not_possible",
1139− },
1367+ "operation_failed",
1368+ `Unable to check out '${requestedBranch}': ${errorMessage(Cause.squash(switchExit.cause))}`,
11401369 );
11411370 }
1142− const afterFailedSwitch = afterFailedSwitchExit.value;
1143− const checkoutChanged = afterFailedSwitch.refName !== targetBefore.refName;
1144− if (checkoutChanged && targetBefore.refName === null) {
1371+ resolvedBranch = switchExit.value.refName;
1372+ if (resolvedBranch === null) {
11451373 return yield* failure(
11461374 "partial_failure",
1147− `Branch checkout failed after changing Git state and the previous detached ref cannot be restored automatically: ${errorMessage(Cause.squash(switchExit.cause))}`,
1375+ `Git reported a detached checkout after selecting '${requestedBranch}'. The durable thread binding was not changed.`,
11481376 {
11491377 workspacePath: targetWorkspacePath,
11501378 recordedBranch: projection.thread.branch,
1151− actualBranch: afterFailedSwitch.refName,
1379+ actualBranch: null,
11521380 rollback: "not_possible",
11531381 },
11541382 );
11551383 }
1156− const rollback = checkoutChanged
1157− ? gitWorkflow.switchRef({
1158− cwd: targetWorkspacePath,
1159− refName: targetBefore.refName!,
1160− })
1161− : Effect.void;
1162− const cleanup = rollback.pipe(
1163− Effect.andThen(
1164− createdBranch === null
1165− ? Effect.void
1166− : gitWorkflow.deleteLocalBranch({
1167− cwd: targetWorkspacePath,
1168− refName: createdBranch,
1169− force: true,
1170− }),
1171− ),
1172− );
1173− const cleanupExit = yield* Effect.exit(cleanup);
1174− if (Exit.isFailure(cleanupExit)) {
1175− const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath);
1176− return yield* failure(
1177− "partial_failure",
1178− `Branch checkout failed and rollback also failed: ${errorMessage(Cause.squash(switchExit.cause))}`,
1179− {
1180− workspacePath: targetWorkspacePath,
1181− recordedBranch: projection.thread.branch,
1182− actualBranch,
1183− rollback: "failed",
1184− },
1384+ checkoutAction = createBranch ? "created" : "switched";
1385+ }
1386+
1387+ const actualExit = yield* Effect.exit(readWorkspaceStatus(targetWorkspacePath));
1388+ if (Exit.isFailure(actualExit)) {
1389+ const detail = errorMessage(Cause.squash(actualExit.cause));
1390+ if (checkoutAction === "switched" || checkoutAction === "created") {
1391+ if (targetBefore.refName === null) {
1392+ return yield* failure(
1393+ "partial_failure",
1394+ `Git checkout completed but its resulting state could not be verified: ${detail}`,
1395+ {
1396+ workspacePath: targetWorkspacePath,
1397+ recordedBranch: projection.thread.branch,
1398+ actualBranch: null,
1399+ rollback: "not_possible",
1400+ },
1401+ );
1402+ }
1403+ const rollbackExit = yield* Effect.exit(
1404+ gitWorkflow
1405+ .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName })
1406+ .pipe(
1407+ Effect.andThen(
1408+ createdBranch === null
1409+ ? Effect.void
1410+ : gitWorkflow.deleteLocalBranch({
1411+ cwd: targetWorkspacePath,
1412+ refName: createdBranch,
1413+ force: true,
1414+ }),
1415+ ),
1416+ ),
11851417 );
1418+ if (Exit.isFailure(rollbackExit)) {
1419+ const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath);
1420+ return yield* failure(
1421+ "partial_failure",
1422+ `Git checkout completed, verification failed, and rollback also failed: ${detail}`,
1423+ {
1424+ workspacePath: targetWorkspacePath,
1425+ recordedBranch: projection.thread.branch,
1426+ actualBranch,
1427+ rollback: "failed",
1428+ },
1429+ );
1430+ }
11861431 }
11871432 return yield* failure(
11881433 "operation_failed",
1189− `Unable to check out '${requestedBranch}': ${errorMessage(Cause.squash(switchExit.cause))}`,
1434+ `Unable to verify the selected checkout '${targetWorkspacePath}': ${detail}`,
11901435 );
11911436 }
1192− checkoutAction = createBranch ? "created" : "switched";
1193− }
1194−
1195− const actualExit = yield* Effect.exit(readWorkspaceStatus(targetWorkspacePath));
1196− if (Exit.isFailure(actualExit)) {
1197− const detail = errorMessage(Cause.squash(actualExit.cause));
1198− if (checkoutAction === "switched" || checkoutAction === "created") {
1199− if (targetBefore.refName === null) {
1200− return yield* failure(
1201− "partial_failure",
1202− `Git checkout completed but its resulting state could not be verified: ${detail}`,
1203− {
1204− workspacePath: targetWorkspacePath,
1205− recordedBranch: projection.thread.branch,
1206− actualBranch: null,
1207− rollback: "not_possible",
1208− },
1437+ const actual = actualExit.value;
1438+ if (
1439+ (checkoutAction === "switched" || checkoutAction === "created") &&
1440+ actual.refName !== resolvedBranch
1441+ ) {
1442+ return yield* failure(
1443+ "partial_failure",
1444+ `Git resolved '${requestedBranch}' to '${resolvedBranch}' but the checkout now reports '${actual.refName ?? "detached HEAD"}'. The durable thread binding was not changed.`,
1445+ {
1446+ workspacePath: targetWorkspacePath,
1447+ recordedBranch: projection.thread.branch,
1448+ actualBranch: actual.refName,
1449+ rollback: "not_possible",
1450+ },
1451+ );
1452+ }
1453+ const nextBranch = actual.refName;
1454+ const workspaceChanged = targetWorkspacePath !== currentWorkspacePath;
1455+ const nextWorktreePath = workspaceChanged
1456+ ? targetWorkspacePath === projectWorktreeRoot
1457+ ? null
1458+ : targetWorkspacePath
1459+ : projection.thread.worktreePath;
1460+ const bindingChanged =
1461+ nextBranch !== projection.thread.branch ||
1462+ nextWorktreePath !== projection.thread.worktreePath;
1463+
1464+ const rollbackOwnedCheckout = Effect.fn("WorktreeMcpService.rollbackOwnedCheckout")(
1465+ function* () {
1466+ if (checkoutAction !== "switched" && checkoutAction !== "created") {
1467+ return "not_needed" as const;
1468+ }
1469+ if (targetBefore.refName === null) {
1470+ return "not_possible" as const;
1471+ }
1472+ const [latestStatus, latestBindings, callerProjectionExit] = yield* Effect.all(
1473+ [
1474+ readWorkspaceStatus(targetWorkspacePath),
1475+ loadActiveWorkspaceBindings(inventory.repositoryCommonDir),
1476+ Effect.exit(threadManagement.getThreadProjection(scope.threadId)),
1477+ ],
1478+ { concurrency: 3 },
12091479 );
1210− }
1211− const rollbackExit = yield* Effect.exit(
1212− gitWorkflow
1213− .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName })
1214− .pipe(
1215− Effect.andThen(
1216− createdBranch === null
1217− ? Effect.void
1218− : gitWorkflow.deleteLocalBranch({
1219− cwd: targetWorkspacePath,
1220− refName: createdBranch,
1221− force: true,
1222− }),
1480+ const anotherOwner = latestBindings.some(
1481+ ([thread, workspacePath]) =>
1482+ thread.id !== scope.threadId && workspacePath === targetWorkspacePath,
1483+ );
1484+ const callerStillInitial =
1485+ Exit.isSuccess(callerProjectionExit) &&
1486+ callerProjectionExit.value.thread.branch === projection.thread.branch &&
1487+ callerProjectionExit.value.thread.worktreePath === projection.thread.worktreePath;
1488+ if (latestStatus.refName !== actual.refName || anotherOwner || !callerStillInitial) {
1489+ return "not_possible" as const;
1490+ }
1491+ const rollbackExit = yield* Effect.exit(
1492+ gitWorkflow
1493+ .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName })
1494+ .pipe(
1495+ Effect.andThen(
1496+ createdBranch === null
1497+ ? Effect.void
1498+ : gitWorkflow.deleteLocalBranch({
1499+ cwd: targetWorkspacePath,
1500+ refName: createdBranch,
1501+ force: true,
1502+ }),
1503+ ),
12231504 ),
1224− ),
1225− );
1226− if (Exit.isFailure(rollbackExit)) {
1227− const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath);
1228− return yield* failure(
1229− "partial_failure",
1230− `Git checkout completed, verification failed, and rollback also failed: ${detail}`,
1231− {
1232− workspacePath: targetWorkspacePath,
1233− recordedBranch: projection.thread.branch,
1234− actualBranch,
1235− rollback: "failed",
1236− },
12371505 );
1238− }
1239− }
1240− return yield* failure(
1241− "operation_failed",
1242− `Unable to verify the selected checkout '${targetWorkspacePath}': ${detail}`,
1506+ return Exit.isSuccess(rollbackExit) ? ("rolled_back" as const) : ("failed" as const);
1507+ },
12431508 );
1244− }
1245− const actual = actualExit.value;
1246− const nextBranch = actual.refName;
1247− const workspaceChanged = targetWorkspacePath !== currentWorkspacePath;
1248− const nextWorktreePath = workspaceChanged
1249− ? targetWorkspacePath === projectWorkspaceRoot
1250− ? null
1251− : targetWorkspacePath
1252− : projection.thread.worktreePath;
1253− const bindingChanged =
1254− nextBranch !== projection.thread.branch ||
1255− nextWorktreePath !== projection.thread.worktreePath;
1256−
1257− if (bindingChanged) {
1258− const dispatchExit = yield* Effect.exit(
1259− threadManagement.dispatch({
1260− type: "thread.metadata.update",
1261− commandId: ids.commandId,
1262− threadId: scope.threadId,
1263− branch: nextBranch,
1264− worktreePath: nextWorktreePath,
1265− expectedBranch: projection.thread.branch,
1266− expectedWorktreePath: projection.thread.worktreePath,
1267− }),
1268− );
1269− if (Exit.isFailure(dispatchExit)) {
1270− const dispatchDetail = errorMessage(Cause.squash(dispatchExit.cause));
1271− const bindingAfterDispatchExit = yield* Effect.exit(loadThread(scope));
1272− if (Exit.isFailure(bindingAfterDispatchExit)) {
1509+
1510+ if (bindingChanged) {
1511+ const preCommitProjectionExit = yield* Effect.exit(loadThread(scope));
1512+ const preCommitProjection = Exit.isSuccess(preCommitProjectionExit)
1513+ ? preCommitProjectionExit.value
1514+ : null;
1515+ const metadataChanged =
1516+ preCommitProjection === null ||
1517+ preCommitProjection.thread.branch !== projection.thread.branch ||
1518+ preCommitProjection.thread.worktreePath !== projection.thread.worktreePath ||
1519+ preCommitProjection.thread.archivedAt !== projection.thread.archivedAt;
1520+ if (metadataChanged) {
1521+ const rollback = yield* rollbackOwnedCheckout();
1522+ if (rollback === "failed" || rollback === "not_possible") {
1523+ return yield* failure(
1524+ "partial_failure",
1525+ `The thread changed or disappeared after Git checkout, so its durable binding was not updated and Git rollback was ${rollback === "failed" ? "unsuccessful" : "unsafe"}.`,
1526+ {
1527+ workspacePath: targetWorkspacePath,
1528+ recordedBranch: projection.thread.branch,
1529+ actualBranch: yield* readWorkspaceBranchOrNull(targetWorkspacePath),
1530+ rollback: rollback === "failed" ? "failed" : "not_possible",
1531+ },
1532+ );
1533+ }
12731534 return yield* failure(
1274− "partial_failure",
1275− `The durable binding update reported a failure and its outcome could not be verified: ${dispatchDetail}`,
1276− {
1277− workspacePath: targetWorkspacePath,
1278− recordedBranch: projection.thread.branch,
1279− actualBranch: actual.refName,
1280− rollback: "not_possible",
1281− },
1535+ "checkout_in_progress",
1536+ `Thread '${scope.threadId}' changed or disappeared before the workspace binding committed. Git state was left unchanged.`,
12821537 );
12831538 }
1284− const bindingAfterDispatch = bindingAfterDispatchExit.value.thread;
1285− const bindingCommitted =
1286− bindingAfterDispatch.branch === nextBranch &&
1287− bindingAfterDispatch.worktreePath === nextWorktreePath;
1288− if (bindingCommitted) {
1289− yield* Effect.logWarning(
1290− "workspace binding dispatch reported failure after the binding committed",
1291− {
1292− threadId: scope.threadId,
1293− workspacePath: targetWorkspacePath,
1294− detail: dispatchDetail,
1295− },
1296− );
1297− } else {
1298− if (checkoutAction === "switched" || checkoutAction === "created") {
1299− if (targetBefore.refName === null) {
1300− return yield* failure(
1301− "partial_failure",
1302− `Git checkout completed but the durable thread binding failed: ${dispatchDetail}`,
1303− {
1304− workspacePath: targetWorkspacePath,
1305− recordedBranch: projection.thread.branch,
1306− actualBranch: actual.refName,
1307− rollback: "not_possible",
1308− },
1309− );
1310− }
1311− const rollbackExit = yield* Effect.exit(
1312− gitWorkflow
1313− .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName })
1314− .pipe(
1315− Effect.andThen(
1316− createdBranch === null
1317− ? Effect.void
1318− : gitWorkflow.deleteLocalBranch({
1319− cwd: targetWorkspacePath,
1320− refName: createdBranch,
1321− force: true,
1322− }),
1323− ),
1324− ),
1539+ const dispatchExit = yield* Effect.exit(
1540+ threadManagement.dispatch({
1541+ type: "thread.metadata.update",
1542+ commandId: ids.commandId,
1543+ threadId: scope.threadId,
1544+ branch: nextBranch,
1545+ worktreePath: nextWorktreePath,
1546+ expectedBranch: projection.thread.branch,
1547+ expectedWorktreePath: projection.thread.worktreePath,
1548+ }),
1549+ );
1550+ if (Exit.isFailure(dispatchExit)) {
1551+ const dispatchDetail = errorMessage(Cause.squash(dispatchExit.cause));
1552+ const bindingAfterDispatchExit = yield* Effect.exit(loadThread(scope));
1553+ if (Exit.isFailure(bindingAfterDispatchExit)) {
1554+ return yield* failure(
1555+ "partial_failure",
1556+ `The durable binding update reported a failure and its outcome could not be verified: ${dispatchDetail}`,
1557+ {
1558+ workspacePath: targetWorkspacePath,
1559+ recordedBranch: projection.thread.branch,
1560+ actualBranch: actual.refName,
1561+ rollback: "not_possible",
1562+ },
1563+ );
1564+ }
1565+ const bindingAfterDispatch = bindingAfterDispatchExit.value.thread;
1566+ const bindingCommitted =
1567+ bindingAfterDispatch.branch === nextBranch &&
1568+ bindingAfterDispatch.worktreePath === nextWorktreePath;
1569+ if (bindingCommitted) {
1570+ yield* Effect.logWarning(
1571+ "workspace binding dispatch reported failure after the binding committed",
1572+ {
1573+ threadId: scope.threadId,
1574+ workspacePath: targetWorkspacePath,
1575+ detail: dispatchDetail,
1576+ },
13251577 );
1326− if (Exit.isFailure(rollbackExit)) {
1327− const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath);
1578+ } else {
1579+ const bindingStillInitial =
1580+ bindingAfterDispatch.branch === projection.thread.branch &&
1581+ bindingAfterDispatch.worktreePath === projection.thread.worktreePath;
1582+ const rollback = bindingStillInitial
1583+ ? yield* rollbackOwnedCheckout()
1584+ : ("not_possible" as const);
1585+ if (rollback === "failed" || rollback === "not_possible") {
13281586 return yield* failure(
13291587 "partial_failure",
1330− `Git checkout completed, the durable binding failed, and rollback also failed: ${dispatchDetail}`,
1588+ `Git checkout completed but the durable binding failed, and rollback was ${rollback === "failed" ? "unsuccessful" : "unsafe"}: ${dispatchDetail}`,
13311589 {
13321590 workspacePath: targetWorkspacePath,
1333− recordedBranch: projection.thread.branch,
1334− actualBranch,
1335− rollback: "failed",
1591+ recordedBranch: bindingAfterDispatch.branch,
1592+ actualBranch: yield* readWorkspaceBranchOrNull(targetWorkspacePath),
1593+ rollback: rollback === "failed" ? "failed" : "not_possible",
13361594 },
13371595 );
13381596 }
1597+ return yield* failure(
1598+ "operation_failed",
1599+ `Unable to update the durable thread workspace: ${dispatchDetail}`,
1600+ );
13391601 }
1340− return yield* failure(
1341− "operation_failed",
1342− `Unable to update the durable thread workspace: ${dispatchDetail}`,
1343− );
13441602 }
13451603 }
1346− }
13471604
1348− const continuation = workspaceChanged
1349− ? yield* queueContinuation({
1350− scope,
1351− projection,
1352− prompt: input.continuationPrompt,
1353− commandId: ids.continuationCommandId,
1354− messageId: ids.continuationMessageId,
1355− workspacePath: targetWorkspacePath,
1356− })
1357− : ({ status: "skipped" } as const);
1358− const previous = {
1359− workspacePath: currentWorkspacePath,
1360− recordedBranch: projection.thread.branch,
1361− recordedWorktreePath: projection.thread.worktreePath,
1362− actualBranch: previousActual.refName,
1363− };
1364− const current = {
1365− workspacePath: targetWorkspacePath,
1366− recordedBranch: nextBranch,
1367− recordedWorktreePath: nextWorktreePath,
1368− actualBranch: actual.refName,
1369− };
1370− return {
1371− previous,
1372− current,
1373− checkoutAction,
1374− workspaceChanged,
1375− branchChanged: previousActual.refName !== actual.refName,
1376− continuation,
1377− setupScript: { status: "skipped" },
1378− callerTurnEnds: workspaceChanged,
1379− note: workspaceChanged
1380− ? continuation.status === "scheduled"
1381− ? "Checkout and durable thread binding completed. The workspace change detaches this provider session; the queued continuation starts the next turn in the selected checkout."
1382− : "Checkout and durable thread binding completed. The workspace change detaches this provider session, so this turn ends after the call. Send another message to continue in the selected checkout."
1383− : "Checkout and durable thread binding completed without changing the provider session workspace.",
1384− } satisfies WorktreeMcpCheckoutResult;
1605+ const continuation = workspaceChanged
1606+ ? yield* queueContinuation({
1607+ scope,
1608+ projection,
1609+ prompt: input.continuationPrompt,
1610+ commandId: ids.continuationCommandId,
1611+ messageId: ids.continuationMessageId,
1612+ workspacePath: targetWorkspacePath,
1613+ })
1614+ : ({ status: "skipped" } as const);
1615+ const previous = {
1616+ workspacePath: currentWorkspacePath,
1617+ recordedBranch: projection.thread.branch,
1618+ recordedWorktreePath: projection.thread.worktreePath,
1619+ actualBranch: previousActual.refName,
1620+ };
1621+ const current = {
1622+ workspacePath: targetWorkspacePath,
1623+ recordedBranch: nextBranch,
1624+ recordedWorktreePath: nextWorktreePath,
1625+ actualBranch: actual.refName,
1626+ };
1627+ return {
1628+ previous,
1629+ current,
1630+ checkoutAction,
1631+ workspaceChanged,
1632+ branchChanged: previousActual.refName !== actual.refName,
1633+ continuation,
1634+ setupScript: { status: "skipped" },
1635+ callerTurnEnds: workspaceChanged,
1636+ note: workspaceChanged
1637+ ? continuation.status === "scheduled"
1638+ ? "Checkout and durable thread binding completed. The workspace change detaches this provider session; the queued continuation starts the next turn in the selected checkout."
1639+ : "Checkout and durable thread binding completed. The workspace change detaches this provider session, so this turn ends after the call. Send another message to continue in the selected checkout."
1640+ : "Checkout and durable thread binding completed without changing the provider session workspace.",
1641+ } satisfies WorktreeMcpCheckoutResult;
1642+ }).pipe(
1643+ Effect.ensuring(
1644+ Effect.sync(() => workspaceTransitionsInFlight.delete(workspaceGuardKey)),
1645+ ),
1646+ );
13851647 }),
13861648 );
13871649 });
@@ -1415,6 +1677,7 @@ export const layer: Layer.Layer< · 27 unmodified lines
14151677 WorktreeMcpService,
14161678 never,
14171679 | Crypto.Crypto
1680+ | FileSystem.FileSystem
14181681 | Path.Path
14191682 | ThreadManagementService
14201683 | ProjectService.ProjectService
apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts+164−0
@@ -15,6 +15,7 @@ import { · 14 unmodified lines
1515 ThreadId,
1616 } from "@t3tools/contracts";
1717 import * as DateTime from "effect/DateTime";
18+import * as Deferred from "effect/Deferred";
1819 import * as Effect from "effect/Effect";
1920 import * as Queue from "effect/Queue";
2021 import * as Ref from "effect/Ref";
@@ -84,6 +85,14 @@ interface RestartAdapterState { · 63 unmodified lines
8485 function makeRestartAdapter(
8586 state: Ref.Ref<RestartAdapterState>,
8687 sessionCapabilities: OrchestrationV2ProviderCapabilities = pooledCapabilities,
88+ observeTurnStart?: (input: {
89+ readonly model: string;
90+ readonly cwd: string | null;
91+ }) => Effect.Effect<void>,
92+ interruptGate?: {
93+ readonly requested: Queue.Queue<void>;
94+ readonly release: Deferred.Deferred<void>;
95+ },
8796 ): ProviderAdapterV2Shape {
8897 return {
8998 instanceId: providerInstanceId,
@@ -224,6 +233,12 @@ function makeRestartAdapter( · 134 unmodified lines
224233 },
225234 ],
226235 }));
236+ if (observeTurnStart !== undefined) {
237+ yield* observeTurnStart({
238+ model: input.modelSelection.model,
239+ cwd: input.runtimePolicy.cwd,
240+ });
241+ }
227242 const active = {
228243 input,
229244 providerTurnId: ProviderTurnId.make(`provider-turn:${input.attemptId}`),
@@ -259,6 +274,10 @@ function makeRestartAdapter( · 29 unmodified lines
259274 Effect.gen(function* () {
260275 const active = (yield* Ref.get(state)).activeTurn;
261276 if (active !== null) {
277+ if (interruptGate !== undefined) {
278+ yield* Queue.offer(interruptGate.requested, undefined);
279+ yield* Deferred.await(interruptGate.release);
280+ }
262281 const updatedAt = yield* DateTime.now;
263282 yield* Queue.offer(events, {
264283 type: "provider_thread.updated",
@@ -529,6 +548,151 @@ it.live("restarts selection as a new attempt and retries after old-session clean · 264 unmodified lines
529548 ),
530549 );
531550
551+it.live("queues a workspace continuation before detaching and starts it in the new cwd", () =>
552+ Effect.scoped(
553+ Effect.gen(function* () {
554+ const initialCwd = yield* checkpointWorkspace("workspace-continuation-initial");
555+ const targetCwd = yield* checkpointWorkspace("workspace-continuation-target");
556+ const threadId = ThreadId.make("thread:workspace-continuation");
557+ const projectId = ProjectId.make("project:workspace-continuation");
558+ const state = yield* Ref.make<RestartAdapterState>({
559+ activeTurn: null,
560+ opened: [],
561+ started: [],
562+ closedSessionCount: 0,
563+ failedReplacementOpen: false,
564+ });
565+ const initialTurnStarted = yield* Deferred.make<{
566+ readonly model: string;
567+ readonly cwd: string | null;
568+ }>();
569+ const continuationTurnStarted = yield* Deferred.make<{
570+ readonly model: string;
571+ readonly cwd: string | null;
572+ }>();
573+ const interruptRequested = yield* Queue.unbounded<void>();
574+ const interruptRelease = yield* Deferred.make<void>();
575+ const registry = makeSingleProviderAdapterRegistryLayer(
576+ makeRestartAdapter(
577+ state,
578+ pooledCapabilities,
579+ (started) =>
580+ Deferred.succeed(
581+ started.cwd === initialCwd ? initialTurnStarted : continuationTurnStarted,
582+ started,
583+ ).pipe(Effect.asVoid),
584+ {
585+ requested: interruptRequested,
586+ release: interruptRelease,
587+ },
588+ ),
589+ );
590+ const orchestratorLayer = makeOrchestratorV2ReplayLayerWithRegistry(
591+ { name: "workspace-continuation" },
592+ registry,
593+ );
594+
595+ yield* Effect.gen(function* () {
596+ const orchestrator = yield* OrchestratorV2;
597+ yield* orchestrator.dispatch({
598+ type: "thread.create",
599+ createdBy: "user",
600+ creationSource: "web",
601+ commandId: CommandId.make("command:workspace-continuation:create"),
602+ threadId,
603+ projectId,
604+ title: "Workspace continuation",
605+ modelSelection: initialSelection,
606+ runtimeMode: "full-access",
607+ interactionMode: "default",
608+ branch: "main",
609+ worktreePath: initialCwd,
610+ });
611+ const providerTurnProjected = yield* Deferred.make<void>();
612+ const afterCreate = yield* orchestrator.getThreadEventSequence(threadId);
613+ yield* orchestrator.streamStoredEventsFrom({ threadId, afterSequence: afterCreate }).pipe(
614+ Stream.runForEach((stored) =>
615+ stored.event.type === "provider-turn.updated" &&
616+ stored.event.payload.status === "running"
617+ ? Deferred.succeed(providerTurnProjected, undefined).pipe(Effect.asVoid)
618+ : Effect.void,
619+ ),
620+ Effect.forkScoped,
621+ );
622+ const initialSendReceipt = yield* orchestrator.dispatch({
623+ type: "message.dispatch",
624+ commandId: CommandId.make("command:workspace-continuation:first"),
625+ threadId,
626+ messageId: MessageId.make("message:workspace-continuation:first"),
627+ text: "Start in the original checkout.",
628+ attachments: [],
629+ modelSelection: initialSelection,
630+ dispatchMode: { type: "start_immediately" },
631+ createdBy: "user",
632+ creationSource: "web",
633+ });
634+ assert.isTrue(initialSendReceipt.storedEvents.length > 0);
635+ assert.deepEqual(yield* Deferred.await(initialTurnStarted), {
636+ model: initialSelection.model,
637+ cwd: initialCwd,
638+ });
639+ yield* Deferred.await(providerTurnProjected);
640+
641+ const bindingReceipt = yield* orchestrator.dispatch({
642+ type: "thread.metadata.update",
643+ commandId: CommandId.make("command:workspace-continuation:binding"),
644+ threadId,
645+ branch: "feature/workspace-continuation",
646+ worktreePath: targetCwd,
647+ expectedBranch: "main",
648+ expectedWorktreePath: initialCwd,
649+ });
650+ assert.isTrue(
651+ bindingReceipt.storedEvents.some(
652+ (stored) => stored.event.type === "provider-session.detached",
653+ ),
654+ );
655+
656+ const continuationReceipt = yield* orchestrator.dispatch({
657+ type: "message.dispatch",
658+ commandId: CommandId.make("command:workspace-continuation:queued"),
659+ threadId,
660+ messageId: MessageId.make("message:workspace-continuation:queued"),
661+ text: "Continue after the workspace handoff.",
662+ attachments: [],
663+ modelSelection: initialSelection,
664+ dispatchMode: { type: "queue_after_active" },
665+ createdBy: "agent",
666+ creationSource: "mcp",
667+ });
668+ assert.isTrue(continuationReceipt.storedEvents.length > 0);
669+ const queued = yield* orchestrator.getThreadProjection(threadId);
670+ const continuationRun = queued.runs.find(
671+ (run) => run.userMessageId === MessageId.make("message:workspace-continuation:queued"),
672+ );
673+ assert.isDefined(continuationRun);
674+ assert.equal(continuationRun.status, "queued");
675+ yield* Queue.take(interruptRequested);
676+ yield* Deferred.succeed(interruptRelease, undefined);
677+
678+ assert.deepEqual(yield* Deferred.await(continuationTurnStarted), {
679+ model: initialSelection.model,
680+ cwd: targetCwd,
681+ });
682+ const projection = yield* orchestrator.getThreadProjection(threadId);
683+ assert.equal(projection.thread.branch, "feature/workspace-continuation");
684+ assert.equal(projection.thread.worktreePath, targetCwd);
685+ assert.equal(
686+ projection.messages.find(
687+ (message) => message.id === MessageId.make("message:workspace-continuation:queued"),
688+ )?.text,
689+ "Continue after the workspace handoff.",
690+ );
691+ }).pipe(Effect.provide(orchestratorLayer));
692+ }),
693+ ),
694+);
695+
532696 it.live("detaches the old provider session after an active provider handoff", () =>
533697 Effect.scoped(
534698 Effect.gen(function* () {
apps/server/src/vcs/GitVcsDriverCore.test.ts+27−0
@@ -1560,6 +1560,33 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { · 1559 unmodified lines
15601560 assert.equal(result.branch, current);
15611561 }),
15621562 );
1563+
1564+ it.effect(
1565+ "resolves an explicit remote ref instead of a same-named untracked local branch",
1566+ () =>
1567+ Effect.gen(function* () {
1568+ const cwd = yield* makeTmpDir();
1569+ const remote = yield* makeTmpDir("git-vcs-driver-remote-");
1570+ const { initialBranch } = yield* initRepoWithCommit(cwd);
1571+ yield* git(remote, ["init", "--bare"]);
1572+ yield* git(cwd, ["remote", "add", "origin", remote]);
1573+ yield* git(cwd, ["push", "origin", "HEAD:refs/heads/feature"]);
1574+ const remoteFeatureCommit = yield* git(cwd, ["rev-parse", "origin/feature"]);
1575+ yield* git(cwd, ["checkout", "-b", "feature"]);
1576+ yield* writeTextFile(cwd, "local-only.txt", "local\n");
1577+ yield* git(cwd, ["add", "local-only.txt"]);
1578+ yield* git(cwd, ["commit", "-m", "local feature"]);
1579+ const localFeatureCommit = yield* git(cwd, ["rev-parse", "feature"]);
1580+ yield* git(cwd, ["checkout", initialBranch]);
1581+ const driver = yield* GitVcsDriver.GitVcsDriver;
1582+
1583+ const switched = yield* driver.switchRef({ cwd, refName: "origin/feature" });
1584+
1585+ assert.equal(switched.refName, null);
1586+ assert.notEqual(localFeatureCommit, remoteFeatureCommit);
1587+ assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), remoteFeatureCommit);
1588+ }),
1589+ );
15631590 });
15641591
15651592 describe("worktree operations", () => {