| @@ -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 |
| 21 | import * as Effect from "effect/Effect"; | 22 | import * as Effect from "effect/Effect"; |
| 22 | import * as FileSystem from "effect/FileSystem"; | 23 | import * as FileSystem from "effect/FileSystem"; |
| 23 | import * as Exit from "effect/Exit"; | 24 | import * as Exit from "effect/Exit"; |
| | 25 | import * as FileSystem from "effect/FileSystem"; |
| 24 | import * as Layer from "effect/Layer"; | 26 | import * as Layer from "effect/Layer"; |
| 25 | import * as Option from "effect/Option"; | 27 | import * as Option from "effect/Option"; |
| 26 | import * as Path from "effect/Path"; | 28 | import * 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 | |
| 77 | const make = Effect.gen(function* () { | 80 | const 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* gitWorkflow | 161 | 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 | gitWorkflow | 254 | 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 === null | 715 | 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.isRepo | 722 | ? "not_repository" |
| 655 | ? "not_repository" | 723 | : actual.refName !== projection.thread.branch |
| 656 | : actual.refName !== projection.thread.branch | 724 | ? "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 === projectWorkspaceRoot | 1046 | 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 == null | 1083 | selectedRef?.isRemote === true || selectedRef?.worktreePath == null |
| 1002 | ? null | 1084 | ? null |
| 1003 | : normalizePath(selectedRef.worktreePath); | 1085 | : selectedRef.worktreePath; |
| 1004 | targetWorkspacePath = | 1086 | targetWorkspacePath = |
| 1005 | workspace === "project_root" | 1087 | workspace === "project_root" |
| 1006 | ? projectWorkspaceRoot | 1088 | ? projectWorktreeRoot |
| 1007 | : workspace === "current" | 1089 | : workspace === "current" |
| 1008 | ? currentWorkspacePath | 1090 | ? currentWorkspacePath |
| 1009 | : (selectedWorktreePath ?? | 1091 | : (selectedWorktreePath ?? |
| 1010 | (projection.thread.worktreePath !== null && selectedRef?.isDefault === true | 1092 | (projection.thread.worktreePath !== null && selectedRef?.isDefault === true |
| 1011 | ? projectWorkspaceRoot | 1093 | ? 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 == null | 1126 | selectedRef?.isRemote === true || selectedRef?.worktreePath == null |
| 1045 | ? null | 1127 | ? 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 === true | 1144 | 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 > 0 | 1165 | 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 > 0 | 1175 | 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* gitWorkflow | 1199 | ); |
| 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 = checkoutChanged | 1384 | 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 === null | 1392 | return yield* failure( |
| 1165 | ? Effect.void | 1393 | "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 | gitWorkflow | 1482 | 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 === null | 1486 | callerProjectionExit.value.thread.branch === projection.thread.branch && |
| 1217 | ? Effect.void | 1487 | 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 = workspaceChanged | 1513 | ? preCommitProjectionExit.value |
| 1249 | ? targetWorkspacePath === projectWorkspaceRoot | 1514 | : null; |
| 1250 | ? null | 1515 | const metadataChanged = |
| 1251 | : targetWorkspacePath | 1516 | 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 | gitWorkflow | 1567 | 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 === null | 1571 | "workspace binding dispatch reported failure after the binding committed", |
| 1317 | ? Effect.void | 1572 | { |
| 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 = workspaceChanged | 1605 | 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: workspaceChanged | 1636 | 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.Crypto | 1679 | | Crypto.Crypto |
| | 1680 | | FileSystem.FileSystem |
| 1418 | | Path.Path | 1681 | | Path.Path |
| 1419 | | ThreadManagementService | 1682 | | ThreadManagementService |
| 1420 | | ProjectService.ProjectService | 1683 | | ProjectService.ProjectService |