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
5 EnvironmentId,5 EnvironmentId,
6 GitManagerError,6 GitManagerError,
7 type OrchestrationV2ThreadProjection,7 type OrchestrationV2ThreadProjection,
8 type OrchestrationV2ThreadShell,
8 type Project,9 type Project,
9 ProjectId,10 ProjectId,
10 ProviderInstanceId,11 ProviderInstanceId,
@@ -104,7 +105,10 @@ interface HarnessOptions { · 93 unmodified lines
104 readonly dispatchGate?: Effect.Effect<void>;105 readonly dispatchGate?: Effect.Effect<void>;
105 readonly threadAttachedOnRecheck?: boolean;106 readonly threadAttachedOnRecheck?: boolean;
106 readonly threadArchivedOnRecheck?: boolean;107 readonly threadArchivedOnRecheck?: boolean;
108 readonly threadArchivedOnCall?: number;
109 readonly threadDeletedOnCall?: number;
107 readonly threadReadFailsOnRecheck?: boolean;110 readonly threadReadFailsOnRecheck?: boolean;
111 readonly threadReadFailsAfterDispatch?: boolean;
108 readonly threadReadFailsOnCall?: number;112 readonly threadReadFailsOnCall?: number;
109 readonly continuation?: "queued" | "fails" | "dies";113 readonly continuation?: "queued" | "fails" | "dies";
110 readonly projectMissing?: boolean;114 readonly projectMissing?: boolean;
@@ -124,6 +128,12 @@ interface HarnessOptions { · 13 unmodified lines
124 readonly isRemote?: boolean;128 readonly isRemote?: boolean;
125 readonly worktreePath: string | null;129 readonly worktreePath: string | null;
126 }>;130 }>;
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>>;
127 readonly workspaceStatuses?: Readonly<Record<string, { branch: string | null; dirty?: boolean }>>;137 readonly workspaceStatuses?: Readonly<Record<string, { branch: string | null; dirty?: boolean }>>;
128 readonly localStatusFailsOnCall?: number;138 readonly localStatusFailsOnCall?: number;
129 readonly localStatusFailure?: "typed" | "defect" | "interrupt";139 readonly localStatusFailure?: "typed" | "defect" | "interrupt";
@@ -135,9 +145,21 @@ interface HarnessOptions { · 5 unmodified lines
135 readonly status?: "idle" | "running";145 readonly status?: "idle" | "running";
136 readonly active?: boolean;146 readonly active?: boolean;
137 }>;147 }>;
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 };
138 readonly switchRefFails?: boolean;157 readonly switchRefFails?: boolean;
139 readonly switchRefFailsAfterMutation?: boolean;158 readonly switchRefFailsAfterMutation?: boolean;
140 readonly switchRefRollbackFails?: boolean;159 readonly switchRefRollbackFails?: boolean;
160 readonly switchRefGate?: Effect.Effect<void>;
161 readonly switchRefResultBranch?: string | null;
162 readonly refChangeAfterSwitch?: string | null;
141 readonly createRefFails?: boolean;163 readonly createRefFails?: boolean;
142}164}
143 165
@@ -189,6 +211,28 @@ const makeHarness = (options: HarnessOptions = {}) => { · 45 unmodified lines
189 }),211 }),
190 ) as never;212 ) as never;
191 }213 }
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 }
192 if (236 if (
193 options.threadAttachedOnRecheck === true &&237 options.threadAttachedOnRecheck === true &&
194 getThreadProjection.mock.calls.length > 1 &&238 getThreadProjection.mock.calls.length > 1 &&
@@ -212,9 +256,26 @@ const makeHarness = (options: HarnessOptions = {}) => { · 17 unmodified lines
212 ) {256 ) {
213 return Effect.succeed(makeProjection({ ...thread, ...options.threadAfterFailedDispatch }));257 return Effect.succeed(makeProjection({ ...thread, ...options.threadAfterFailedDispatch }));
214 }258 }
215 return id === threadId && thread !== null259 if (id === threadId && thread !== null) {
216 ? Effect.succeed(makeProjection(thread))260 return Effect.succeed(makeProjection(thread));
217 : Effect.fail(new OrchestratorProjectionError({ threadId: id }));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 }));
218 });279 });
219 const sendToThread = vi.fn((_: unknown) => {280 const sendToThread = vi.fn((_: unknown) => {
220 switch (options.continuation ?? "queued") {281 switch (options.continuation ?? "queued") {
@@ -236,34 +297,59 @@ const makeHarness = (options: HarnessOptions = {}) => { · 15 unmodified lines
236 : Effect.succeed(297 : Effect.succeed(
237 id === projectId && options.projectMissing !== true298 id === projectId && options.projectMissing !== true
238 ? Option.some(project)299 ? 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(),
240 ),307 ),
241 );308 );
242 const listProjectThreads = vi.fn(() =>309 const projectThreadShells: Array<OrchestrationV2ThreadShell> = (
243 Effect.succeed(310 options.projectThreads ?? [
244 (311 {
245 options.projectThreads ?? [312 id: threadId,
246 {313 title: "Worktree test thread",
247 id: threadId,314 branch: thread?.branch ?? null,
248 title: "Worktree test thread",315 worktreePath: thread?.worktreePath ?? null,
249 branch: thread?.branch ?? null,316 },
250 worktreePath: thread?.worktreePath ?? null,317 ]
251 },318 ).map(
252 ]319 (item) =>
253 ).map(320 ({
254 (item) =>321 id: item.id,
255 ({322 projectId,
256 id: item.id,323 title: item.title,
257 projectId,324 branch: item.branch,
258 title: item.title,325 worktreePath: item.worktreePath,
259 branch: item.branch,326 status: item.status ?? "idle",
260 worktreePath: item.worktreePath,327 activeRunId: item.active === true ? "run-active" : null,
261 status: item.status ?? "idle",328 lineage: { relationshipToParent: "none" },
262 activeRunId: item.active === true ? "run-active" : null,329 }) as unknown as OrchestrationV2ThreadShell,
263 lineage: { relationshipToParent: "none" },330 );
264 }) as never,331 if (options.otherProjectThread !== undefined) {
265 ),332 projectThreadShells.push({
266 ),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),
267 );353 );
268 const removeWorktree = vi.fn((_: unknown) =>354 const removeWorktree = vi.fn((_: unknown) =>
269 options.removeWorktreeFails355 options.removeWorktreeFails
@@ -312,28 +398,56 @@ const makeHarness = (options: HarnessOptions = {}) => { · 42 unmodified lines
312 ),398 ),
313 ),399 ),
314 );400 );
315 const listRefs = vi.fn((input: { readonly query?: string | undefined }) =>401 const listRefs = vi.fn((input: { readonly query?: string | undefined }) => {
316 Effect.succeed({402 const refs =
317 refs:403 options.refs !== undefined
318 options.refs !== undefined404 ? options.refs.filter((ref) =>
319 ? options.refs.filter(405 input.query === undefined ? true : ref.name.includes(input.query),
320 (ref) => input.query === undefined || ref.name.includes(input.query),406 )
321 )407 : options.existingBranchWorktreePath === undefined
322 : options.existingBranchWorktreePath === undefined408 ? []
323 ? []409 : [
324 : [410 {
325 {411 name: input.query ?? "",
326 name: input.query ?? "",412 current: false,
327 current: false,413 isDefault: false,
328 isDefault: false,414 worktreePath: options.existingBranchWorktreePath,
329 worktreePath: options.existingBranchWorktreePath,415 },
330 },416 ];
331 ],417 return Effect.succeed({
418 refs,
332 isRepo: true,419 isRepo: true,
333 hasPrimaryRemote: true,420 hasPrimaryRemote: true,
334 nextCursor: null,421 nextCursor: null,
335 totalCount:422 totalCount:
336 options.refs?.length ?? (options.existingBranchWorktreePath === undefined ? 0 : 1),423 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,
337 }),451 }),
338 );452 );
339 let localStatusCallCount = 0;453 let localStatusCallCount = 0;
@@ -363,27 +477,47 @@ const makeHarness = (options: HarnessOptions = {}) => { · 23 unmodified lines
363 hasPrimaryRemote: true,477 hasPrimaryRemote: true,
364 isDefaultRef: false,478 isDefaultRef: false,
365 refName:479 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,
367 hasWorkingTreeChanges: current?.dirty ?? false,485 hasWorkingTreeChanges: current?.dirty ?? false,
368 workingTree: { files: [], insertions: 0, deletions: 0 },486 workingTree: { files: [], insertions: 0, deletions: 0 },
369 });487 });
370 });488 });
371 let switchCallCount = 0;489 let switchCallCount = 0;
372 const switchRef = vi.fn((input: { readonly cwd: string; readonly refName: string }) => {490 const switchRef = vi.fn((input: { readonly cwd: string; readonly refName: string }) =>
373 switchCallCount += 1;491 (options.switchRefGate ?? Effect.void).pipe(
374 if (options.switchRefFailsAfterMutation === true && switchCallCount === 1) {492 Effect.andThen(
375 workspaceStatuses.set(input.cwd, { branch: input.refName, dirty: false });493 Effect.suspend(() => {
376 return Effect.fail("simulated switch failure after mutation") as never;494 switchCallCount += 1;
377 }495 if (options.switchRefFailsAfterMutation === true && switchCallCount === 1) {
378 if (496 workspaceStatuses.set(input.cwd, { branch: input.refName, dirty: false });
379 options.switchRefFails === true ||497 return Effect.fail("simulated switch failure after mutation") as never;
380 (options.switchRefRollbackFails === true && switchCallCount > 1)498 }
381 ) {499 if (
382 return Effect.fail("simulated switch failure") as never;500 options.switchRefFails === true ||
383 }501 (options.switchRefRollbackFails === true && switchCallCount > 1)
384 workspaceStatuses.set(input.cwd, { branch: input.refName, dirty: false });502 ) {
385 return Effect.succeed({ refName: input.refName });503 return Effect.fail("simulated switch failure") as never;
386 });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 );
387 const createRef = vi.fn((_: unknown) =>521 const createRef = vi.fn((_: unknown) =>
388 options.createRefFails === true522 options.createRefFails === true
389 ? (Effect.fail("simulated create ref failure") as never)523 ? (Effect.fail("simulated create ref failure") as never)
@@ -418,8 +552,8 @@ const makeHarness = (options: HarnessOptions = {}) => { · 28 unmodified lines
418 // Optional deterministic Path semantics: providing this BEFORE the general552 // Optional deterministic Path semantics: providing this BEFORE the general
419 // mocks means the service resolves Path here rather than from NodeServices,553 // mocks means the service resolves Path here rather than from NodeServices,
420 // so absolute-path validation is testable independently of the host OS. The554 // so absolute-path validation is testable independently of the host OS. The
421 // service only calls isAbsolute; the minimal per-platform semantics are555 // The minimal per-platform semantics are inlined so the test does not
422 // inlined so the test does not depend on the host's path module.556 // depend on the host's path module.
423 const win32IsAbsolute = (value: string) => /^(?:[a-zA-Z]:[\\/]|[\\/])/.test(value);557 const win32IsAbsolute = (value: string) => /^(?:[a-zA-Z]:[\\/]|[\\/])/.test(value);
424 const posixIsAbsolute = (value: string) => value.startsWith("/");558 const posixIsAbsolute = (value: string) => value.startsWith("/");
425 const serviceLayer =559 const serviceLayer =
@@ -429,6 +563,8 @@ const makeHarness = (options: HarnessOptions = {}) => { · 3 unmodified lines
429 Layer.provide(563 Layer.provide(
430 Layer.succeed(Path.Path, {564 Layer.succeed(Path.Path, {
431 isAbsolute: options.pathSemantics === "win32" ? win32IsAbsolute : posixIsAbsolute,565 isAbsolute: options.pathSemantics === "win32" ? win32IsAbsolute : posixIsAbsolute,
566 normalize: (value: string) => value,
567 resolve: (value: string) => value,
432 } as unknown as Path.Path),568 } as unknown as Path.Path),
433 ),569 ),
434 );570 );
@@ -437,6 +573,7 @@ const makeHarness = (options: HarnessOptions = {}) => { · 2 unmodified lines
437 Layer.mergeAll(573 Layer.mergeAll(
438 Layer.mock(ThreadManagementService)({574 Layer.mock(ThreadManagementService)({
439 dispatch,575 dispatch,
576 getShellSnapshot,
440 getThreadProjection,577 getThreadProjection,
441 listProjectThreads,578 listProjectThreads,
442 sendToThread,579 sendToThread,
@@ -449,6 +586,7 @@ const makeHarness = (options: HarnessOptions = {}) => { · 6 unmodified lines
449 }),586 }),
450 Layer.mock(GitWorkflowService.GitWorkflowService)({587 Layer.mock(GitWorkflowService.GitWorkflowService)({
451 listRefs,588 listRefs,
589 listWorktrees,
452 listLocalBranchNames,590 listLocalBranchNames,
453 localStatus,591 localStatus,
454 fetchRemote,592 fetchRemote,
@@ -483,6 +621,7 @@ const makeHarness = (options: HarnessOptions = {}) => { · 28 unmodified lines
483 deleteLocalBranch,621 deleteLocalBranch,
484 localStatus,622 localStatus,
485 listRefs,623 listRefs,
624 listWorktrees,
486 listProjectThreads,625 listProjectThreads,
487 switchRef,626 switchRef,
488 createRef,627 createRef,
@@ -525,10 +664,13 @@ const runStatus = (harness: ReturnType<typeof makeHarness>) => · 36 unmodified lines
525 return yield* service.status(harness.scope);664 return yield* service.status(harness.scope);
526 }).pipe(Effect.provide(harness.layer));665 }).pipe(Effect.provide(harness.layer));
527 666
528const runList = (harness: ReturnType<typeof makeHarness>) =>667const runList = (
668 harness: ReturnType<typeof makeHarness>,
669 input: Parameters<WorktreeMcpService["Service"]["listWorktrees"]>[1] = {},
670) =>
529 Effect.gen(function* () {671 Effect.gen(function* () {
530 const service = yield* WorktreeMcpService;672 const service = yield* WorktreeMcpService;
531 return yield* service.listWorktrees(harness.scope);673 return yield* service.listWorktrees(harness.scope, input);
532 }).pipe(Effect.provide(harness.layer));674 }).pipe(Effect.provide(harness.layer));
533 675
534const runCheckout = (676const runCheckout = (
@@ -987,7 +1129,12 @@ describe("t3_worktree_handoff", () => { · 452 unmodified lines
987 it.effect("queues the continuation even when interrupted during the binding dispatch", () =>1129 it.effect("queues the continuation even when interrupted during the binding dispatch", () =>
988 Effect.gen(function* () {1130 Effect.gen(function* () {
989 const gate = yield* Deferred.make<void>();1131 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 });
991 1138
992 // Interrupt arrives while the metadata dispatch is in flight; the1139 // Interrupt arrives while the metadata dispatch is in flight; the
993 // binding-plus-continuation section must run to completion anyway so the1140 // binding-plus-continuation section must run to completion anyway so the
@@ -998,7 +1145,7 @@ describe("t3_worktree_handoff", () => { · 4 unmodified lines
998 continuationPrompt: "Keep going in the worktree.",1145 continuationPrompt: "Keep going in the worktree.",
999 }),1146 }),
1000 );1147 );
1001 yield* Effect.yieldNow;1148 yield* Deferred.await(dispatchStarted);
1002 const interruption = yield* Effect.forkChild(Fiber.interrupt(fiber));1149 const interruption = yield* Effect.forkChild(Fiber.interrupt(fiber));
1003 yield* Effect.yieldNow;1150 yield* Effect.yieldNow;
1004 yield* Deferred.succeed(gate, undefined);1151 yield* Deferred.succeed(gate, undefined);
@@ -1335,6 +1482,10 @@ describe("t3_worktree_list", () => { · 330 unmodified lines
1335 return Effect.gen(function* () {1482 return Effect.gen(function* () {
1336 const result = yield* runList(harness);1483 const result = yield* runList(harness);
1337 expect(result.projectWorkspaceRoot).toBe(workspaceRoot);1484 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);
1338 expect(result.worktrees).toEqual([1489 expect(result.worktrees).toEqual([
1339 {1490 {
1340 path: workspaceRoot,1491 path: workspaceRoot,
@@ -1343,6 +1494,8 @@ describe("t3_worktree_list", () => { · 2 unmodified lines
1343 isRepo: true,1494 isRepo: true,
1344 isProjectRoot: true,1495 isProjectRoot: true,
1345 hasWorkingTreeChanges: false,1496 hasWorkingTreeChanges: false,
1497 availability: "available",
1498 statusError: null,
1346 bindings: [1499 bindings: [
1347 {1500 {
1348 threadId,1501 threadId,
@@ -1354,6 +1507,7 @@ describe("t3_worktree_list", () => { · 5 unmodified lines
1354 callingThread: true,1507 callingThread: true,
1355 },1508 },
1356 ],1509 ],
1510 bindingCount: 1,
1357 },1511 },
1358 {1512 {
1359 path: worktreePath,1513 path: worktreePath,
@@ -1362,6 +1516,8 @@ describe("t3_worktree_list", () => { · 2 unmodified lines
1362 isRepo: true,1516 isRepo: true,
1363 isProjectRoot: false,1517 isProjectRoot: false,
1364 hasWorkingTreeChanges: true,1518 hasWorkingTreeChanges: true,
1519 availability: "available",
1520 statusError: null,
1365 bindings: [1521 bindings: [
1366 {1522 {
1367 threadId: otherThreadId,1523 threadId: otherThreadId,
@@ -1373,10 +1529,84 @@ describe("t3_worktree_list", () => { · 5 unmodified lines
1373 callingThread: false,1529 callingThread: false,
1374 },1530 },
1375 ],1531 ],
1532 bindingCount: 1,
1376 },1533 },
1377 ]);1534 ]);
1378 });1535 });
1379 });1536 });
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 });
1380});1610});
1381 1611
1382describe("t3_thread_checkout", () => {1612describe("t3_thread_checkout", () => {
@@ -1430,6 +1660,101 @@ describe("t3_thread_checkout", () => { · 47 unmodified lines
1430 });1660 });
1431 });1661 });
1432 1662
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
1433 it.effect("creates and checks out a new branch in the current workspace", () => {1758 it.effect("creates and checks out a new branch in the current workspace", () => {
1434 const harness = makeHarness({1759 const harness = makeHarness({
1435 thread: { branch: "dev", worktreePath: null },1760 thread: { branch: "dev", worktreePath: null },
@@ -1566,6 +1891,51 @@ describe("t3_thread_checkout", () => { · 130 unmodified lines
1566 });1891 });
1567 });1892 });
1568 1893
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
1569 it.effect("rejects dirty files before switching branches", () => {1939 it.effect("rejects dirty files before switching branches", () => {
1570 const harness = makeHarness({1940 const harness = makeHarness({
1571 thread: { branch: "dev", worktreePath: null },1941 thread: { branch: "dev", worktreePath: null },
@@ -1844,6 +2214,44 @@ describe("t3_thread_checkout", () => { · 272 unmodified lines
1844 });2214 });
1845 });2215 });
1846 2216
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
1847 it.effect("rejects switching the shared project root while another thread is active", () => {2255 it.effect("rejects switching the shared project root while another thread is active", () => {
1848 const harness = makeHarness({2256 const harness = makeHarness({
1849 thread: { branch: "dev", worktreePath: null },2257 thread: { branch: "dev", worktreePath: null },
@@ -1912,6 +2320,25 @@ describe("t3_thread_checkout", () => { · 62 unmodified lines
1912 });2320 });
1913 });2321 });
1914 2322
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
1915 it.effect("rolls the git branch back when the durable binding fails", () => {2342 it.effect("rolls the git branch back when the durable binding fails", () => {
1916 const harness = makeHarness({2343 const harness = makeHarness({
1917 thread: { branch: "dev", worktreePath: null },2344 thread: { branch: "dev", worktreePath: null },
@@ -1960,7 +2387,7 @@ describe("t3_thread_checkout", () => { · 42 unmodified lines
1960 refs: rootRefs,2387 refs: rootRefs,
1961 workspaceStatuses: { [workspaceRoot]: { branch: "dev" } },2388 workspaceStatuses: { [workspaceRoot]: { branch: "dev" } },
1962 dispatchFails: true,2389 dispatchFails: true,
1963 threadReadFailsOnRecheck: true,2390 threadReadFailsAfterDispatch: true,
1964 });2391 });
1965 return Effect.gen(function* () {2392 return Effect.gen(function* () {
1966 const exit = yield* Effect.exit(2393 const exit = yield* Effect.exit(
@@ -1982,6 +2409,32 @@ describe("t3_thread_checkout", () => { · 15 unmodified lines
1982 });2409 });
1983 });2410 });
1984 2411
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
1985 it.effect("rolls back when checkout reports failure after changing the branch", () => {2438 it.effect("rolls back when checkout reports failure after changing the branch", () => {
1986 const harness = makeHarness({2439 const harness = makeHarness({
1987 thread: { branch: "dev", worktreePath: null },2440 thread: { branch: "dev", worktreePath: null },
@@ -2009,7 +2462,7 @@ describe("t3_thread_checkout", () => { · 21 unmodified lines
2009 thread: { branch: "dev", worktreePath: null },2462 thread: { branch: "dev", worktreePath: null },
2010 refs: rootRefs,2463 refs: rootRefs,
2011 workspaceStatuses: { [workspaceRoot]: { branch: "dev" } },2464 workspaceStatuses: { [workspaceRoot]: { branch: "dev" } },
2012 localStatusFailsOnCall: 2,2465 localStatusFailsOnCall: 3,
2013 });2466 });
2014 return Effect.gen(function* () {2467 return Effect.gen(function* () {
2015 const exit = yield* Effect.exit(2468 const exit = yield* Effect.exit(
@@ -2091,6 +2544,59 @@ describe("t3_thread_checkout", () => { · 75 unmodified lines
2091 yield* Fiber.join(first);2544 yield* Fiber.join(first);
2092 }),2545 }),
2093 );2546 );
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 );
2094});2600});
2095 2601
2096describe("WorktreeMcpHandoffInput schema", () => {2602describe("WorktreeMcpHandoffInput schema", () => {
apps/server/src/mcp/WorktreeMcpService.ts+572−309
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
11 type WorktreeMcpContinuationStatus,11 type WorktreeMcpContinuationStatus,
12 type WorktreeMcpHandoffInput,12 type WorktreeMcpHandoffInput,
13 type WorktreeMcpHandoffResult,13 type WorktreeMcpHandoffResult,
14 type WorktreeMcpListInput,
14 type WorktreeMcpListResult,15 type WorktreeMcpListResult,
15 type WorktreeMcpSetupScriptStatus,16 type WorktreeMcpSetupScriptStatus,
16 type WorktreeMcpStatusResult,17 type WorktreeMcpStatusResult,
@@ -21,6 +22,7 @@ import * as Crypto from "effect/Crypto"; · 4 unmodified lines
21import * as Effect from "effect/Effect";22import * as Effect from "effect/Effect";
22import * as FileSystem from "effect/FileSystem";23import * as FileSystem from "effect/FileSystem";
23import * as Exit from "effect/Exit";24import * as Exit from "effect/Exit";
25import * as FileSystem from "effect/FileSystem";
24import * as Layer from "effect/Layer";26import * as Layer from "effect/Layer";
25import * as Option from "effect/Option";27import * as Option from "effect/Option";
26import * as Path from "effect/Path";28import * as Path from "effect/Path";
@@ -45,6 +47,7 @@ export class WorktreeMcpService extends Context.Service< · 18 unmodified lines
45 ) => Effect.Effect<WorktreeMcpStatusResult, WorktreeMcpFailure>;47 ) => Effect.Effect<WorktreeMcpStatusResult, WorktreeMcpFailure>;
46 readonly listWorktrees: (48 readonly listWorktrees: (
47 scope: McpInvocationScope,49 scope: McpInvocationScope,
50 input: WorktreeMcpListInput,
48 ) => Effect.Effect<WorktreeMcpListResult, WorktreeMcpFailure>;51 ) => Effect.Effect<WorktreeMcpListResult, WorktreeMcpFailure>;
49 readonly checkout: (52 readonly checkout: (
50 scope: McpInvocationScope,53 scope: McpInvocationScope,
@@ -76,6 +79,7 @@ const asOperationFailed = (prefix: string) => · 25 unmodified lines
76 79
77const make = Effect.gen(function* () {80const make = Effect.gen(function* () {
78 const crypto = yield* Crypto.Crypto;81 const crypto = yield* Crypto.Crypto;
82 const fileSystem = yield* FileSystem.FileSystem;
79 const path = yield* Path.Path;83 const path = yield* Path.Path;
80 const threadManagement = yield* ThreadManagementService;84 const threadManagement = yield* ThreadManagementService;
81 const projects = yield* ProjectService.ProjectService;85 const projects = yield* ProjectService.ProjectService;
@@ -135,10 +139,17 @@ const make = Effect.gen(function* () { · 53 unmodified lines
135 139
136 const normalizePath = (value: string) => path.normalize(path.resolve(value));140 const normalizePath = (value: string) => path.normalize(path.resolve(value));
137 141
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* (
139 thread: Pick<OrchestrationV2ThreadShell, "worktreePath">,148 thread: Pick<OrchestrationV2ThreadShell, "worktreePath">,
140 projectWorkspaceRoot: string,149 projectWorkspaceRoot: string,
141 ) => normalizePath(thread.worktreePath ?? projectWorkspaceRoot);150 ) {
151 return yield* canonicalizePath(thread.worktreePath ?? projectWorkspaceRoot);
152 });
142 153
143 const loadRefs = Effect.fn("WorktreeMcpService.loadRefs")(function* (154 const loadRefs = Effect.fn("WorktreeMcpService.loadRefs")(function* (
144 projectWorkspaceRoot: string,155 projectWorkspaceRoot: string,
@@ -146,14 +157,13 @@ const make = Effect.gen(function* () { · 1 unmodified line
146 ) {157 ) {
147 const refs: Array<VcsRef> = [];158 const refs: Array<VcsRef> = [];
148 let cursor: number | undefined;159 let cursor: number | undefined;
149 let firstPage = true;
150 do {160 do {
151 const page = yield* gitWorkflow161 const page = yield* gitWorkflow
152 .listRefs({162 .listRefs({
153 cwd: projectWorkspaceRoot,163 cwd: projectWorkspaceRoot,
154 refKind,164 refKind,
155 includeMatchingRemoteRefs: refKind === "all",165 includeMatchingRemoteRefs: refKind === "all",
156 refresh: firstPage,166 refresh: cursor === undefined,
157 limit: 200,167 limit: 200,
158 ...(cursor === undefined ? {} : { cursor }),168 ...(cursor === undefined ? {} : { cursor }),
159 })169 })
@@ -166,11 +176,18 @@ const make = Effect.gen(function* () { · 6 unmodified lines
166 }176 }
167 refs.push(...page.refs);177 refs.push(...page.refs);
168 cursor = page.nextCursor ?? undefined;178 cursor = page.nextCursor ?? undefined;
169 firstPage = false;
170 } while (cursor !== undefined);179 } while (cursor !== undefined);
171 return refs;180 return refs;
172 });181 });
173 182
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
174 const loadProjectThreads = (191 const loadProjectThreads = (
175 projectId: ProjectId,192 projectId: ProjectId,
176 ): Effect.Effect<ReadonlyArray<OrchestrationV2ThreadShell>, WorktreeMcpFailure> =>193 ): Effect.Effect<ReadonlyArray<OrchestrationV2ThreadShell>, WorktreeMcpFailure> =>
@@ -178,6 +195,61 @@ const make = Effect.gen(function* () { · 1 unmodified line
178 .listProjectThreads({ projectId, includeSubagents: true })195 .listProjectThreads({ projectId, includeSubagents: true })
179 .pipe(asOperationFailed(`Unable to list threads in project ${projectId}`));196 .pipe(asOperationFailed(`Unable to list threads in project ${projectId}`));
180 197
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
181 const readWorkspaceStatus = (workspacePath: string) =>253 const readWorkspaceStatus = (workspacePath: string) =>
182 gitWorkflow254 gitWorkflow
183 .invalidateLocalStatus(workspacePath)255 .invalidateLocalStatus(workspacePath)
@@ -265,17 +337,13 @@ const make = Effect.gen(function* () { · 81 unmodified lines
265 }337 }
266 338
267 const project = yield* loadProject(scope, projection.thread.projectId);339 const project = yield* loadProject(scope, projection.thread.projectId);
268 const projectCwd = project.workspaceRoot;340 const projectCwd = yield* canonicalizePath(project.workspaceRoot);
269 const sourceCwd = projection.thread.worktreePath ?? projectCwd;341 const sourceCwd = yield* canonicalizePath(projection.thread.worktreePath ?? projectCwd);
270 342
271 if (projection.thread.worktreePath !== null) {343 if (projection.thread.worktreePath !== null) {
272 const projectRefs = yield* loadRefs(projectCwd, "local");344 const inventory = yield* loadWorktrees(projectCwd);
273 const projectWorktreePaths = new Set(345 const projectWorktreePaths = new Set(inventory.worktrees.map((worktree) => worktree.path));
274 projectRefs.flatMap((ref) =>346 if (!projectWorktreePaths.has(sourceCwd)) {
275 ref.worktreePath === null ? [] : [normalizePath(ref.worktreePath)],
276 ),
277 );
278 if (!projectWorktreePaths.has(normalizePath(projection.thread.worktreePath))) {
279 return yield* failure(347 return yield* failure(
280 "scope_mismatch",348 "scope_mismatch",
281 `Thread worktree '${projection.thread.worktreePath}' is not registered in project '${projection.thread.projectId}'.`,349 `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
631 yield* requireCapability(scope);699 yield* requireCapability(scope);
632 const projection = yield* loadThread(scope);700 const projection = yield* loadThread(scope);
633 const project = yield* loadProject(scope, projection.thread.projectId);701 const project = yield* loadProject(scope, projection.thread.projectId);
634 const projectWorkspaceRoot = normalizePath(project.workspaceRoot);702 const projectWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot);
635 const workspacePath = normalizePath(projection.thread.worktreePath ?? projectWorkspaceRoot);703 const workspacePath = normalizePath(projection.thread.worktreePath ?? projectWorkspaceRoot);
636 const [defaultStartFromOrigin, actual, refs] = yield* Effect.all(704 const [defaultStartFromOrigin, actual, projectInventory, workspaceInventory] =
637 [705 yield* Effect.all(
638 readDefaultStartFromOrigin,706 [
639 readWorkspaceStatus(workspacePath),707 readDefaultStartFromOrigin,
640 loadRefs(projectWorkspaceRoot, "local"),708 readWorkspaceStatus(workspacePath),
641 ],709 loadWorktrees(projectWorkspaceRoot),
642 { concurrency: 3 },710 loadWorktrees(workspacePath),
643 );711 ],
644 const knownWorkspacePaths = new Set([712 { concurrency: 4 },
645 projectWorkspaceRoot,713 );
646 ...refs.flatMap((ref) =>714 const canonicalWorkspacePath = yield* canonicalizePath(workspacePath);
647 ref.isRemote === true || ref.worktreePath === null715 const physicalWorkspacePath = workspaceInventory.currentWorktreeRoot;
648 ? []716 const agreement =
649 : [normalizePath(ref.worktreePath)],717 workspaceInventory.repositoryCommonDir !== projectInventory.repositoryCommonDir ||
650 ),718 physicalWorkspacePath === null ||
651 ]);719 !projectInventory.worktrees.some((worktree) => worktree.path === physicalWorkspacePath)
652 const agreement = !knownWorkspacePaths.has(workspacePath)720 ? "workspace_missing"
653 ? "workspace_missing"721 : !actual.isRepo
654 : !actual.isRepo722 ? "not_repository"
655 ? "not_repository"723 : actual.refName !== projection.thread.branch
656 : actual.refName !== projection.thread.branch724 ? "branch_mismatch"
657 ? "branch_mismatch"725 : "in_sync";
658 : "in_sync";
659 726
660 const result: WorktreeMcpStatusResult = {727 const result: WorktreeMcpStatusResult = {
661 attached: projection.thread.worktreePath !== null,728 attached: projection.thread.worktreePath !== null,
@@ -668,7 +735,7 @@ const make = Effect.gen(function* () { · 6 unmodified lines
668 worktreePath: projection.thread.worktreePath,735 worktreePath: projection.thread.worktreePath,
669 },736 },
670 actualWorkspace: {737 actualWorkspace: {
671 workspacePath,738 workspacePath: physicalWorkspacePath ?? canonicalWorkspacePath,
672 isRepo: actual.isRepo,739 isRepo: actual.isRepo,
673 branch: actual.refName,740 branch: actual.refName,
674 hasWorkingTreeChanges: actual.hasWorkingTreeChanges,741 hasWorkingTreeChanges: actual.hasWorkingTreeChanges,
@@ -879,12 +946,12 @@ const make = Effect.gen(function* () { · 204 unmodified lines
879 }946 }
880 947
881 const project = yield* loadProject(scope, projection.thread.projectId);948 const project = yield* loadProject(scope, projection.thread.projectId);
882 const projectWorkspaceRoot = normalizePath(project.workspaceRoot);949 const projectWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot);
883 const currentWorkspacePath = normalizePath(950 const recordedWorkspacePath = yield* canonicalizePath(
884 projection.thread.worktreePath ?? projectWorkspaceRoot,951 projection.thread.worktreePath ?? projectWorkspaceRoot,
885 );952 );
886 if (input.target.type === "new_worktree") {953 if (input.target.type === "new_worktree") {
887 const previousActual = yield* readWorkspaceStatus(currentWorkspacePath);954 const previousActual = yield* readWorkspaceStatus(recordedWorkspacePath);
888 const handoff = yield* performHandoff(955 const handoff = yield* performHandoff(
889 scope,956 scope,
890 {957 {
@@ -903,10 +970,9 @@ const make = Effect.gen(function* () { · 12 unmodified lines
903 },970 },
904 projection,971 projection,
905 );972 );
906 const actual = yield* readWorkspaceStatus(handoff.worktreePath);
907 return {973 return {
908 previous: {974 previous: {
909 workspacePath: currentWorkspacePath,975 workspacePath: recordedWorkspacePath,
910 recordedBranch: projection.thread.branch,976 recordedBranch: projection.thread.branch,
911 recordedWorktreePath: projection.thread.worktreePath,977 recordedWorktreePath: projection.thread.worktreePath,
912 actualBranch: previousActual.refName,978 actualBranch: previousActual.refName,
@@ -915,17 +981,35 @@ const make = Effect.gen(function* () { · 2 unmodified lines
915 workspacePath: handoff.worktreePath,981 workspacePath: handoff.worktreePath,
916 recordedBranch: handoff.branch,982 recordedBranch: handoff.branch,
917 recordedWorktreePath: handoff.worktreePath,983 recordedWorktreePath: handoff.worktreePath,
918 actualBranch: actual.refName,984 actualBranch: handoff.branch,
919 },985 },
920 checkoutAction: "created",986 checkoutAction: "created",
921 workspaceChanged: true,987 workspaceChanged: true,
922 branchChanged: previousActual.refName !== actual.refName,988 branchChanged: previousActual.refName !== handoff.branch,
923 continuation: handoff.continuation,989 continuation: handoff.continuation,
924 setupScript: handoff.setupScript,990 setupScript: handoff.setupScript,
925 callerTurnEnds: true,991 callerTurnEnds: true,
926 note: handoff.note,992 note: handoff.note,
927 } satisfies WorktreeMcpCheckoutResult;993 } satisfies WorktreeMcpCheckoutResult;
928 }994 }
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 }
929 const [refs, threads, previousActual] = yield* Effect.all(1013 const [refs, threads, previousActual] = yield* Effect.all(
930 [1014 [
931 loadRefs(projectWorkspaceRoot),1015 loadRefs(projectWorkspaceRoot),
@@ -936,12 +1020,7 @@ const make = Effect.gen(function* () { · 4 unmodified lines
936 );1020 );
937 1021
938 const localRefs = refs.filter((ref) => ref.isRemote !== true);1022 const localRefs = refs.filter((ref) => ref.isRemote !== true);
939 const workspacePaths = new Set([1023 const workspacePaths = new Set(inventory.worktrees.map((worktree) => worktree.path));
940 projectWorkspaceRoot,
941 ...localRefs.flatMap((ref) =>
942 ref.worktreePath === null ? [] : [normalizePath(ref.worktreePath)],
943 ),
944 ]);
945 const localRefByName = new Map(localRefs.map((ref) => [ref.name, ref]));1024 const localRefByName = new Map(localRefs.map((ref) => [ref.name, ref]));
946 const remoteRefByName = new Map(1025 const remoteRefByName = new Map(
947 refs.filter((ref) => ref.isRemote === true).map((ref) => [ref.name, ref]),1026 refs.filter((ref) => ref.isRemote === true).map((ref) => [ref.name, ref]),
@@ -954,14 +1033,17 @@ const make = Effect.gen(function* () { · 6 unmodified lines
954 1033
955 switch (input.target.type) {1034 switch (input.target.type) {
956 case "worktree": {1035 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));
958 if (1039 if (
959 targetWorkspacePath === projectWorkspaceRoot ||1040 targetInventory.repositoryCommonDir !== inventory.repositoryCommonDir ||
1041 targetWorkspacePath === projectWorktreeRoot ||
960 !workspacePaths.has(targetWorkspacePath)1042 !workspacePaths.has(targetWorkspacePath)
961 ) {1043 ) {
962 return yield* failure(1044 return yield* failure(
963 "scope_mismatch",1045 "scope_mismatch",
964 targetWorkspacePath === projectWorkspaceRoot1046 targetWorkspacePath === projectWorktreeRoot
965 ? "Use target.type='project_root' to return to the project's main checkout."1047 ? "Use target.type='project_root' to return to the project's main checkout."
966 : `Worktree '${input.target.path}' does not belong to project '${projection.thread.projectId}'. Call t3_worktree_list and choose one of its paths.`,1048 : `Worktree '${input.target.path}' does not belong to project '${projection.thread.projectId}'. Call t3_worktree_list and choose one of its paths.`,
967 );1049 );
@@ -969,7 +1051,7 @@ const make = Effect.gen(function* () { · 1 unmodified line
969 break;1051 break;
970 }1052 }
971 case "project_root": {1053 case "project_root": {
972 targetWorkspacePath = projectWorkspaceRoot;1054 targetWorkspacePath = projectWorktreeRoot;
973 requestedBranch = input.target.branch;1055 requestedBranch = input.target.branch;
974 createBranch = input.target.create ?? false;1056 createBranch = input.target.create ?? false;
975 if (createBranch && requestedBranch === undefined) {1057 if (createBranch && requestedBranch === undefined) {
@@ -1000,15 +1082,15 @@ const make = Effect.gen(function* () { · 24 unmodified lines
1000 const selectedWorktreePath =1082 const selectedWorktreePath =
1001 selectedRef?.isRemote === true || selectedRef?.worktreePath == null1083 selectedRef?.isRemote === true || selectedRef?.worktreePath == null
1002 ? null1084 ? null
1003 : normalizePath(selectedRef.worktreePath);1085 : selectedRef.worktreePath;
1004 targetWorkspacePath =1086 targetWorkspacePath =
1005 workspace === "project_root"1087 workspace === "project_root"
1006 ? projectWorkspaceRoot1088 ? projectWorktreeRoot
1007 : workspace === "current"1089 : workspace === "current"
1008 ? currentWorkspacePath1090 ? currentWorkspacePath
1009 : (selectedWorktreePath ??1091 : (selectedWorktreePath ??
1010 (projection.thread.worktreePath !== null && selectedRef?.isDefault === true1092 (projection.thread.worktreePath !== null && selectedRef?.isDefault === true
1011 ? projectWorkspaceRoot1093 ? projectWorktreeRoot
1012 : currentWorkspacePath));1094 : currentWorkspacePath));
1013 break;1095 break;
1014 }1096 }
@@ -1043,7 +1125,7 @@ const make = Effect.gen(function* () { · 28 unmodified lines
1043 const selectedWorktreePath =1125 const selectedWorktreePath =
1044 selectedRef?.isRemote === true || selectedRef?.worktreePath == null1126 selectedRef?.isRemote === true || selectedRef?.worktreePath == null
1045 ? null1127 ? null
1046 : normalizePath(selectedRef.worktreePath);1128 : selectedRef.worktreePath;
1047 if (1129 if (
1048 !createBranch &&1130 !createBranch &&
1049 requestedBranch !== undefined &&1131 requestedBranch !== undefined &&
@@ -1058,16 +1140,18 @@ const make = Effect.gen(function* () { · 8 unmodified lines
1058 1140
1059 const shouldMutateCheckout =1141 const shouldMutateCheckout =
1060 requestedBranch !== undefined &&1142 requestedBranch !== undefined &&
1061 (createBranch ||1143 (createBranch || selectedRef?.isRemote === true || targetBefore.refName !== requestedBranch);
1062 (selectedRef?.isRemote === true1144 const threadWorkspaces = yield* Effect.forEach(threads, (thread) =>
1063 ? targetBefore.refName !== requestedBranch &&1145 threadWorkspacePath(thread, projectWorktreeRoot).pipe(
1064 targetBefore.refName !== requestedBranch.replace(/^[^/]+\//, "")1146 Effect.map((workspacePath) => [thread, workspacePath] as const),
1065 : targetBefore.refName !== requestedBranch));1147 ),
1066 const otherBindings = threads.filter(
1067 (thread) =>
1068 thread.id !== scope.threadId &&
1069 threadWorkspacePath(thread, projectWorkspaceRoot) === targetWorkspacePath,
1070 );1148 );
1149 const otherBindings = threadWorkspaces
1150 .filter(
1151 ([thread, workspacePath]) =>
1152 thread.id !== scope.threadId && workspacePath === targetWorkspacePath,
1153 )
1154 .map(([thread]) => thread);
1071 const activeBinding = otherBindings.find((thread) => thread.activeRunId !== null);1155 const activeBinding = otherBindings.find((thread) => thread.activeRunId !== null);
1072 if ((targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) && activeBinding) {1156 if ((targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) && activeBinding) {
1073 return yield* failure(1157 return yield* failure(
@@ -1076,7 +1160,7 @@ const make = Effect.gen(function* () { · 2 unmodified lines
1076 );1160 );
1077 }1161 }
1078 if (1162 if (
1079 targetWorkspacePath !== projectWorkspaceRoot &&1163 targetWorkspacePath !== projectWorktreeRoot &&
1080 (targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) &&1164 (targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) &&
1081 otherBindings.length > 01165 otherBindings.length > 0
1082 ) {1166 ) {
@@ -1086,7 +1170,7 @@ const make = Effect.gen(function* () { · 3 unmodified lines
1086 );1170 );
1087 }1171 }
1088 if (1172 if (
1089 targetWorkspacePath === projectWorkspaceRoot &&1173 targetWorkspacePath === projectWorktreeRoot &&
1090 shouldMutateCheckout &&1174 shouldMutateCheckout &&
1091 otherBindings.length > 01175 otherBindings.length > 0
1092 ) {1176 ) {
@@ -1103,285 +1187,463 @@ const make = Effect.gen(function* () { · 10 unmodified lines
1103 }1187 }
1104 1188
1105 const ids = yield* transitionIds(scope, "checkout");1189 const ids = yield* transitionIds(scope, "checkout");
1190 const workspaceGuardKey = `workspace:${inventory.repositoryCommonDir}:${targetWorkspacePath}`;
1106 return yield* Effect.uninterruptibleMask(() =>1191 return yield* Effect.uninterruptibleMask(() =>
1107 Effect.gen(function* () {1192 Effect.suspend(() => {
1108 let checkoutAction: WorktreeMcpCheckoutResult["checkoutAction"] =1193 if (workspaceTransitionsInFlight.has(workspaceGuardKey)) {
1109 targetWorkspacePath === currentWorkspacePath ? "unchanged" : "reused";1194 return Effect.fail(
1110 let createdBranch: string | null = null;1195 failure(
1111 1196 "checkout_in_progress",
1112 if (shouldMutateCheckout && requestedBranch !== undefined) {1197 `Another workspace transition is already in progress for '${targetWorkspacePath}'.`,
1113 if (createBranch) {1198 ),
1114 yield* gitWorkflow1199 );
1115 .createRef({1200 }
1116 cwd: targetWorkspacePath,1201 workspaceTransitionsInFlight.add(workspaceGuardKey);
1117 refName: requestedBranch,1202 return Effect.gen(function* () {
1118 switchRef: false,1203 const [latestProjection, latestInventory, latestBindings, latestTargetBefore] =
1119 })1204 yield* Effect.all(
1120 .pipe(asOperationFailed(`Unable to create branch '${requestedBranch}'`));1205 [
1121 createdBranch = requestedBranch;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 );
1122 }1246 }
1123 const switchExit = yield* Effect.exit(1247 const latestOtherBindings = latestBindings
1124 gitWorkflow.switchRef({ cwd: targetWorkspacePath, refName: requestedBranch }),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,
1125 );1255 );
1126 if (Exit.isFailure(switchExit)) {1256 if (
1127 const afterFailedSwitchExit = yield* Effect.exit(1257 (targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) &&
1128 readWorkspaceStatus(targetWorkspacePath),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 }),
1129 );1304 );
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 }
1131 return yield* failure(1366 return yield* failure(
1132 "partial_failure",1367 "operation_failed",
1133 `Branch checkout failed and the resulting Git state could not be verified: ${errorMessage(Cause.squash(switchExit.cause))}`,1368 `Unable to check out '${requestedBranch}': ${errorMessage(Cause.squash(switchExit.cause))}`,
1134 {
1135 workspacePath: targetWorkspacePath,
1136 recordedBranch: projection.thread.branch,
1137 actualBranch: null,
1138 rollback: "not_possible",
1139 },
1140 );1369 );
1141 }1370 }
1142 const afterFailedSwitch = afterFailedSwitchExit.value;1371 resolvedBranch = switchExit.value.refName;
1143 const checkoutChanged = afterFailedSwitch.refName !== targetBefore.refName;1372 if (resolvedBranch === null) {
1144 if (checkoutChanged && targetBefore.refName === null) {
1145 return yield* failure(1373 return yield* failure(
1146 "partial_failure",1374 "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.`,
1148 {1376 {
1149 workspacePath: targetWorkspacePath,1377 workspacePath: targetWorkspacePath,
1150 recordedBranch: projection.thread.branch,1378 recordedBranch: projection.thread.branch,
1151 actualBranch: afterFailedSwitch.refName,1379 actualBranch: null,
1152 rollback: "not_possible",1380 rollback: "not_possible",
1153 },1381 },
1154 );1382 );
1155 }1383 }
1156 const rollback = checkoutChanged1384 checkoutAction = createBranch ? "created" : "switched";
1157 ? gitWorkflow.switchRef({1385 }
1158 cwd: targetWorkspacePath,1386
1159 refName: targetBefore.refName!,1387 const actualExit = yield* Effect.exit(readWorkspaceStatus(targetWorkspacePath));
1160 })1388 if (Exit.isFailure(actualExit)) {
1161 : Effect.void;1389 const detail = errorMessage(Cause.squash(actualExit.cause));
1162 const cleanup = rollback.pipe(1390 if (checkoutAction === "switched" || checkoutAction === "created") {
1163 Effect.andThen(1391 if (targetBefore.refName === null) {
1164 createdBranch === null1392 return yield* failure(
1165 ? Effect.void1393 "partial_failure",
1166 : gitWorkflow.deleteLocalBranch({1394 `Git checkout completed but its resulting state could not be verified: ${detail}`,
1167 cwd: targetWorkspacePath,1395 {
1168 refName: createdBranch,1396 workspacePath: targetWorkspacePath,
1169 force: true,1397 recordedBranch: projection.thread.branch,
1170 }),1398 actualBranch: null,
1171 ),1399 rollback: "not_possible",
1172 );1400 },
1173 const cleanupExit = yield* Effect.exit(cleanup);1401 );
1174 if (Exit.isFailure(cleanupExit)) {1402 }
1175 const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath);1403 const rollbackExit = yield* Effect.exit(
1176 return yield* failure(1404 gitWorkflow
1177 "partial_failure",1405 .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName })
1178 `Branch checkout failed and rollback also failed: ${errorMessage(Cause.squash(switchExit.cause))}`,1406 .pipe(
1179 {1407 Effect.andThen(
1180 workspacePath: targetWorkspacePath,1408 createdBranch === null
1181 recordedBranch: projection.thread.branch,1409 ? Effect.void
1182 actualBranch,1410 : gitWorkflow.deleteLocalBranch({
1183 rollback: "failed",1411 cwd: targetWorkspacePath,
1184 },1412 refName: createdBranch,
1413 force: true,
1414 }),
1415 ),
1416 ),
1185 );1417 );
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 }
1186 }1431 }
1187 return yield* failure(1432 return yield* failure(
1188 "operation_failed",1433 "operation_failed",
1189 `Unable to check out '${requestedBranch}': ${errorMessage(Cause.squash(switchExit.cause))}`,1434 `Unable to verify the selected checkout '${targetWorkspacePath}': ${detail}`,
1190 );1435 );
1191 }1436 }
1192 checkoutAction = createBranch ? "created" : "switched";1437 const actual = actualExit.value;
1193 }1438 if (
1194 1439 (checkoutAction === "switched" || checkoutAction === "created") &&
1195 const actualExit = yield* Effect.exit(readWorkspaceStatus(targetWorkspacePath));1440 actual.refName !== resolvedBranch
1196 if (Exit.isFailure(actualExit)) {1441 ) {
1197 const detail = errorMessage(Cause.squash(actualExit.cause));1442 return yield* failure(
1198 if (checkoutAction === "switched" || checkoutAction === "created") {1443 "partial_failure",
1199 if (targetBefore.refName === null) {1444 `Git resolved '${requestedBranch}' to '${resolvedBranch}' but the checkout now reports '${actual.refName ?? "detached HEAD"}'. The durable thread binding was not changed.`,
1200 return yield* failure(1445 {
1201 "partial_failure",1446 workspacePath: targetWorkspacePath,
1202 `Git checkout completed but its resulting state could not be verified: ${detail}`,1447 recordedBranch: projection.thread.branch,
1203 {1448 actualBranch: actual.refName,
1204 workspacePath: targetWorkspacePath,1449 rollback: "not_possible",
1205 recordedBranch: projection.thread.branch,1450 },
1206 actualBranch: null,1451 );
1207 rollback: "not_possible",1452 }
1208 },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 },
1209 );1479 );
1210 }1480 const anotherOwner = latestBindings.some(
1211 const rollbackExit = yield* Effect.exit(1481 ([thread, workspacePath]) =>
1212 gitWorkflow1482 thread.id !== scope.threadId && workspacePath === targetWorkspacePath,
1213 .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName })1483 );
1214 .pipe(1484 const callerStillInitial =
1215 Effect.andThen(1485 Exit.isSuccess(callerProjectionExit) &&
1216 createdBranch === null1486 callerProjectionExit.value.thread.branch === projection.thread.branch &&
1217 ? Effect.void1487 callerProjectionExit.value.thread.worktreePath === projection.thread.worktreePath;
1218 : gitWorkflow.deleteLocalBranch({1488 if (latestStatus.refName !== actual.refName || anotherOwner || !callerStillInitial) {
1219 cwd: targetWorkspacePath,1489 return "not_possible" as const;
1220 refName: createdBranch,1490 }
1221 force: true,1491 const rollbackExit = yield* Effect.exit(
1222 }),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 ),
1223 ),1504 ),
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 },
1237 );1505 );
1238 }1506 return Exit.isSuccess(rollbackExit) ? ("rolled_back" as const) : ("failed" as const);
1239 }1507 },
1240 return yield* failure(
1241 "operation_failed",
1242 `Unable to verify the selected checkout '${targetWorkspacePath}': ${detail}`,
1243 );1508 );
1244 }1509
1245 const actual = actualExit.value;1510 if (bindingChanged) {
1246 const nextBranch = actual.refName;1511 const preCommitProjectionExit = yield* Effect.exit(loadThread(scope));
1247 const workspaceChanged = targetWorkspacePath !== currentWorkspacePath;1512 const preCommitProjection = Exit.isSuccess(preCommitProjectionExit)
1248 const nextWorktreePath = workspaceChanged1513 ? preCommitProjectionExit.value
1249 ? targetWorkspacePath === projectWorkspaceRoot1514 : null;
1250 ? null1515 const metadataChanged =
1251 : targetWorkspacePath1516 preCommitProjection === null ||
1252 : projection.thread.worktreePath;1517 preCommitProjection.thread.branch !== projection.thread.branch ||
1253 const bindingChanged =1518 preCommitProjection.thread.worktreePath !== projection.thread.worktreePath ||
1254 nextBranch !== projection.thread.branch ||1519 preCommitProjection.thread.archivedAt !== projection.thread.archivedAt;
1255 nextWorktreePath !== projection.thread.worktreePath;1520 if (metadataChanged) {
1256 1521 const rollback = yield* rollbackOwnedCheckout();
1257 if (bindingChanged) {1522 if (rollback === "failed" || rollback === "not_possible") {
1258 const dispatchExit = yield* Effect.exit(1523 return yield* failure(
1259 threadManagement.dispatch({1524 "partial_failure",
1260 type: "thread.metadata.update",1525 `The thread changed or disappeared after Git checkout, so its durable binding was not updated and Git rollback was ${rollback === "failed" ? "unsuccessful" : "unsafe"}.`,
1261 commandId: ids.commandId,1526 {
1262 threadId: scope.threadId,1527 workspacePath: targetWorkspacePath,
1263 branch: nextBranch,1528 recordedBranch: projection.thread.branch,
1264 worktreePath: nextWorktreePath,1529 actualBranch: yield* readWorkspaceBranchOrNull(targetWorkspacePath),
1265 expectedBranch: projection.thread.branch,1530 rollback: rollback === "failed" ? "failed" : "not_possible",
1266 expectedWorktreePath: projection.thread.worktreePath,1531 },
1267 }),1532 );
1268 );1533 }
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)) {
1273 return yield* failure(1534 return yield* failure(
1274 "partial_failure",1535 "checkout_in_progress",
1275 `The durable binding update reported a failure and its outcome could not be verified: ${dispatchDetail}`,1536 `Thread '${scope.threadId}' changed or disappeared before the workspace binding committed. Git state was left unchanged.`,
1276 {
1277 workspacePath: targetWorkspacePath,
1278 recordedBranch: projection.thread.branch,
1279 actualBranch: actual.refName,
1280 rollback: "not_possible",
1281 },
1282 );1537 );
1283 }1538 }
1284 const bindingAfterDispatch = bindingAfterDispatchExit.value.thread;1539 const dispatchExit = yield* Effect.exit(
1285 const bindingCommitted =1540 threadManagement.dispatch({
1286 bindingAfterDispatch.branch === nextBranch &&1541 type: "thread.metadata.update",
1287 bindingAfterDispatch.worktreePath === nextWorktreePath;1542 commandId: ids.commandId,
1288 if (bindingCommitted) {1543 threadId: scope.threadId,
1289 yield* Effect.logWarning(1544 branch: nextBranch,
1290 "workspace binding dispatch reported failure after the binding committed",1545 worktreePath: nextWorktreePath,
1291 {1546 expectedBranch: projection.thread.branch,
1292 threadId: scope.threadId,1547 expectedWorktreePath: projection.thread.worktreePath,
1293 workspacePath: targetWorkspacePath,1548 }),
1294 detail: dispatchDetail,1549 );
1295 },1550 if (Exit.isFailure(dispatchExit)) {
1296 );1551 const dispatchDetail = errorMessage(Cause.squash(dispatchExit.cause));
1297 } else {1552 const bindingAfterDispatchExit = yield* Effect.exit(loadThread(scope));
1298 if (checkoutAction === "switched" || checkoutAction === "created") {1553 if (Exit.isFailure(bindingAfterDispatchExit)) {
1299 if (targetBefore.refName === null) {1554 return yield* failure(
1300 return yield* failure(1555 "partial_failure",
1301 "partial_failure",1556 `The durable binding update reported a failure and its outcome could not be verified: ${dispatchDetail}`,
1302 `Git checkout completed but the durable thread binding failed: ${dispatchDetail}`,1557 {
1303 {1558 workspacePath: targetWorkspacePath,
1304 workspacePath: targetWorkspacePath,1559 recordedBranch: projection.thread.branch,
1305 recordedBranch: projection.thread.branch,1560 actualBranch: actual.refName,
1306 actualBranch: actual.refName,1561 rollback: "not_possible",
1307 rollback: "not_possible",1562 },
1308 },1563 );
1309 );1564 }
1310 }1565 const bindingAfterDispatch = bindingAfterDispatchExit.value.thread;
1311 const rollbackExit = yield* Effect.exit(1566 const bindingCommitted =
1312 gitWorkflow1567 bindingAfterDispatch.branch === nextBranch &&
1313 .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName })1568 bindingAfterDispatch.worktreePath === nextWorktreePath;
1314 .pipe(1569 if (bindingCommitted) {
1315 Effect.andThen(1570 yield* Effect.logWarning(
1316 createdBranch === null1571 "workspace binding dispatch reported failure after the binding committed",
1317 ? Effect.void1572 {
1318 : gitWorkflow.deleteLocalBranch({1573 threadId: scope.threadId,
1319 cwd: targetWorkspacePath,1574 workspacePath: targetWorkspacePath,
1320 refName: createdBranch,1575 detail: dispatchDetail,
1321 force: true,1576 },
1322 }),
1323 ),
1324 ),
1325 );1577 );
1326 if (Exit.isFailure(rollbackExit)) {1578 } else {
1327 const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath);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") {
1328 return yield* failure(1586 return yield* failure(
1329 "partial_failure",1587 "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}`,
1331 {1589 {
1332 workspacePath: targetWorkspacePath,1590 workspacePath: targetWorkspacePath,
1333 recordedBranch: projection.thread.branch,1591 recordedBranch: bindingAfterDispatch.branch,
1334 actualBranch,1592 actualBranch: yield* readWorkspaceBranchOrNull(targetWorkspacePath),
1335 rollback: "failed",1593 rollback: rollback === "failed" ? "failed" : "not_possible",
1336 },1594 },
1337 );1595 );
1338 }1596 }
1597 return yield* failure(
1598 "operation_failed",
1599 `Unable to update the durable thread workspace: ${dispatchDetail}`,
1600 );
1339 }1601 }
1340 return yield* failure(
1341 "operation_failed",
1342 `Unable to update the durable thread workspace: ${dispatchDetail}`,
1343 );
1344 }1602 }
1345 }1603 }
1346 }
1347 1604
1348 const continuation = workspaceChanged1605 const continuation = workspaceChanged
1349 ? yield* queueContinuation({1606 ? yield* queueContinuation({
1350 scope,1607 scope,
1351 projection,1608 projection,
1352 prompt: input.continuationPrompt,1609 prompt: input.continuationPrompt,
1353 commandId: ids.continuationCommandId,1610 commandId: ids.continuationCommandId,
1354 messageId: ids.continuationMessageId,1611 messageId: ids.continuationMessageId,
1355 workspacePath: targetWorkspacePath,1612 workspacePath: targetWorkspacePath,
1356 })1613 })
1357 : ({ status: "skipped" } as const);1614 : ({ status: "skipped" } as const);
1358 const previous = {1615 const previous = {
1359 workspacePath: currentWorkspacePath,1616 workspacePath: currentWorkspacePath,
1360 recordedBranch: projection.thread.branch,1617 recordedBranch: projection.thread.branch,
1361 recordedWorktreePath: projection.thread.worktreePath,1618 recordedWorktreePath: projection.thread.worktreePath,
1362 actualBranch: previousActual.refName,1619 actualBranch: previousActual.refName,
1363 };1620 };
1364 const current = {1621 const current = {
1365 workspacePath: targetWorkspacePath,1622 workspacePath: targetWorkspacePath,
1366 recordedBranch: nextBranch,1623 recordedBranch: nextBranch,
1367 recordedWorktreePath: nextWorktreePath,1624 recordedWorktreePath: nextWorktreePath,
1368 actualBranch: actual.refName,1625 actualBranch: actual.refName,
1369 };1626 };
1370 return {1627 return {
1371 previous,1628 previous,
1372 current,1629 current,
1373 checkoutAction,1630 checkoutAction,
1374 workspaceChanged,1631 workspaceChanged,
1375 branchChanged: previousActual.refName !== actual.refName,1632 branchChanged: previousActual.refName !== actual.refName,
1376 continuation,1633 continuation,
1377 setupScript: { status: "skipped" },1634 setupScript: { status: "skipped" },
1378 callerTurnEnds: workspaceChanged,1635 callerTurnEnds: workspaceChanged,
1379 note: workspaceChanged1636 note: workspaceChanged
1380 ? continuation.status === "scheduled"1637 ? 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."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."
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."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."
1383 : "Checkout and durable thread binding completed without changing the provider session workspace.",1640 : "Checkout and durable thread binding completed without changing the provider session workspace.",
1384 } satisfies WorktreeMcpCheckoutResult;1641 } satisfies WorktreeMcpCheckoutResult;
1642 }).pipe(
1643 Effect.ensuring(
1644 Effect.sync(() => workspaceTransitionsInFlight.delete(workspaceGuardKey)),
1645 ),
1646 );
1385 }),1647 }),
1386 );1648 );
1387 });1649 });
@@ -1415,6 +1677,7 @@ export const layer: Layer.Layer< · 27 unmodified lines
1415 WorktreeMcpService,1677 WorktreeMcpService,
1416 never,1678 never,
1417 | Crypto.Crypto1679 | Crypto.Crypto
1680 | FileSystem.FileSystem
1418 | Path.Path1681 | Path.Path
1419 | ThreadManagementService1682 | ThreadManagementService
1420 | ProjectService.ProjectService1683 | ProjectService.ProjectService
apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts+164−0
@@ -15,6 +15,7 @@ import { · 14 unmodified lines
15 ThreadId,15 ThreadId,
16} from "@t3tools/contracts";16} from "@t3tools/contracts";
17import * as DateTime from "effect/DateTime";17import * as DateTime from "effect/DateTime";
18import * as Deferred from "effect/Deferred";
18import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
19import * as Queue from "effect/Queue";20import * as Queue from "effect/Queue";
20import * as Ref from "effect/Ref";21import * as Ref from "effect/Ref";
@@ -84,6 +85,14 @@ interface RestartAdapterState { · 63 unmodified lines
84function makeRestartAdapter(85function makeRestartAdapter(
85 state: Ref.Ref<RestartAdapterState>,86 state: Ref.Ref<RestartAdapterState>,
86 sessionCapabilities: OrchestrationV2ProviderCapabilities = pooledCapabilities,87 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 },
87): ProviderAdapterV2Shape {96): ProviderAdapterV2Shape {
88 return {97 return {
89 instanceId: providerInstanceId,98 instanceId: providerInstanceId,
@@ -224,6 +233,12 @@ function makeRestartAdapter( · 134 unmodified lines
224 },233 },
225 ],234 ],
226 }));235 }));
236 if (observeTurnStart !== undefined) {
237 yield* observeTurnStart({
238 model: input.modelSelection.model,
239 cwd: input.runtimePolicy.cwd,
240 });
241 }
227 const active = {242 const active = {
228 input,243 input,
229 providerTurnId: ProviderTurnId.make(`provider-turn:${input.attemptId}`),244 providerTurnId: ProviderTurnId.make(`provider-turn:${input.attemptId}`),
@@ -259,6 +274,10 @@ function makeRestartAdapter( · 29 unmodified lines
259 Effect.gen(function* () {274 Effect.gen(function* () {
260 const active = (yield* Ref.get(state)).activeTurn;275 const active = (yield* Ref.get(state)).activeTurn;
261 if (active !== null) {276 if (active !== null) {
277 if (interruptGate !== undefined) {
278 yield* Queue.offer(interruptGate.requested, undefined);
279 yield* Deferred.await(interruptGate.release);
280 }
262 const updatedAt = yield* DateTime.now;281 const updatedAt = yield* DateTime.now;
263 yield* Queue.offer(events, {282 yield* Queue.offer(events, {
264 type: "provider_thread.updated",283 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
529 ),548 ),
530);549);
531 550
551it.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
532it.live("detaches the old provider session after an active provider handoff", () =>696it.live("detaches the old provider session after an active provider handoff", () =>
533 Effect.scoped(697 Effect.scoped(
534 Effect.gen(function* () {698 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
1560 assert.equal(result.branch, current);1560 assert.equal(result.branch, current);
1561 }),1561 }),
1562 );1562 );
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 );
1563 });1590 });
1564 1591
1565 describe("worktree operations", () => {1592 describe("worktree operations", () => {