Unified · Split
| @@ -5,6 +5,7 @@ import { · 4 unmodified lines | ||
| 5 | 5 | EnvironmentId, |
| 6 | 6 | GitManagerError, |
| 7 | 7 | type OrchestrationV2ThreadProjection, |
| 8 | + type OrchestrationV2ThreadShell, | |
| 8 | 9 | type Project, |
| 9 | 10 | ProjectId, |
| 10 | 11 | ProviderInstanceId, |
| @@ -104,7 +105,10 @@ interface HarnessOptions { · 93 unmodified lines | ||
| 104 | 105 | readonly dispatchGate?: Effect.Effect<void>; |
| 105 | 106 | readonly threadAttachedOnRecheck?: boolean; |
| 106 | 107 | readonly threadArchivedOnRecheck?: boolean; |
| 108 | + readonly threadArchivedOnCall?: number; | |
| 109 | + readonly threadDeletedOnCall?: number; | |
| 107 | 110 | readonly threadReadFailsOnRecheck?: boolean; |
| 111 | + readonly threadReadFailsAfterDispatch?: boolean; | |
| 108 | 112 | readonly threadReadFailsOnCall?: number; |
| 109 | 113 | readonly continuation?: "queued" | "fails" | "dies"; |
| 110 | 114 | readonly projectMissing?: boolean; |
| @@ -124,6 +128,12 @@ interface HarnessOptions { · 13 unmodified lines | ||
| 124 | 128 | readonly isRemote?: boolean; |
| 125 | 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 | 137 | readonly workspaceStatuses?: Readonly<Record<string, { branch: string | null; dirty?: boolean }>>; |
| 128 | 138 | readonly localStatusFailsOnCall?: number; |
| 129 | 139 | readonly localStatusFailure?: "typed" | "defect" | "interrupt"; |
| @@ -135,9 +145,21 @@ interface HarnessOptions { · 5 unmodified lines | ||
| 135 | 145 | readonly status?: "idle" | "running"; |
| 136 | 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 | 157 | readonly switchRefFails?: boolean; |
| 139 | 158 | readonly switchRefFailsAfterMutation?: boolean; |
| 140 | 159 | readonly switchRefRollbackFails?: boolean; |
| 160 | + readonly switchRefGate?: Effect.Effect<void>; | |
| 161 | + readonly switchRefResultBranch?: string | null; | |
| 162 | + readonly refChangeAfterSwitch?: string | null; | |
| 141 | 163 | readonly createRefFails?: boolean; |
| 142 | 164 | } |
| 143 | 165 | |
| @@ -189,6 +211,28 @@ const makeHarness = (options: HarnessOptions = {}) => { · 45 unmodified lines | ||
| 189 | 211 | }), |
| 190 | 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 | 236 | if ( |
| 193 | 237 | options.threadAttachedOnRecheck === true && |
| 194 | 238 | getThreadProjection.mock.calls.length > 1 && |
| @@ -212,9 +256,26 @@ const makeHarness = (options: HarnessOptions = {}) => { · 17 unmodified lines | ||
| 212 | 256 | ) { |
| 213 | 257 | return Effect.succeed(makeProjection({ ...thread, ...options.threadAfterFailedDispatch })); |
| 214 | 258 | } |
| 215 | − return id === threadId && thread !== null | |
| 216 | − ? Effect.succeed(makeProjection(thread)) | |
| 217 | − : Effect.fail(new OrchestratorProjectionError({ threadId: id })); | |
| 259 | + if (id === threadId && thread !== null) { | |
| 260 | + return Effect.succeed(makeProjection(thread)); | |
| 261 | + } | |
| 262 | + const projectThread = options.projectThreads?.find((item) => item.id === id); | |
| 263 | + if (projectThread !== undefined) { | |
| 264 | + const projection = makeProjection({ | |
| 265 | + branch: projectThread.branch, | |
| 266 | + worktreePath: projectThread.worktreePath, | |
| 267 | + }); | |
| 268 | + return Effect.succeed({ | |
| 269 | + ...projection, | |
| 270 | + thread: { | |
| 271 | + ...projection.thread, | |
| 272 | + id: projectThread.id, | |
| 273 | + title: projectThread.title, | |
| 274 | + activeRunId: projectThread.active === true ? "run-active" : null, | |
| 275 | + }, | |
| 276 | + }); | |
| 277 | + } | |
| 278 | + return Effect.fail(new OrchestratorProjectionError({ threadId: id })); | |
| 218 | 279 | }); |
| 219 | 280 | const sendToThread = vi.fn((_: unknown) => { |
| 220 | 281 | switch (options.continuation ?? "queued") { |
| @@ -236,34 +297,59 @@ const makeHarness = (options: HarnessOptions = {}) => { · 15 unmodified lines | ||
| 236 | 297 | : Effect.succeed( |
| 237 | 298 | id === projectId && options.projectMissing !== true |
| 238 | 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(() => | |
| 243 | − Effect.succeed( | |
| 244 | − ( | |
| 245 | − options.projectThreads ?? [ | |
| 246 | − { | |
| 247 | − id: threadId, | |
| 248 | − title: "Worktree test thread", | |
| 249 | − branch: thread?.branch ?? null, | |
| 250 | − worktreePath: thread?.worktreePath ?? null, | |
| 251 | − }, | |
| 252 | − ] | |
| 253 | − ).map( | |
| 254 | − (item) => | |
| 255 | − ({ | |
| 256 | − id: item.id, | |
| 257 | − projectId, | |
| 258 | − title: item.title, | |
| 259 | − branch: item.branch, | |
| 260 | − worktreePath: item.worktreePath, | |
| 261 | − status: item.status ?? "idle", | |
| 262 | − activeRunId: item.active === true ? "run-active" : null, | |
| 263 | − lineage: { relationshipToParent: "none" }, | |
| 264 | − }) as never, | |
| 265 | − ), | |
| 266 | − ), | |
| 309 | + const projectThreadShells: Array<OrchestrationV2ThreadShell> = ( | |
| 310 | + options.projectThreads ?? [ | |
| 311 | + { | |
| 312 | + id: threadId, | |
| 313 | + title: "Worktree test thread", | |
| 314 | + branch: thread?.branch ?? null, | |
| 315 | + worktreePath: thread?.worktreePath ?? null, | |
| 316 | + }, | |
| 317 | + ] | |
| 318 | + ).map( | |
| 319 | + (item) => | |
| 320 | + ({ | |
| 321 | + id: item.id, | |
| 322 | + projectId, | |
| 323 | + title: item.title, | |
| 324 | + branch: item.branch, | |
| 325 | + worktreePath: item.worktreePath, | |
| 326 | + status: item.status ?? "idle", | |
| 327 | + activeRunId: item.active === true ? "run-active" : null, | |
| 328 | + lineage: { relationshipToParent: "none" }, | |
| 329 | + }) as unknown as OrchestrationV2ThreadShell, | |
| 330 | + ); | |
| 331 | + if (options.otherProjectThread !== undefined) { | |
| 332 | + projectThreadShells.push({ | |
| 333 | + ...(projectThreadShells[0] ?? makeProjection({}).thread), | |
| 334 | + id: options.otherProjectThread.id, | |
| 335 | + projectId: options.otherProjectThread.projectId, | |
| 336 | + title: options.otherProjectThread.title, | |
| 337 | + branch: options.otherProjectThread.branch, | |
| 338 | + worktreePath: options.otherProjectThread.worktreePath, | |
| 339 | + activeRunId: options.otherProjectThread.active === true ? "run-active" : null, | |
| 340 | + lineage: { relationshipToParent: "none" }, | |
| 341 | + } as unknown as OrchestrationV2ThreadShell); | |
| 342 | + } | |
| 343 | + const listProjectThreads = vi.fn((input: { readonly projectId: ProjectId }) => | |
| 344 | + Effect.succeed(projectThreadShells.filter((item) => item.projectId === input.projectId)), | |
| 345 | + ); | |
| 346 | + const getShellSnapshot = vi.fn(() => | |
| 347 | + Effect.succeed({ | |
| 348 | + schemaVersion: 1, | |
| 349 | + snapshotSequence: 1, | |
| 350 | + threads: projectThreadShells, | |
| 351 | + archivedThreads: [], | |
| 352 | + } as never), | |
| 267 | 353 | ); |
| 268 | 354 | const removeWorktree = vi.fn((_: unknown) => |
| 269 | 355 | 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 }) => | |
| 316 | − Effect.succeed({ | |
| 317 | − refs: | |
| 318 | − options.refs !== undefined | |
| 319 | − ? options.refs.filter( | |
| 320 | − (ref) => input.query === undefined || ref.name.includes(input.query), | |
| 321 | − ) | |
| 322 | − : options.existingBranchWorktreePath === undefined | |
| 323 | − ? [] | |
| 324 | − : [ | |
| 325 | − { | |
| 326 | − name: input.query ?? "", | |
| 327 | − current: false, | |
| 328 | − isDefault: false, | |
| 329 | − worktreePath: options.existingBranchWorktreePath, | |
| 330 | − }, | |
| 331 | − ], | |
| 401 | + const listRefs = vi.fn((input: { readonly query?: string | undefined }) => { | |
| 402 | + const refs = | |
| 403 | + options.refs !== undefined | |
| 404 | + ? options.refs.filter((ref) => | |
| 405 | + input.query === undefined ? true : ref.name.includes(input.query), | |
| 406 | + ) | |
| 407 | + : options.existingBranchWorktreePath === undefined | |
| 408 | + ? [] | |
| 409 | + : [ | |
| 410 | + { | |
| 411 | + name: input.query ?? "", | |
| 412 | + current: false, | |
| 413 | + isDefault: false, | |
| 414 | + worktreePath: options.existingBranchWorktreePath, | |
| 415 | + }, | |
| 416 | + ]; | |
| 417 | + return Effect.succeed({ | |
| 418 | + refs, | |
| 332 | 419 | isRepo: true, |
| 333 | 420 | hasPrimaryRemote: true, |
| 334 | 421 | nextCursor: null, |
| 335 | 422 | totalCount: |
| 336 | 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 | 453 | let localStatusCallCount = 0; |
| @@ -363,27 +477,47 @@ const makeHarness = (options: HarnessOptions = {}) => { · 23 unmodified lines | ||
| 363 | 477 | hasPrimaryRemote: true, |
| 364 | 478 | isDefaultRef: false, |
| 365 | 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 | 485 | hasWorkingTreeChanges: current?.dirty ?? false, |
| 368 | 486 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 369 | 487 | }); |
| 370 | 488 | }); |
| 371 | 489 | let switchCallCount = 0; |
| 372 | − const switchRef = vi.fn((input: { readonly cwd: string; readonly refName: string }) => { | |
| 373 | − switchCallCount += 1; | |
| 374 | − if (options.switchRefFailsAfterMutation === true && switchCallCount === 1) { | |
| 375 | − workspaceStatuses.set(input.cwd, { branch: input.refName, dirty: false }); | |
| 376 | − return Effect.fail("simulated switch failure after mutation") as never; | |
| 377 | − } | |
| 378 | − if ( | |
| 379 | − options.switchRefFails === true || | |
| 380 | − (options.switchRefRollbackFails === true && switchCallCount > 1) | |
| 381 | − ) { | |
| 382 | − return Effect.fail("simulated switch failure") as never; | |
| 383 | − } | |
| 384 | − workspaceStatuses.set(input.cwd, { branch: input.refName, dirty: false }); | |
| 385 | − return Effect.succeed({ refName: input.refName }); | |
| 386 | − }); | |
| 490 | + const switchRef = vi.fn((input: { readonly cwd: string; readonly refName: string }) => | |
| 491 | + (options.switchRefGate ?? Effect.void).pipe( | |
| 492 | + Effect.andThen( | |
| 493 | + Effect.suspend(() => { | |
| 494 | + switchCallCount += 1; | |
| 495 | + if (options.switchRefFailsAfterMutation === true && switchCallCount === 1) { | |
| 496 | + workspaceStatuses.set(input.cwd, { branch: input.refName, dirty: false }); | |
| 497 | + return Effect.fail("simulated switch failure after mutation") as never; | |
| 498 | + } | |
| 499 | + if ( | |
| 500 | + options.switchRefFails === true || | |
| 501 | + (options.switchRefRollbackFails === true && switchCallCount > 1) | |
| 502 | + ) { | |
| 503 | + return Effect.fail("simulated switch failure") as never; | |
| 504 | + } | |
| 505 | + const resolvedBranch = | |
| 506 | + options.switchRefResultBranch === undefined | |
| 507 | + ? input.refName | |
| 508 | + : options.switchRefResultBranch; | |
| 509 | + workspaceStatuses.set(input.cwd, { | |
| 510 | + branch: | |
| 511 | + options.refChangeAfterSwitch === undefined | |
| 512 | + ? resolvedBranch | |
| 513 | + : options.refChangeAfterSwitch, | |
| 514 | + dirty: false, | |
| 515 | + }); | |
| 516 | + return Effect.succeed({ refName: resolvedBranch }); | |
| 517 | + }), | |
| 518 | + ), | |
| 519 | + ), | |
| 520 | + ); | |
| 387 | 521 | const createRef = vi.fn((_: unknown) => |
| 388 | 522 | options.createRefFails === true |
| 389 | 523 | ? (Effect.fail("simulated create ref failure") as never) |
| @@ -418,8 +552,8 @@ const makeHarness = (options: HarnessOptions = {}) => { · 28 unmodified lines | ||
| 418 | 552 | // Optional deterministic Path semantics: providing this BEFORE the general |
| 419 | 553 | // mocks means the service resolves Path here rather than from NodeServices, |
| 420 | 554 | // so absolute-path validation is testable independently of the host OS. The |
| 421 | − // service only calls isAbsolute; the minimal per-platform semantics are | |
| 422 | − // inlined so the test does not depend on the host's path module. | |
| 555 | + // The minimal per-platform semantics are inlined so the test does not | |
| 556 | + // depend on the host's path module. | |
| 423 | 557 | const win32IsAbsolute = (value: string) => /^(?:[a-zA-Z]:[\\/]|[\\/])/.test(value); |
| 424 | 558 | const posixIsAbsolute = (value: string) => value.startsWith("/"); |
| 425 | 559 | const serviceLayer = |
| @@ -429,6 +563,8 @@ const makeHarness = (options: HarnessOptions = {}) => { · 3 unmodified lines | ||
| 429 | 563 | Layer.provide( |
| 430 | 564 | Layer.succeed(Path.Path, { |
| 431 | 565 | isAbsolute: options.pathSemantics === "win32" ? win32IsAbsolute : posixIsAbsolute, |
| 566 | + normalize: (value: string) => value, | |
| 567 | + resolve: (value: string) => value, | |
| 432 | 568 | } as unknown as Path.Path), |
| 433 | 569 | ), |
| 434 | 570 | ); |
| @@ -437,6 +573,7 @@ const makeHarness = (options: HarnessOptions = {}) => { · 2 unmodified lines | ||
| 437 | 573 | Layer.mergeAll( |
| 438 | 574 | Layer.mock(ThreadManagementService)({ |
| 439 | 575 | dispatch, |
| 576 | + getShellSnapshot, | |
| 440 | 577 | getThreadProjection, |
| 441 | 578 | listProjectThreads, |
| 442 | 579 | sendToThread, |
| @@ -449,6 +586,7 @@ const makeHarness = (options: HarnessOptions = {}) => { · 6 unmodified lines | ||
| 449 | 586 | }), |
| 450 | 587 | Layer.mock(GitWorkflowService.GitWorkflowService)({ |
| 451 | 588 | listRefs, |
| 589 | + listWorktrees, | |
| 452 | 590 | listLocalBranchNames, |
| 453 | 591 | localStatus, |
| 454 | 592 | fetchRemote, |
| @@ -483,6 +621,7 @@ const makeHarness = (options: HarnessOptions = {}) => { · 28 unmodified lines | ||
| 483 | 621 | deleteLocalBranch, |
| 484 | 622 | localStatus, |
| 485 | 623 | listRefs, |
| 624 | + listWorktrees, | |
| 486 | 625 | listProjectThreads, |
| 487 | 626 | switchRef, |
| 488 | 627 | createRef, |
| @@ -525,10 +664,13 @@ const runStatus = (harness: ReturnType<typeof makeHarness>) => · 36 unmodified lines | ||
| 525 | 664 | return yield* service.status(harness.scope); |
| 526 | 665 | }).pipe(Effect.provide(harness.layer)); |
| 527 | 666 | |
| 528 | −const runList = (harness: ReturnType<typeof makeHarness>) => | |
| 667 | +const runList = ( | |
| 668 | + harness: ReturnType<typeof makeHarness>, | |
| 669 | + input: Parameters<WorktreeMcpService["Service"]["listWorktrees"]>[1] = {}, | |
| 670 | +) => | |
| 529 | 671 | Effect.gen(function* () { |
| 530 | 672 | const service = yield* WorktreeMcpService; |
| 531 | − return yield* service.listWorktrees(harness.scope); | |
| 673 | + return yield* service.listWorktrees(harness.scope, input); | |
| 532 | 674 | }).pipe(Effect.provide(harness.layer)); |
| 533 | 675 | |
| 534 | 676 | const runCheckout = ( |
| @@ -987,7 +1129,12 @@ describe("t3_worktree_handoff", () => { · 452 unmodified lines | ||
| 987 | 1129 | it.effect("queues the continuation even when interrupted during the binding dispatch", () => |
| 988 | 1130 | Effect.gen(function* () { |
| 989 | 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 | 1139 | // Interrupt arrives while the metadata dispatch is in flight; the |
| 993 | 1140 | // binding-plus-continuation section must run to completion anyway so the |
| @@ -998,7 +1145,7 @@ describe("t3_worktree_handoff", () => { · 4 unmodified lines | ||
| 998 | 1145 | continuationPrompt: "Keep going in the worktree.", |
| 999 | 1146 | }), |
| 1000 | 1147 | ); |
| 1001 | − yield* Effect.yieldNow; | |
| 1148 | + yield* Deferred.await(dispatchStarted); | |
| 1002 | 1149 | const interruption = yield* Effect.forkChild(Fiber.interrupt(fiber)); |
| 1003 | 1150 | yield* Effect.yieldNow; |
| 1004 | 1151 | yield* Deferred.succeed(gate, undefined); |
| @@ -1335,6 +1482,10 @@ describe("t3_worktree_list", () => { · 330 unmodified lines | ||
| 1335 | 1482 | return Effect.gen(function* () { |
| 1336 | 1483 | const result = yield* runList(harness); |
| 1337 | 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 | 1489 | expect(result.worktrees).toEqual([ |
| 1339 | 1490 | { |
| 1340 | 1491 | path: workspaceRoot, |
| @@ -1343,6 +1494,8 @@ describe("t3_worktree_list", () => { · 2 unmodified lines | ||
| 1343 | 1494 | isRepo: true, |
| 1344 | 1495 | isProjectRoot: true, |
| 1345 | 1496 | hasWorkingTreeChanges: false, |
| 1497 | + availability: "available", | |
| 1498 | + statusError: null, | |
| 1346 | 1499 | bindings: [ |
| 1347 | 1500 | { |
| 1348 | 1501 | threadId, |
| @@ -1354,6 +1507,7 @@ describe("t3_worktree_list", () => { · 5 unmodified lines | ||
| 1354 | 1507 | callingThread: true, |
| 1355 | 1508 | }, |
| 1356 | 1509 | ], |
| 1510 | + bindingCount: 1, | |
| 1357 | 1511 | }, |
| 1358 | 1512 | { |
| 1359 | 1513 | path: worktreePath, |
| @@ -1362,6 +1516,8 @@ describe("t3_worktree_list", () => { · 2 unmodified lines | ||
| 1362 | 1516 | isRepo: true, |
| 1363 | 1517 | isProjectRoot: false, |
| 1364 | 1518 | hasWorkingTreeChanges: true, |
| 1519 | + availability: "available", | |
| 1520 | + statusError: null, | |
| 1365 | 1521 | bindings: [ |
| 1366 | 1522 | { |
| 1367 | 1523 | threadId: otherThreadId, |
| @@ -1373,10 +1529,84 @@ describe("t3_worktree_list", () => { · 5 unmodified lines | ||
| 1373 | 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 | |
| 1382 | 1612 | describe("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 | 1758 | it.effect("creates and checks out a new branch in the current workspace", () => { |
| 1434 | 1759 | const harness = makeHarness({ |
| 1435 | 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 | 1939 | it.effect("rejects dirty files before switching branches", () => { |
| 1570 | 1940 | const harness = makeHarness({ |
| 1571 | 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 | 2255 | it.effect("rejects switching the shared project root while another thread is active", () => { |
| 1848 | 2256 | const harness = makeHarness({ |
| 1849 | 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 | 2342 | it.effect("rolls the git branch back when the durable binding fails", () => { |
| 1916 | 2343 | const harness = makeHarness({ |
| 1917 | 2344 | thread: { branch: "dev", worktreePath: null }, |
| @@ -1960,7 +2387,7 @@ describe("t3_thread_checkout", () => { · 42 unmodified lines | ||
| 1960 | 2387 | refs: rootRefs, |
| 1961 | 2388 | workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, |
| 1962 | 2389 | dispatchFails: true, |
| 1963 | − threadReadFailsOnRecheck: true, | |
| 2390 | + threadReadFailsAfterDispatch: true, | |
| 1964 | 2391 | }); |
| 1965 | 2392 | return Effect.gen(function* () { |
| 1966 | 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 | 2438 | it.effect("rolls back when checkout reports failure after changing the branch", () => { |
| 1986 | 2439 | const harness = makeHarness({ |
| 1987 | 2440 | thread: { branch: "dev", worktreePath: null }, |
| @@ -2009,7 +2462,7 @@ describe("t3_thread_checkout", () => { · 21 unmodified lines | ||
| 2009 | 2462 | thread: { branch: "dev", worktreePath: null }, |
| 2010 | 2463 | refs: rootRefs, |
| 2011 | 2464 | workspaceStatuses: { [workspaceRoot]: { branch: "dev" } }, |
| 2012 | − localStatusFailsOnCall: 2, | |
| 2465 | + localStatusFailsOnCall: 3, | |
| 2013 | 2466 | }); |
| 2014 | 2467 | return Effect.gen(function* () { |
| 2015 | 2468 | const exit = yield* Effect.exit( |
| @@ -2091,6 +2544,59 @@ describe("t3_thread_checkout", () => { · 75 unmodified lines | ||
| 2091 | 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 | |
| 2096 | 2602 | describe("WorktreeMcpHandoffInput schema", () => { |
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | ||
| 11 | 11 | type WorktreeMcpContinuationStatus, |
| 12 | 12 | type WorktreeMcpHandoffInput, |
| 13 | 13 | type WorktreeMcpHandoffResult, |
| 14 | + type WorktreeMcpListInput, | |
| 14 | 15 | type WorktreeMcpListResult, |
| 15 | 16 | type WorktreeMcpSetupScriptStatus, |
| 16 | 17 | type WorktreeMcpStatusResult, |
| @@ -21,6 +22,7 @@ import * as Crypto from "effect/Crypto"; · 4 unmodified lines | ||
| 21 | 22 | import * as Effect from "effect/Effect"; |
| 22 | 23 | import * as FileSystem from "effect/FileSystem"; |
| 23 | 24 | import * as Exit from "effect/Exit"; |
| 25 | +import * as FileSystem from "effect/FileSystem"; | |
| 24 | 26 | import * as Layer from "effect/Layer"; |
| 25 | 27 | import * as Option from "effect/Option"; |
| 26 | 28 | import * as Path from "effect/Path"; |
| @@ -45,6 +47,7 @@ export class WorktreeMcpService extends Context.Service< · 18 unmodified lines | ||
| 45 | 47 | ) => Effect.Effect<WorktreeMcpStatusResult, WorktreeMcpFailure>; |
| 46 | 48 | readonly listWorktrees: ( |
| 47 | 49 | scope: McpInvocationScope, |
| 50 | + input: WorktreeMcpListInput, | |
| 48 | 51 | ) => Effect.Effect<WorktreeMcpListResult, WorktreeMcpFailure>; |
| 49 | 52 | readonly checkout: ( |
| 50 | 53 | scope: McpInvocationScope, |
| @@ -76,6 +79,7 @@ const asOperationFailed = (prefix: string) => · 25 unmodified lines | ||
| 76 | 79 | |
| 77 | 80 | const make = Effect.gen(function* () { |
| 78 | 81 | const crypto = yield* Crypto.Crypto; |
| 82 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 79 | 83 | const path = yield* Path.Path; |
| 80 | 84 | const threadManagement = yield* ThreadManagementService; |
| 81 | 85 | const projects = yield* ProjectService.ProjectService; |
| @@ -135,10 +139,17 @@ const make = Effect.gen(function* () { · 53 unmodified lines | ||
| 135 | 139 | |
| 136 | 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 | 148 | thread: Pick<OrchestrationV2ThreadShell, "worktreePath">, |
| 140 | 149 | projectWorkspaceRoot: string, |
| 141 | − ) => normalizePath(thread.worktreePath ?? projectWorkspaceRoot); | |
| 150 | + ) { | |
| 151 | + return yield* canonicalizePath(thread.worktreePath ?? projectWorkspaceRoot); | |
| 152 | + }); | |
| 142 | 153 | |
| 143 | 154 | const loadRefs = Effect.fn("WorktreeMcpService.loadRefs")(function* ( |
| 144 | 155 | projectWorkspaceRoot: string, |
| @@ -146,14 +157,13 @@ const make = Effect.gen(function* () { · 1 unmodified line | ||
| 146 | 157 | ) { |
| 147 | 158 | const refs: Array<VcsRef> = []; |
| 148 | 159 | let cursor: number | undefined; |
| 149 | − let firstPage = true; | |
| 150 | 160 | do { |
| 151 | 161 | const page = yield* gitWorkflow |
| 152 | 162 | .listRefs({ |
| 153 | 163 | cwd: projectWorkspaceRoot, |
| 154 | 164 | refKind, |
| 155 | 165 | includeMatchingRemoteRefs: refKind === "all", |
| 156 | − refresh: firstPage, | |
| 166 | + refresh: cursor === undefined, | |
| 157 | 167 | limit: 200, |
| 158 | 168 | ...(cursor === undefined ? {} : { cursor }), |
| 159 | 169 | }) |
| @@ -166,11 +176,18 @@ const make = Effect.gen(function* () { · 6 unmodified lines | ||
| 166 | 176 | } |
| 167 | 177 | refs.push(...page.refs); |
| 168 | 178 | cursor = page.nextCursor ?? undefined; |
| 169 | − firstPage = false; | |
| 170 | 179 | } while (cursor !== undefined); |
| 171 | 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 | 191 | const loadProjectThreads = ( |
| 175 | 192 | projectId: ProjectId, |
| 176 | 193 | ): Effect.Effect<ReadonlyArray<OrchestrationV2ThreadShell>, WorktreeMcpFailure> => |
| @@ -178,6 +195,61 @@ const make = Effect.gen(function* () { · 1 unmodified line | ||
| 178 | 195 | .listProjectThreads({ projectId, includeSubagents: true }) |
| 179 | 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 | 253 | const readWorkspaceStatus = (workspacePath: string) => |
| 182 | 254 | gitWorkflow |
| 183 | 255 | .invalidateLocalStatus(workspacePath) |
| @@ -265,17 +337,13 @@ const make = Effect.gen(function* () { · 81 unmodified lines | ||
| 265 | 337 | } |
| 266 | 338 | |
| 267 | 339 | const project = yield* loadProject(scope, projection.thread.projectId); |
| 268 | − const projectCwd = project.workspaceRoot; | |
| 269 | − const sourceCwd = projection.thread.worktreePath ?? projectCwd; | |
| 340 | + const projectCwd = yield* canonicalizePath(project.workspaceRoot); | |
| 341 | + const sourceCwd = yield* canonicalizePath(projection.thread.worktreePath ?? projectCwd); | |
| 270 | 342 | |
| 271 | 343 | if (projection.thread.worktreePath !== null) { |
| 272 | − const projectRefs = yield* loadRefs(projectCwd, "local"); | |
| 273 | − const projectWorktreePaths = new Set( | |
| 274 | − projectRefs.flatMap((ref) => | |
| 275 | − ref.worktreePath === null ? [] : [normalizePath(ref.worktreePath)], | |
| 276 | − ), | |
| 277 | − ); | |
| 278 | − if (!projectWorktreePaths.has(normalizePath(projection.thread.worktreePath))) { | |
| 344 | + const inventory = yield* loadWorktrees(projectCwd); | |
| 345 | + const projectWorktreePaths = new Set(inventory.worktrees.map((worktree) => worktree.path)); | |
| 346 | + if (!projectWorktreePaths.has(sourceCwd)) { | |
| 279 | 347 | return yield* failure( |
| 280 | 348 | "scope_mismatch", |
| 281 | 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 | 699 | yield* requireCapability(scope); |
| 632 | 700 | const projection = yield* loadThread(scope); |
| 633 | 701 | const project = yield* loadProject(scope, projection.thread.projectId); |
| 634 | − const projectWorkspaceRoot = normalizePath(project.workspaceRoot); | |
| 702 | + const projectWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot); | |
| 635 | 703 | const workspacePath = normalizePath(projection.thread.worktreePath ?? projectWorkspaceRoot); |
| 636 | − const [defaultStartFromOrigin, actual, refs] = yield* Effect.all( | |
| 637 | − [ | |
| 638 | − readDefaultStartFromOrigin, | |
| 639 | − readWorkspaceStatus(workspacePath), | |
| 640 | − loadRefs(projectWorkspaceRoot, "local"), | |
| 641 | − ], | |
| 642 | − { concurrency: 3 }, | |
| 643 | − ); | |
| 644 | − const knownWorkspacePaths = new Set([ | |
| 645 | − projectWorkspaceRoot, | |
| 646 | − ...refs.flatMap((ref) => | |
| 647 | − ref.isRemote === true || ref.worktreePath === null | |
| 648 | − ? [] | |
| 649 | − : [normalizePath(ref.worktreePath)], | |
| 650 | − ), | |
| 651 | − ]); | |
| 652 | − const agreement = !knownWorkspacePaths.has(workspacePath) | |
| 653 | − ? "workspace_missing" | |
| 654 | − : !actual.isRepo | |
| 655 | − ? "not_repository" | |
| 656 | − : actual.refName !== projection.thread.branch | |
| 657 | − ? "branch_mismatch" | |
| 658 | − : "in_sync"; | |
| 704 | + const [defaultStartFromOrigin, actual, projectInventory, workspaceInventory] = | |
| 705 | + yield* Effect.all( | |
| 706 | + [ | |
| 707 | + readDefaultStartFromOrigin, | |
| 708 | + readWorkspaceStatus(workspacePath), | |
| 709 | + loadWorktrees(projectWorkspaceRoot), | |
| 710 | + loadWorktrees(workspacePath), | |
| 711 | + ], | |
| 712 | + { concurrency: 4 }, | |
| 713 | + ); | |
| 714 | + const canonicalWorkspacePath = yield* canonicalizePath(workspacePath); | |
| 715 | + const physicalWorkspacePath = workspaceInventory.currentWorktreeRoot; | |
| 716 | + const agreement = | |
| 717 | + workspaceInventory.repositoryCommonDir !== projectInventory.repositoryCommonDir || | |
| 718 | + physicalWorkspacePath === null || | |
| 719 | + !projectInventory.worktrees.some((worktree) => worktree.path === physicalWorkspacePath) | |
| 720 | + ? "workspace_missing" | |
| 721 | + : !actual.isRepo | |
| 722 | + ? "not_repository" | |
| 723 | + : actual.refName !== projection.thread.branch | |
| 724 | + ? "branch_mismatch" | |
| 725 | + : "in_sync"; | |
| 659 | 726 | |
| 660 | 727 | const result: WorktreeMcpStatusResult = { |
| 661 | 728 | attached: projection.thread.worktreePath !== null, |
| @@ -668,7 +735,7 @@ const make = Effect.gen(function* () { · 6 unmodified lines | ||
| 668 | 735 | worktreePath: projection.thread.worktreePath, |
| 669 | 736 | }, |
| 670 | 737 | actualWorkspace: { |
| 671 | − workspacePath, | |
| 738 | + workspacePath: physicalWorkspacePath ?? canonicalWorkspacePath, | |
| 672 | 739 | isRepo: actual.isRepo, |
| 673 | 740 | branch: actual.refName, |
| 674 | 741 | hasWorkingTreeChanges: actual.hasWorkingTreeChanges, |
| @@ -879,12 +946,12 @@ const make = Effect.gen(function* () { · 204 unmodified lines | ||
| 879 | 946 | } |
| 880 | 947 | |
| 881 | 948 | const project = yield* loadProject(scope, projection.thread.projectId); |
| 882 | − const projectWorkspaceRoot = normalizePath(project.workspaceRoot); | |
| 883 | − const currentWorkspacePath = normalizePath( | |
| 949 | + const projectWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot); | |
| 950 | + const recordedWorkspacePath = yield* canonicalizePath( | |
| 884 | 951 | projection.thread.worktreePath ?? projectWorkspaceRoot, |
| 885 | 952 | ); |
| 886 | 953 | if (input.target.type === "new_worktree") { |
| 887 | − const previousActual = yield* readWorkspaceStatus(currentWorkspacePath); | |
| 954 | + const previousActual = yield* readWorkspaceStatus(recordedWorkspacePath); | |
| 888 | 955 | const handoff = yield* performHandoff( |
| 889 | 956 | scope, |
| 890 | 957 | { |
| @@ -903,10 +970,9 @@ const make = Effect.gen(function* () { · 12 unmodified lines | ||
| 903 | 970 | }, |
| 904 | 971 | projection, |
| 905 | 972 | ); |
| 906 | − const actual = yield* readWorkspaceStatus(handoff.worktreePath); | |
| 907 | 973 | return { |
| 908 | 974 | previous: { |
| 909 | − workspacePath: currentWorkspacePath, | |
| 975 | + workspacePath: recordedWorkspacePath, | |
| 910 | 976 | recordedBranch: projection.thread.branch, |
| 911 | 977 | recordedWorktreePath: projection.thread.worktreePath, |
| 912 | 978 | actualBranch: previousActual.refName, |
| @@ -915,17 +981,35 @@ const make = Effect.gen(function* () { · 2 unmodified lines | ||
| 915 | 981 | workspacePath: handoff.worktreePath, |
| 916 | 982 | recordedBranch: handoff.branch, |
| 917 | 983 | recordedWorktreePath: handoff.worktreePath, |
| 918 | − actualBranch: actual.refName, | |
| 984 | + actualBranch: handoff.branch, | |
| 919 | 985 | }, |
| 920 | 986 | checkoutAction: "created", |
| 921 | 987 | workspaceChanged: true, |
| 922 | − branchChanged: previousActual.refName !== actual.refName, | |
| 988 | + branchChanged: previousActual.refName !== handoff.branch, | |
| 923 | 989 | continuation: handoff.continuation, |
| 924 | 990 | setupScript: handoff.setupScript, |
| 925 | 991 | callerTurnEnds: true, |
| 926 | 992 | note: handoff.note, |
| 927 | 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 | 1013 | const [refs, threads, previousActual] = yield* Effect.all( |
| 930 | 1014 | [ |
| 931 | 1015 | loadRefs(projectWorkspaceRoot), |
| @@ -936,12 +1020,7 @@ const make = Effect.gen(function* () { · 4 unmodified lines | ||
| 936 | 1020 | ); |
| 937 | 1021 | |
| 938 | 1022 | const localRefs = refs.filter((ref) => ref.isRemote !== true); |
| 939 | − const workspacePaths = new Set([ | |
| 940 | − projectWorkspaceRoot, | |
| 941 | − ...localRefs.flatMap((ref) => | |
| 942 | − ref.worktreePath === null ? [] : [normalizePath(ref.worktreePath)], | |
| 943 | − ), | |
| 944 | − ]); | |
| 1023 | + const workspacePaths = new Set(inventory.worktrees.map((worktree) => worktree.path)); | |
| 945 | 1024 | const localRefByName = new Map(localRefs.map((ref) => [ref.name, ref])); |
| 946 | 1025 | const remoteRefByName = new Map( |
| 947 | 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 | 1034 | switch (input.target.type) { |
| 956 | 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 | 1039 | if ( |
| 959 | − targetWorkspacePath === projectWorkspaceRoot || | |
| 1040 | + targetInventory.repositoryCommonDir !== inventory.repositoryCommonDir || | |
| 1041 | + targetWorkspacePath === projectWorktreeRoot || | |
| 960 | 1042 | !workspacePaths.has(targetWorkspacePath) |
| 961 | 1043 | ) { |
| 962 | 1044 | return yield* failure( |
| 963 | 1045 | "scope_mismatch", |
| 964 | − targetWorkspacePath === projectWorkspaceRoot | |
| 1046 | + targetWorkspacePath === projectWorktreeRoot | |
| 965 | 1047 | ? "Use target.type='project_root' to return to the project's main checkout." |
| 966 | 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 | 1051 | break; |
| 970 | 1052 | } |
| 971 | 1053 | case "project_root": { |
| 972 | − targetWorkspacePath = projectWorkspaceRoot; | |
| 1054 | + targetWorkspacePath = projectWorktreeRoot; | |
| 973 | 1055 | requestedBranch = input.target.branch; |
| 974 | 1056 | createBranch = input.target.create ?? false; |
| 975 | 1057 | if (createBranch && requestedBranch === undefined) { |
| @@ -1000,15 +1082,15 @@ const make = Effect.gen(function* () { · 24 unmodified lines | ||
| 1000 | 1082 | const selectedWorktreePath = |
| 1001 | 1083 | selectedRef?.isRemote === true || selectedRef?.worktreePath == null |
| 1002 | 1084 | ? null |
| 1003 | − : normalizePath(selectedRef.worktreePath); | |
| 1085 | + : selectedRef.worktreePath; | |
| 1004 | 1086 | targetWorkspacePath = |
| 1005 | 1087 | workspace === "project_root" |
| 1006 | − ? projectWorkspaceRoot | |
| 1088 | + ? projectWorktreeRoot | |
| 1007 | 1089 | : workspace === "current" |
| 1008 | 1090 | ? currentWorkspacePath |
| 1009 | 1091 | : (selectedWorktreePath ?? |
| 1010 | 1092 | (projection.thread.worktreePath !== null && selectedRef?.isDefault === true |
| 1011 | − ? projectWorkspaceRoot | |
| 1093 | + ? projectWorktreeRoot | |
| 1012 | 1094 | : currentWorkspacePath)); |
| 1013 | 1095 | break; |
| 1014 | 1096 | } |
| @@ -1043,7 +1125,7 @@ const make = Effect.gen(function* () { · 28 unmodified lines | ||
| 1043 | 1125 | const selectedWorktreePath = |
| 1044 | 1126 | selectedRef?.isRemote === true || selectedRef?.worktreePath == null |
| 1045 | 1127 | ? null |
| 1046 | − : normalizePath(selectedRef.worktreePath); | |
| 1128 | + : selectedRef.worktreePath; | |
| 1047 | 1129 | if ( |
| 1048 | 1130 | !createBranch && |
| 1049 | 1131 | requestedBranch !== undefined && |
| @@ -1058,16 +1140,18 @@ const make = Effect.gen(function* () { · 8 unmodified lines | ||
| 1058 | 1140 | |
| 1059 | 1141 | const shouldMutateCheckout = |
| 1060 | 1142 | requestedBranch !== undefined && |
| 1061 | − (createBranch || | |
| 1062 | − (selectedRef?.isRemote === true | |
| 1063 | − ? targetBefore.refName !== requestedBranch && | |
| 1064 | − targetBefore.refName !== requestedBranch.replace(/^[^/]+\//, "") | |
| 1065 | − : targetBefore.refName !== requestedBranch)); | |
| 1066 | − const otherBindings = threads.filter( | |
| 1067 | − (thread) => | |
| 1068 | − thread.id !== scope.threadId && | |
| 1069 | − threadWorkspacePath(thread, projectWorkspaceRoot) === targetWorkspacePath, | |
| 1143 | + (createBranch || selectedRef?.isRemote === true || targetBefore.refName !== requestedBranch); | |
| 1144 | + const threadWorkspaces = yield* Effect.forEach(threads, (thread) => | |
| 1145 | + threadWorkspacePath(thread, projectWorktreeRoot).pipe( | |
| 1146 | + Effect.map((workspacePath) => [thread, workspacePath] as const), | |
| 1147 | + ), | |
| 1070 | 1148 | ); |
| 1149 | + const otherBindings = threadWorkspaces | |
| 1150 | + .filter( | |
| 1151 | + ([thread, workspacePath]) => | |
| 1152 | + thread.id !== scope.threadId && workspacePath === targetWorkspacePath, | |
| 1153 | + ) | |
| 1154 | + .map(([thread]) => thread); | |
| 1071 | 1155 | const activeBinding = otherBindings.find((thread) => thread.activeRunId !== null); |
| 1072 | 1156 | if ((targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) && activeBinding) { |
| 1073 | 1157 | return yield* failure( |
| @@ -1076,7 +1160,7 @@ const make = Effect.gen(function* () { · 2 unmodified lines | ||
| 1076 | 1160 | ); |
| 1077 | 1161 | } |
| 1078 | 1162 | if ( |
| 1079 | − targetWorkspacePath !== projectWorkspaceRoot && | |
| 1163 | + targetWorkspacePath !== projectWorktreeRoot && | |
| 1080 | 1164 | (targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) && |
| 1081 | 1165 | otherBindings.length > 0 |
| 1082 | 1166 | ) { |
| @@ -1086,7 +1170,7 @@ const make = Effect.gen(function* () { · 3 unmodified lines | ||
| 1086 | 1170 | ); |
| 1087 | 1171 | } |
| 1088 | 1172 | if ( |
| 1089 | − targetWorkspacePath === projectWorkspaceRoot && | |
| 1173 | + targetWorkspacePath === projectWorktreeRoot && | |
| 1090 | 1174 | shouldMutateCheckout && |
| 1091 | 1175 | otherBindings.length > 0 |
| 1092 | 1176 | ) { |
| @@ -1103,285 +1187,463 @@ const make = Effect.gen(function* () { · 10 unmodified lines | ||
| 1103 | 1187 | } |
| 1104 | 1188 | |
| 1105 | 1189 | const ids = yield* transitionIds(scope, "checkout"); |
| 1190 | + const workspaceGuardKey = `workspace:${inventory.repositoryCommonDir}:${targetWorkspacePath}`; | |
| 1106 | 1191 | return yield* Effect.uninterruptibleMask(() => |
| 1107 | − Effect.gen(function* () { | |
| 1108 | − let checkoutAction: WorktreeMcpCheckoutResult["checkoutAction"] = | |
| 1109 | − targetWorkspacePath === currentWorkspacePath ? "unchanged" : "reused"; | |
| 1110 | − let createdBranch: string | null = null; | |
| 1111 | − | |
| 1112 | − if (shouldMutateCheckout && requestedBranch !== undefined) { | |
| 1113 | − if (createBranch) { | |
| 1114 | − yield* gitWorkflow | |
| 1115 | − .createRef({ | |
| 1116 | − cwd: targetWorkspacePath, | |
| 1117 | − refName: requestedBranch, | |
| 1118 | − switchRef: false, | |
| 1119 | − }) | |
| 1120 | − .pipe(asOperationFailed(`Unable to create branch '${requestedBranch}'`)); | |
| 1121 | − createdBranch = requestedBranch; | |
| 1192 | + Effect.suspend(() => { | |
| 1193 | + if (workspaceTransitionsInFlight.has(workspaceGuardKey)) { | |
| 1194 | + return Effect.fail( | |
| 1195 | + failure( | |
| 1196 | + "checkout_in_progress", | |
| 1197 | + `Another workspace transition is already in progress for '${targetWorkspacePath}'.`, | |
| 1198 | + ), | |
| 1199 | + ); | |
| 1200 | + } | |
| 1201 | + workspaceTransitionsInFlight.add(workspaceGuardKey); | |
| 1202 | + return Effect.gen(function* () { | |
| 1203 | + const [latestProjection, latestInventory, latestBindings, latestTargetBefore] = | |
| 1204 | + yield* Effect.all( | |
| 1205 | + [ | |
| 1206 | + loadThread(scope), | |
| 1207 | + loadWorktrees(projectWorkspaceRoot), | |
| 1208 | + loadActiveWorkspaceBindings(inventory.repositoryCommonDir), | |
| 1209 | + readWorkspaceStatus(targetWorkspacePath), | |
| 1210 | + ], | |
| 1211 | + { concurrency: 4 }, | |
| 1212 | + ); | |
| 1213 | + if ( | |
| 1214 | + latestProjection.thread.branch !== projection.thread.branch || | |
| 1215 | + latestProjection.thread.worktreePath !== projection.thread.worktreePath || | |
| 1216 | + latestProjection.thread.archivedAt !== projection.thread.archivedAt | |
| 1217 | + ) { | |
| 1218 | + return yield* failure( | |
| 1219 | + "checkout_in_progress", | |
| 1220 | + `Thread '${scope.threadId}' changed workspace state while checkout was being prepared. Retry from its current binding.`, | |
| 1221 | + ); | |
| 1222 | + } | |
| 1223 | + if ( | |
| 1224 | + latestInventory.repositoryCommonDir !== inventory.repositoryCommonDir || | |
| 1225 | + !latestInventory.worktrees.some((worktree) => worktree.path === targetWorkspacePath) | |
| 1226 | + ) { | |
| 1227 | + return yield* failure( | |
| 1228 | + "scope_mismatch", | |
| 1229 | + `Checkout target '${targetWorkspacePath}' is no longer registered in the calling thread's Git repository.`, | |
| 1230 | + ); | |
| 1231 | + } | |
| 1232 | + if ( | |
| 1233 | + latestTargetBefore.refName !== targetBefore.refName || | |
| 1234 | + latestTargetBefore.hasWorkingTreeChanges !== targetBefore.hasWorkingTreeChanges | |
| 1235 | + ) { | |
| 1236 | + return yield* failure( | |
| 1237 | + "checkout_in_progress", | |
| 1238 | + `Checkout '${targetWorkspacePath}' changed while the transition was being prepared. Retry from its current Git state.`, | |
| 1239 | + ); | |
| 1240 | + } | |
| 1241 | + if (shouldMutateCheckout && latestTargetBefore.hasWorkingTreeChanges) { | |
| 1242 | + return yield* failure( | |
| 1243 | + "dirty_workspace", | |
| 1244 | + `Checkout '${targetWorkspacePath}' has uncommitted files. Commit or discard them before switching branches.`, | |
| 1245 | + ); | |
| 1122 | 1246 | } |
| 1123 | − const switchExit = yield* Effect.exit( | |
| 1124 | − gitWorkflow.switchRef({ cwd: targetWorkspacePath, refName: requestedBranch }), | |
| 1247 | + const latestOtherBindings = latestBindings | |
| 1248 | + .filter( | |
| 1249 | + ([thread, workspacePath]) => | |
| 1250 | + thread.id !== scope.threadId && workspacePath === targetWorkspacePath, | |
| 1251 | + ) | |
| 1252 | + .map(([thread]) => thread); | |
| 1253 | + const latestActiveBinding = latestOtherBindings.find( | |
| 1254 | + (thread) => thread.activeRunId !== null, | |
| 1125 | 1255 | ); |
| 1126 | − if (Exit.isFailure(switchExit)) { | |
| 1127 | − const afterFailedSwitchExit = yield* Effect.exit( | |
| 1128 | − readWorkspaceStatus(targetWorkspacePath), | |
| 1256 | + if ( | |
| 1257 | + (targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) && | |
| 1258 | + latestActiveBinding | |
| 1259 | + ) { | |
| 1260 | + return yield* failure( | |
| 1261 | + "workspace_in_use", | |
| 1262 | + `Checkout '${targetWorkspacePath}' is in use by active thread '${latestActiveBinding.id}' (${latestActiveBinding.title}).`, | |
| 1263 | + ); | |
| 1264 | + } | |
| 1265 | + if ( | |
| 1266 | + targetWorkspacePath !== projectWorktreeRoot && | |
| 1267 | + (targetWorkspacePath !== currentWorkspacePath || shouldMutateCheckout) && | |
| 1268 | + latestOtherBindings.length > 0 | |
| 1269 | + ) { | |
| 1270 | + return yield* failure( | |
| 1271 | + "workspace_shared", | |
| 1272 | + `Worktree '${targetWorkspacePath}' is already bound to thread '${latestOtherBindings[0]!.id}'. Reusing it would make two threads share one mutable checkout.`, | |
| 1273 | + ); | |
| 1274 | + } | |
| 1275 | + if ( | |
| 1276 | + targetWorkspacePath === projectWorktreeRoot && | |
| 1277 | + shouldMutateCheckout && | |
| 1278 | + latestOtherBindings.length > 0 | |
| 1279 | + ) { | |
| 1280 | + return yield* failure( | |
| 1281 | + "workspace_shared", | |
| 1282 | + `The project root is also bound to thread '${latestOtherBindings[0]!.id}'. Switching its branch would make that thread's recorded branch disagree with Git.`, | |
| 1283 | + ); | |
| 1284 | + } | |
| 1285 | + | |
| 1286 | + let checkoutAction: WorktreeMcpCheckoutResult["checkoutAction"] = | |
| 1287 | + targetWorkspacePath === currentWorkspacePath ? "unchanged" : "reused"; | |
| 1288 | + let createdBranch: string | null = null; | |
| 1289 | + let resolvedBranch: string | null = targetBefore.refName; | |
| 1290 | + | |
| 1291 | + if (shouldMutateCheckout && requestedBranch !== undefined) { | |
| 1292 | + if (createBranch) { | |
| 1293 | + yield* gitWorkflow | |
| 1294 | + .createRef({ | |
| 1295 | + cwd: targetWorkspacePath, | |
| 1296 | + refName: requestedBranch, | |
| 1297 | + switchRef: false, | |
| 1298 | + }) | |
| 1299 | + .pipe(asOperationFailed(`Unable to create branch '${requestedBranch}'`)); | |
| 1300 | + createdBranch = requestedBranch; | |
| 1301 | + } | |
| 1302 | + const switchExit = yield* Effect.exit( | |
| 1303 | + gitWorkflow.switchRef({ cwd: targetWorkspacePath, refName: requestedBranch }), | |
| 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 | 1366 | return yield* failure( |
| 1132 | − "partial_failure", | |
| 1133 | − `Branch checkout failed and the resulting Git state could not be verified: ${errorMessage(Cause.squash(switchExit.cause))}`, | |
| 1134 | − { | |
| 1135 | − workspacePath: targetWorkspacePath, | |
| 1136 | − recordedBranch: projection.thread.branch, | |
| 1137 | − actualBranch: null, | |
| 1138 | − rollback: "not_possible", | |
| 1139 | − }, | |
| 1367 | + "operation_failed", | |
| 1368 | + `Unable to check out '${requestedBranch}': ${errorMessage(Cause.squash(switchExit.cause))}`, | |
| 1140 | 1369 | ); |
| 1141 | 1370 | } |
| 1142 | − const afterFailedSwitch = afterFailedSwitchExit.value; | |
| 1143 | − const checkoutChanged = afterFailedSwitch.refName !== targetBefore.refName; | |
| 1144 | − if (checkoutChanged && targetBefore.refName === null) { | |
| 1371 | + resolvedBranch = switchExit.value.refName; | |
| 1372 | + if (resolvedBranch === null) { | |
| 1145 | 1373 | return yield* failure( |
| 1146 | 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 | 1377 | workspacePath: targetWorkspacePath, |
| 1150 | 1378 | recordedBranch: projection.thread.branch, |
| 1151 | − actualBranch: afterFailedSwitch.refName, | |
| 1379 | + actualBranch: null, | |
| 1152 | 1380 | rollback: "not_possible", |
| 1153 | 1381 | }, |
| 1154 | 1382 | ); |
| 1155 | 1383 | } |
| 1156 | − const rollback = checkoutChanged | |
| 1157 | − ? gitWorkflow.switchRef({ | |
| 1158 | − cwd: targetWorkspacePath, | |
| 1159 | − refName: targetBefore.refName!, | |
| 1160 | − }) | |
| 1161 | − : Effect.void; | |
| 1162 | − const cleanup = rollback.pipe( | |
| 1163 | − Effect.andThen( | |
| 1164 | − createdBranch === null | |
| 1165 | − ? Effect.void | |
| 1166 | − : gitWorkflow.deleteLocalBranch({ | |
| 1167 | − cwd: targetWorkspacePath, | |
| 1168 | − refName: createdBranch, | |
| 1169 | − force: true, | |
| 1170 | − }), | |
| 1171 | − ), | |
| 1172 | − ); | |
| 1173 | − const cleanupExit = yield* Effect.exit(cleanup); | |
| 1174 | − if (Exit.isFailure(cleanupExit)) { | |
| 1175 | − const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath); | |
| 1176 | − return yield* failure( | |
| 1177 | − "partial_failure", | |
| 1178 | − `Branch checkout failed and rollback also failed: ${errorMessage(Cause.squash(switchExit.cause))}`, | |
| 1179 | − { | |
| 1180 | − workspacePath: targetWorkspacePath, | |
| 1181 | − recordedBranch: projection.thread.branch, | |
| 1182 | − actualBranch, | |
| 1183 | − rollback: "failed", | |
| 1184 | − }, | |
| 1384 | + checkoutAction = createBranch ? "created" : "switched"; | |
| 1385 | + } | |
| 1386 | + | |
| 1387 | + const actualExit = yield* Effect.exit(readWorkspaceStatus(targetWorkspacePath)); | |
| 1388 | + if (Exit.isFailure(actualExit)) { | |
| 1389 | + const detail = errorMessage(Cause.squash(actualExit.cause)); | |
| 1390 | + if (checkoutAction === "switched" || checkoutAction === "created") { | |
| 1391 | + if (targetBefore.refName === null) { | |
| 1392 | + return yield* failure( | |
| 1393 | + "partial_failure", | |
| 1394 | + `Git checkout completed but its resulting state could not be verified: ${detail}`, | |
| 1395 | + { | |
| 1396 | + workspacePath: targetWorkspacePath, | |
| 1397 | + recordedBranch: projection.thread.branch, | |
| 1398 | + actualBranch: null, | |
| 1399 | + rollback: "not_possible", | |
| 1400 | + }, | |
| 1401 | + ); | |
| 1402 | + } | |
| 1403 | + const rollbackExit = yield* Effect.exit( | |
| 1404 | + gitWorkflow | |
| 1405 | + .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName }) | |
| 1406 | + .pipe( | |
| 1407 | + Effect.andThen( | |
| 1408 | + createdBranch === null | |
| 1409 | + ? Effect.void | |
| 1410 | + : gitWorkflow.deleteLocalBranch({ | |
| 1411 | + cwd: targetWorkspacePath, | |
| 1412 | + refName: createdBranch, | |
| 1413 | + force: true, | |
| 1414 | + }), | |
| 1415 | + ), | |
| 1416 | + ), | |
| 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 | 1432 | return yield* failure( |
| 1188 | 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"; | |
| 1193 | − } | |
| 1194 | − | |
| 1195 | − const actualExit = yield* Effect.exit(readWorkspaceStatus(targetWorkspacePath)); | |
| 1196 | − if (Exit.isFailure(actualExit)) { | |
| 1197 | − const detail = errorMessage(Cause.squash(actualExit.cause)); | |
| 1198 | − if (checkoutAction === "switched" || checkoutAction === "created") { | |
| 1199 | − if (targetBefore.refName === null) { | |
| 1200 | − return yield* failure( | |
| 1201 | − "partial_failure", | |
| 1202 | − `Git checkout completed but its resulting state could not be verified: ${detail}`, | |
| 1203 | − { | |
| 1204 | − workspacePath: targetWorkspacePath, | |
| 1205 | − recordedBranch: projection.thread.branch, | |
| 1206 | − actualBranch: null, | |
| 1207 | − rollback: "not_possible", | |
| 1208 | − }, | |
| 1437 | + const actual = actualExit.value; | |
| 1438 | + if ( | |
| 1439 | + (checkoutAction === "switched" || checkoutAction === "created") && | |
| 1440 | + actual.refName !== resolvedBranch | |
| 1441 | + ) { | |
| 1442 | + return yield* failure( | |
| 1443 | + "partial_failure", | |
| 1444 | + `Git resolved '${requestedBranch}' to '${resolvedBranch}' but the checkout now reports '${actual.refName ?? "detached HEAD"}'. The durable thread binding was not changed.`, | |
| 1445 | + { | |
| 1446 | + workspacePath: targetWorkspacePath, | |
| 1447 | + recordedBranch: projection.thread.branch, | |
| 1448 | + actualBranch: actual.refName, | |
| 1449 | + rollback: "not_possible", | |
| 1450 | + }, | |
| 1451 | + ); | |
| 1452 | + } | |
| 1453 | + const nextBranch = actual.refName; | |
| 1454 | + const workspaceChanged = targetWorkspacePath !== currentWorkspacePath; | |
| 1455 | + const nextWorktreePath = workspaceChanged | |
| 1456 | + ? targetWorkspacePath === projectWorktreeRoot | |
| 1457 | + ? null | |
| 1458 | + : targetWorkspacePath | |
| 1459 | + : projection.thread.worktreePath; | |
| 1460 | + const bindingChanged = | |
| 1461 | + nextBranch !== projection.thread.branch || | |
| 1462 | + nextWorktreePath !== projection.thread.worktreePath; | |
| 1463 | + | |
| 1464 | + const rollbackOwnedCheckout = Effect.fn("WorktreeMcpService.rollbackOwnedCheckout")( | |
| 1465 | + function* () { | |
| 1466 | + if (checkoutAction !== "switched" && checkoutAction !== "created") { | |
| 1467 | + return "not_needed" as const; | |
| 1468 | + } | |
| 1469 | + if (targetBefore.refName === null) { | |
| 1470 | + return "not_possible" as const; | |
| 1471 | + } | |
| 1472 | + const [latestStatus, latestBindings, callerProjectionExit] = yield* Effect.all( | |
| 1473 | + [ | |
| 1474 | + readWorkspaceStatus(targetWorkspacePath), | |
| 1475 | + loadActiveWorkspaceBindings(inventory.repositoryCommonDir), | |
| 1476 | + Effect.exit(threadManagement.getThreadProjection(scope.threadId)), | |
| 1477 | + ], | |
| 1478 | + { concurrency: 3 }, | |
| 1209 | 1479 | ); |
| 1210 | − } | |
| 1211 | − const rollbackExit = yield* Effect.exit( | |
| 1212 | − gitWorkflow | |
| 1213 | − .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName }) | |
| 1214 | − .pipe( | |
| 1215 | − Effect.andThen( | |
| 1216 | − createdBranch === null | |
| 1217 | − ? Effect.void | |
| 1218 | − : gitWorkflow.deleteLocalBranch({ | |
| 1219 | − cwd: targetWorkspacePath, | |
| 1220 | − refName: createdBranch, | |
| 1221 | − force: true, | |
| 1222 | − }), | |
| 1480 | + const anotherOwner = latestBindings.some( | |
| 1481 | + ([thread, workspacePath]) => | |
| 1482 | + thread.id !== scope.threadId && workspacePath === targetWorkspacePath, | |
| 1483 | + ); | |
| 1484 | + const callerStillInitial = | |
| 1485 | + Exit.isSuccess(callerProjectionExit) && | |
| 1486 | + callerProjectionExit.value.thread.branch === projection.thread.branch && | |
| 1487 | + callerProjectionExit.value.thread.worktreePath === projection.thread.worktreePath; | |
| 1488 | + if (latestStatus.refName !== actual.refName || anotherOwner || !callerStillInitial) { | |
| 1489 | + return "not_possible" as const; | |
| 1490 | + } | |
| 1491 | + const rollbackExit = yield* Effect.exit( | |
| 1492 | + gitWorkflow | |
| 1493 | + .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName }) | |
| 1494 | + .pipe( | |
| 1495 | + Effect.andThen( | |
| 1496 | + createdBranch === null | |
| 1497 | + ? Effect.void | |
| 1498 | + : gitWorkflow.deleteLocalBranch({ | |
| 1499 | + cwd: targetWorkspacePath, | |
| 1500 | + refName: createdBranch, | |
| 1501 | + force: true, | |
| 1502 | + }), | |
| 1503 | + ), | |
| 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 | − } | |
| 1239 | − } | |
| 1240 | − return yield* failure( | |
| 1241 | − "operation_failed", | |
| 1242 | − `Unable to verify the selected checkout '${targetWorkspacePath}': ${detail}`, | |
| 1506 | + return Exit.isSuccess(rollbackExit) ? ("rolled_back" as const) : ("failed" as const); | |
| 1507 | + }, | |
| 1243 | 1508 | ); |
| 1244 | − } | |
| 1245 | − const actual = actualExit.value; | |
| 1246 | − const nextBranch = actual.refName; | |
| 1247 | − const workspaceChanged = targetWorkspacePath !== currentWorkspacePath; | |
| 1248 | − const nextWorktreePath = workspaceChanged | |
| 1249 | − ? targetWorkspacePath === projectWorkspaceRoot | |
| 1250 | − ? null | |
| 1251 | − : targetWorkspacePath | |
| 1252 | − : projection.thread.worktreePath; | |
| 1253 | − const bindingChanged = | |
| 1254 | − nextBranch !== projection.thread.branch || | |
| 1255 | − nextWorktreePath !== projection.thread.worktreePath; | |
| 1256 | − | |
| 1257 | − if (bindingChanged) { | |
| 1258 | − const dispatchExit = yield* Effect.exit( | |
| 1259 | − threadManagement.dispatch({ | |
| 1260 | − type: "thread.metadata.update", | |
| 1261 | − commandId: ids.commandId, | |
| 1262 | − threadId: scope.threadId, | |
| 1263 | − branch: nextBranch, | |
| 1264 | − worktreePath: nextWorktreePath, | |
| 1265 | − expectedBranch: projection.thread.branch, | |
| 1266 | − expectedWorktreePath: projection.thread.worktreePath, | |
| 1267 | − }), | |
| 1268 | − ); | |
| 1269 | − if (Exit.isFailure(dispatchExit)) { | |
| 1270 | − const dispatchDetail = errorMessage(Cause.squash(dispatchExit.cause)); | |
| 1271 | − const bindingAfterDispatchExit = yield* Effect.exit(loadThread(scope)); | |
| 1272 | − if (Exit.isFailure(bindingAfterDispatchExit)) { | |
| 1509 | + | |
| 1510 | + if (bindingChanged) { | |
| 1511 | + const preCommitProjectionExit = yield* Effect.exit(loadThread(scope)); | |
| 1512 | + const preCommitProjection = Exit.isSuccess(preCommitProjectionExit) | |
| 1513 | + ? preCommitProjectionExit.value | |
| 1514 | + : null; | |
| 1515 | + const metadataChanged = | |
| 1516 | + preCommitProjection === null || | |
| 1517 | + preCommitProjection.thread.branch !== projection.thread.branch || | |
| 1518 | + preCommitProjection.thread.worktreePath !== projection.thread.worktreePath || | |
| 1519 | + preCommitProjection.thread.archivedAt !== projection.thread.archivedAt; | |
| 1520 | + if (metadataChanged) { | |
| 1521 | + const rollback = yield* rollbackOwnedCheckout(); | |
| 1522 | + if (rollback === "failed" || rollback === "not_possible") { | |
| 1523 | + return yield* failure( | |
| 1524 | + "partial_failure", | |
| 1525 | + `The thread changed or disappeared after Git checkout, so its durable binding was not updated and Git rollback was ${rollback === "failed" ? "unsuccessful" : "unsafe"}.`, | |
| 1526 | + { | |
| 1527 | + workspacePath: targetWorkspacePath, | |
| 1528 | + recordedBranch: projection.thread.branch, | |
| 1529 | + actualBranch: yield* readWorkspaceBranchOrNull(targetWorkspacePath), | |
| 1530 | + rollback: rollback === "failed" ? "failed" : "not_possible", | |
| 1531 | + }, | |
| 1532 | + ); | |
| 1533 | + } | |
| 1273 | 1534 | return yield* failure( |
| 1274 | − "partial_failure", | |
| 1275 | − `The durable binding update reported a failure and its outcome could not be verified: ${dispatchDetail}`, | |
| 1276 | − { | |
| 1277 | − workspacePath: targetWorkspacePath, | |
| 1278 | − recordedBranch: projection.thread.branch, | |
| 1279 | − actualBranch: actual.refName, | |
| 1280 | − rollback: "not_possible", | |
| 1281 | − }, | |
| 1535 | + "checkout_in_progress", | |
| 1536 | + `Thread '${scope.threadId}' changed or disappeared before the workspace binding committed. Git state was left unchanged.`, | |
| 1282 | 1537 | ); |
| 1283 | 1538 | } |
| 1284 | − const bindingAfterDispatch = bindingAfterDispatchExit.value.thread; | |
| 1285 | − const bindingCommitted = | |
| 1286 | − bindingAfterDispatch.branch === nextBranch && | |
| 1287 | − bindingAfterDispatch.worktreePath === nextWorktreePath; | |
| 1288 | − if (bindingCommitted) { | |
| 1289 | − yield* Effect.logWarning( | |
| 1290 | − "workspace binding dispatch reported failure after the binding committed", | |
| 1291 | − { | |
| 1292 | − threadId: scope.threadId, | |
| 1293 | − workspacePath: targetWorkspacePath, | |
| 1294 | − detail: dispatchDetail, | |
| 1295 | − }, | |
| 1296 | − ); | |
| 1297 | − } else { | |
| 1298 | − if (checkoutAction === "switched" || checkoutAction === "created") { | |
| 1299 | − if (targetBefore.refName === null) { | |
| 1300 | − return yield* failure( | |
| 1301 | − "partial_failure", | |
| 1302 | − `Git checkout completed but the durable thread binding failed: ${dispatchDetail}`, | |
| 1303 | − { | |
| 1304 | − workspacePath: targetWorkspacePath, | |
| 1305 | − recordedBranch: projection.thread.branch, | |
| 1306 | − actualBranch: actual.refName, | |
| 1307 | − rollback: "not_possible", | |
| 1308 | − }, | |
| 1309 | − ); | |
| 1310 | − } | |
| 1311 | − const rollbackExit = yield* Effect.exit( | |
| 1312 | − gitWorkflow | |
| 1313 | − .switchRef({ cwd: targetWorkspacePath, refName: targetBefore.refName }) | |
| 1314 | − .pipe( | |
| 1315 | − Effect.andThen( | |
| 1316 | − createdBranch === null | |
| 1317 | − ? Effect.void | |
| 1318 | − : gitWorkflow.deleteLocalBranch({ | |
| 1319 | − cwd: targetWorkspacePath, | |
| 1320 | − refName: createdBranch, | |
| 1321 | − force: true, | |
| 1322 | − }), | |
| 1323 | − ), | |
| 1324 | − ), | |
| 1539 | + const dispatchExit = yield* Effect.exit( | |
| 1540 | + threadManagement.dispatch({ | |
| 1541 | + type: "thread.metadata.update", | |
| 1542 | + commandId: ids.commandId, | |
| 1543 | + threadId: scope.threadId, | |
| 1544 | + branch: nextBranch, | |
| 1545 | + worktreePath: nextWorktreePath, | |
| 1546 | + expectedBranch: projection.thread.branch, | |
| 1547 | + expectedWorktreePath: projection.thread.worktreePath, | |
| 1548 | + }), | |
| 1549 | + ); | |
| 1550 | + if (Exit.isFailure(dispatchExit)) { | |
| 1551 | + const dispatchDetail = errorMessage(Cause.squash(dispatchExit.cause)); | |
| 1552 | + const bindingAfterDispatchExit = yield* Effect.exit(loadThread(scope)); | |
| 1553 | + if (Exit.isFailure(bindingAfterDispatchExit)) { | |
| 1554 | + return yield* failure( | |
| 1555 | + "partial_failure", | |
| 1556 | + `The durable binding update reported a failure and its outcome could not be verified: ${dispatchDetail}`, | |
| 1557 | + { | |
| 1558 | + workspacePath: targetWorkspacePath, | |
| 1559 | + recordedBranch: projection.thread.branch, | |
| 1560 | + actualBranch: actual.refName, | |
| 1561 | + rollback: "not_possible", | |
| 1562 | + }, | |
| 1563 | + ); | |
| 1564 | + } | |
| 1565 | + const bindingAfterDispatch = bindingAfterDispatchExit.value.thread; | |
| 1566 | + const bindingCommitted = | |
| 1567 | + bindingAfterDispatch.branch === nextBranch && | |
| 1568 | + bindingAfterDispatch.worktreePath === nextWorktreePath; | |
| 1569 | + if (bindingCommitted) { | |
| 1570 | + yield* Effect.logWarning( | |
| 1571 | + "workspace binding dispatch reported failure after the binding committed", | |
| 1572 | + { | |
| 1573 | + threadId: scope.threadId, | |
| 1574 | + workspacePath: targetWorkspacePath, | |
| 1575 | + detail: dispatchDetail, | |
| 1576 | + }, | |
| 1325 | 1577 | ); |
| 1326 | − if (Exit.isFailure(rollbackExit)) { | |
| 1327 | − const actualBranch = yield* readWorkspaceBranchOrNull(targetWorkspacePath); | |
| 1578 | + } else { | |
| 1579 | + const bindingStillInitial = | |
| 1580 | + bindingAfterDispatch.branch === projection.thread.branch && | |
| 1581 | + bindingAfterDispatch.worktreePath === projection.thread.worktreePath; | |
| 1582 | + const rollback = bindingStillInitial | |
| 1583 | + ? yield* rollbackOwnedCheckout() | |
| 1584 | + : ("not_possible" as const); | |
| 1585 | + if (rollback === "failed" || rollback === "not_possible") { | |
| 1328 | 1586 | return yield* failure( |
| 1329 | 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 | 1590 | workspacePath: targetWorkspacePath, |
| 1333 | − recordedBranch: projection.thread.branch, | |
| 1334 | − actualBranch, | |
| 1335 | − rollback: "failed", | |
| 1591 | + recordedBranch: bindingAfterDispatch.branch, | |
| 1592 | + actualBranch: yield* readWorkspaceBranchOrNull(targetWorkspacePath), | |
| 1593 | + rollback: rollback === "failed" ? "failed" : "not_possible", | |
| 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 | |
| 1349 | − ? yield* queueContinuation({ | |
| 1350 | − scope, | |
| 1351 | − projection, | |
| 1352 | − prompt: input.continuationPrompt, | |
| 1353 | − commandId: ids.continuationCommandId, | |
| 1354 | − messageId: ids.continuationMessageId, | |
| 1355 | − workspacePath: targetWorkspacePath, | |
| 1356 | − }) | |
| 1357 | − : ({ status: "skipped" } as const); | |
| 1358 | − const previous = { | |
| 1359 | − workspacePath: currentWorkspacePath, | |
| 1360 | − recordedBranch: projection.thread.branch, | |
| 1361 | − recordedWorktreePath: projection.thread.worktreePath, | |
| 1362 | − actualBranch: previousActual.refName, | |
| 1363 | − }; | |
| 1364 | − const current = { | |
| 1365 | − workspacePath: targetWorkspacePath, | |
| 1366 | − recordedBranch: nextBranch, | |
| 1367 | − recordedWorktreePath: nextWorktreePath, | |
| 1368 | − actualBranch: actual.refName, | |
| 1369 | − }; | |
| 1370 | − return { | |
| 1371 | − previous, | |
| 1372 | − current, | |
| 1373 | − checkoutAction, | |
| 1374 | − workspaceChanged, | |
| 1375 | − branchChanged: previousActual.refName !== actual.refName, | |
| 1376 | − continuation, | |
| 1377 | − setupScript: { status: "skipped" }, | |
| 1378 | − callerTurnEnds: workspaceChanged, | |
| 1379 | − note: workspaceChanged | |
| 1380 | − ? continuation.status === "scheduled" | |
| 1381 | − ? "Checkout and durable thread binding completed. The workspace change detaches this provider session; the queued continuation starts the next turn in the selected checkout." | |
| 1382 | − : "Checkout and durable thread binding completed. The workspace change detaches this provider session, so this turn ends after the call. Send another message to continue in the selected checkout." | |
| 1383 | − : "Checkout and durable thread binding completed without changing the provider session workspace.", | |
| 1384 | − } satisfies WorktreeMcpCheckoutResult; | |
| 1605 | + const continuation = workspaceChanged | |
| 1606 | + ? yield* queueContinuation({ | |
| 1607 | + scope, | |
| 1608 | + projection, | |
| 1609 | + prompt: input.continuationPrompt, | |
| 1610 | + commandId: ids.continuationCommandId, | |
| 1611 | + messageId: ids.continuationMessageId, | |
| 1612 | + workspacePath: targetWorkspacePath, | |
| 1613 | + }) | |
| 1614 | + : ({ status: "skipped" } as const); | |
| 1615 | + const previous = { | |
| 1616 | + workspacePath: currentWorkspacePath, | |
| 1617 | + recordedBranch: projection.thread.branch, | |
| 1618 | + recordedWorktreePath: projection.thread.worktreePath, | |
| 1619 | + actualBranch: previousActual.refName, | |
| 1620 | + }; | |
| 1621 | + const current = { | |
| 1622 | + workspacePath: targetWorkspacePath, | |
| 1623 | + recordedBranch: nextBranch, | |
| 1624 | + recordedWorktreePath: nextWorktreePath, | |
| 1625 | + actualBranch: actual.refName, | |
| 1626 | + }; | |
| 1627 | + return { | |
| 1628 | + previous, | |
| 1629 | + current, | |
| 1630 | + checkoutAction, | |
| 1631 | + workspaceChanged, | |
| 1632 | + branchChanged: previousActual.refName !== actual.refName, | |
| 1633 | + continuation, | |
| 1634 | + setupScript: { status: "skipped" }, | |
| 1635 | + callerTurnEnds: workspaceChanged, | |
| 1636 | + note: workspaceChanged | |
| 1637 | + ? continuation.status === "scheduled" | |
| 1638 | + ? "Checkout and durable thread binding completed. The workspace change detaches this provider session; the queued continuation starts the next turn in the selected checkout." | |
| 1639 | + : "Checkout and durable thread binding completed. The workspace change detaches this provider session, so this turn ends after the call. Send another message to continue in the selected checkout." | |
| 1640 | + : "Checkout and durable thread binding completed without changing the provider session workspace.", | |
| 1641 | + } satisfies WorktreeMcpCheckoutResult; | |
| 1642 | + }).pipe( | |
| 1643 | + Effect.ensuring( | |
| 1644 | + Effect.sync(() => workspaceTransitionsInFlight.delete(workspaceGuardKey)), | |
| 1645 | + ), | |
| 1646 | + ); | |
| 1385 | 1647 | }), |
| 1386 | 1648 | ); |
| 1387 | 1649 | }); |
| @@ -1415,6 +1677,7 @@ export const layer: Layer.Layer< · 27 unmodified lines | ||
| 1415 | 1677 | WorktreeMcpService, |
| 1416 | 1678 | never, |
| 1417 | 1679 | | Crypto.Crypto |
| 1680 | + | FileSystem.FileSystem | |
| 1418 | 1681 | | Path.Path |
| 1419 | 1682 | | ThreadManagementService |
| 1420 | 1683 | | ProjectService.ProjectService |
| @@ -15,6 +15,7 @@ import { · 14 unmodified lines | ||
| 15 | 15 | ThreadId, |
| 16 | 16 | } from "@t3tools/contracts"; |
| 17 | 17 | import * as DateTime from "effect/DateTime"; |
| 18 | +import * as Deferred from "effect/Deferred"; | |
| 18 | 19 | import * as Effect from "effect/Effect"; |
| 19 | 20 | import * as Queue from "effect/Queue"; |
| 20 | 21 | import * as Ref from "effect/Ref"; |
| @@ -84,6 +85,14 @@ interface RestartAdapterState { · 63 unmodified lines | ||
| 84 | 85 | function makeRestartAdapter( |
| 85 | 86 | state: Ref.Ref<RestartAdapterState>, |
| 86 | 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 | 96 | ): ProviderAdapterV2Shape { |
| 88 | 97 | return { |
| 89 | 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 | 242 | const active = { |
| 228 | 243 | input, |
| 229 | 244 | providerTurnId: ProviderTurnId.make(`provider-turn:${input.attemptId}`), |
| @@ -259,6 +274,10 @@ function makeRestartAdapter( · 29 unmodified lines | ||
| 259 | 274 | Effect.gen(function* () { |
| 260 | 275 | const active = (yield* Ref.get(state)).activeTurn; |
| 261 | 276 | if (active !== null) { |
| 277 | + if (interruptGate !== undefined) { | |
| 278 | + yield* Queue.offer(interruptGate.requested, undefined); | |
| 279 | + yield* Deferred.await(interruptGate.release); | |
| 280 | + } | |
| 262 | 281 | const updatedAt = yield* DateTime.now; |
| 263 | 282 | yield* Queue.offer(events, { |
| 264 | 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 | |
| 551 | +it.live("queues a workspace continuation before detaching and starts it in the new cwd", () => | |
| 552 | + Effect.scoped( | |
| 553 | + Effect.gen(function* () { | |
| 554 | + const initialCwd = yield* checkpointWorkspace("workspace-continuation-initial"); | |
| 555 | + const targetCwd = yield* checkpointWorkspace("workspace-continuation-target"); | |
| 556 | + const threadId = ThreadId.make("thread:workspace-continuation"); | |
| 557 | + const projectId = ProjectId.make("project:workspace-continuation"); | |
| 558 | + const state = yield* Ref.make<RestartAdapterState>({ | |
| 559 | + activeTurn: null, | |
| 560 | + opened: [], | |
| 561 | + started: [], | |
| 562 | + closedSessionCount: 0, | |
| 563 | + failedReplacementOpen: false, | |
| 564 | + }); | |
| 565 | + const initialTurnStarted = yield* Deferred.make<{ | |
| 566 | + readonly model: string; | |
| 567 | + readonly cwd: string | null; | |
| 568 | + }>(); | |
| 569 | + const continuationTurnStarted = yield* Deferred.make<{ | |
| 570 | + readonly model: string; | |
| 571 | + readonly cwd: string | null; | |
| 572 | + }>(); | |
| 573 | + const interruptRequested = yield* Queue.unbounded<void>(); | |
| 574 | + const interruptRelease = yield* Deferred.make<void>(); | |
| 575 | + const registry = makeSingleProviderAdapterRegistryLayer( | |
| 576 | + makeRestartAdapter( | |
| 577 | + state, | |
| 578 | + pooledCapabilities, | |
| 579 | + (started) => | |
| 580 | + Deferred.succeed( | |
| 581 | + started.cwd === initialCwd ? initialTurnStarted : continuationTurnStarted, | |
| 582 | + started, | |
| 583 | + ).pipe(Effect.asVoid), | |
| 584 | + { | |
| 585 | + requested: interruptRequested, | |
| 586 | + release: interruptRelease, | |
| 587 | + }, | |
| 588 | + ), | |
| 589 | + ); | |
| 590 | + const orchestratorLayer = makeOrchestratorV2ReplayLayerWithRegistry( | |
| 591 | + { name: "workspace-continuation" }, | |
| 592 | + registry, | |
| 593 | + ); | |
| 594 | + | |
| 595 | + yield* Effect.gen(function* () { | |
| 596 | + const orchestrator = yield* OrchestratorV2; | |
| 597 | + yield* orchestrator.dispatch({ | |
| 598 | + type: "thread.create", | |
| 599 | + createdBy: "user", | |
| 600 | + creationSource: "web", | |
| 601 | + commandId: CommandId.make("command:workspace-continuation:create"), | |
| 602 | + threadId, | |
| 603 | + projectId, | |
| 604 | + title: "Workspace continuation", | |
| 605 | + modelSelection: initialSelection, | |
| 606 | + runtimeMode: "full-access", | |
| 607 | + interactionMode: "default", | |
| 608 | + branch: "main", | |
| 609 | + worktreePath: initialCwd, | |
| 610 | + }); | |
| 611 | + const providerTurnProjected = yield* Deferred.make<void>(); | |
| 612 | + const afterCreate = yield* orchestrator.getThreadEventSequence(threadId); | |
| 613 | + yield* orchestrator.streamStoredEventsFrom({ threadId, afterSequence: afterCreate }).pipe( | |
| 614 | + Stream.runForEach((stored) => | |
| 615 | + stored.event.type === "provider-turn.updated" && | |
| 616 | + stored.event.payload.status === "running" | |
| 617 | + ? Deferred.succeed(providerTurnProjected, undefined).pipe(Effect.asVoid) | |
| 618 | + : Effect.void, | |
| 619 | + ), | |
| 620 | + Effect.forkScoped, | |
| 621 | + ); | |
| 622 | + const initialSendReceipt = yield* orchestrator.dispatch({ | |
| 623 | + type: "message.dispatch", | |
| 624 | + commandId: CommandId.make("command:workspace-continuation:first"), | |
| 625 | + threadId, | |
| 626 | + messageId: MessageId.make("message:workspace-continuation:first"), | |
| 627 | + text: "Start in the original checkout.", | |
| 628 | + attachments: [], | |
| 629 | + modelSelection: initialSelection, | |
| 630 | + dispatchMode: { type: "start_immediately" }, | |
| 631 | + createdBy: "user", | |
| 632 | + creationSource: "web", | |
| 633 | + }); | |
| 634 | + assert.isTrue(initialSendReceipt.storedEvents.length > 0); | |
| 635 | + assert.deepEqual(yield* Deferred.await(initialTurnStarted), { | |
| 636 | + model: initialSelection.model, | |
| 637 | + cwd: initialCwd, | |
| 638 | + }); | |
| 639 | + yield* Deferred.await(providerTurnProjected); | |
| 640 | + | |
| 641 | + const bindingReceipt = yield* orchestrator.dispatch({ | |
| 642 | + type: "thread.metadata.update", | |
| 643 | + commandId: CommandId.make("command:workspace-continuation:binding"), | |
| 644 | + threadId, | |
| 645 | + branch: "feature/workspace-continuation", | |
| 646 | + worktreePath: targetCwd, | |
| 647 | + expectedBranch: "main", | |
| 648 | + expectedWorktreePath: initialCwd, | |
| 649 | + }); | |
| 650 | + assert.isTrue( | |
| 651 | + bindingReceipt.storedEvents.some( | |
| 652 | + (stored) => stored.event.type === "provider-session.detached", | |
| 653 | + ), | |
| 654 | + ); | |
| 655 | + | |
| 656 | + const continuationReceipt = yield* orchestrator.dispatch({ | |
| 657 | + type: "message.dispatch", | |
| 658 | + commandId: CommandId.make("command:workspace-continuation:queued"), | |
| 659 | + threadId, | |
| 660 | + messageId: MessageId.make("message:workspace-continuation:queued"), | |
| 661 | + text: "Continue after the workspace handoff.", | |
| 662 | + attachments: [], | |
| 663 | + modelSelection: initialSelection, | |
| 664 | + dispatchMode: { type: "queue_after_active" }, | |
| 665 | + createdBy: "agent", | |
| 666 | + creationSource: "mcp", | |
| 667 | + }); | |
| 668 | + assert.isTrue(continuationReceipt.storedEvents.length > 0); | |
| 669 | + const queued = yield* orchestrator.getThreadProjection(threadId); | |
| 670 | + const continuationRun = queued.runs.find( | |
| 671 | + (run) => run.userMessageId === MessageId.make("message:workspace-continuation:queued"), | |
| 672 | + ); | |
| 673 | + assert.isDefined(continuationRun); | |
| 674 | + assert.equal(continuationRun.status, "queued"); | |
| 675 | + yield* Queue.take(interruptRequested); | |
| 676 | + yield* Deferred.succeed(interruptRelease, undefined); | |
| 677 | + | |
| 678 | + assert.deepEqual(yield* Deferred.await(continuationTurnStarted), { | |
| 679 | + model: initialSelection.model, | |
| 680 | + cwd: targetCwd, | |
| 681 | + }); | |
| 682 | + const projection = yield* orchestrator.getThreadProjection(threadId); | |
| 683 | + assert.equal(projection.thread.branch, "feature/workspace-continuation"); | |
| 684 | + assert.equal(projection.thread.worktreePath, targetCwd); | |
| 685 | + assert.equal( | |
| 686 | + projection.messages.find( | |
| 687 | + (message) => message.id === MessageId.make("message:workspace-continuation:queued"), | |
| 688 | + )?.text, | |
| 689 | + "Continue after the workspace handoff.", | |
| 690 | + ); | |
| 691 | + }).pipe(Effect.provide(orchestratorLayer)); | |
| 692 | + }), | |
| 693 | + ), | |
| 694 | +); | |
| 695 | + | |
| 532 | 696 | it.live("detaches the old provider session after an active provider handoff", () => |
| 533 | 697 | Effect.scoped( |
| 534 | 698 | Effect.gen(function* () { |
| @@ -1560,6 +1560,33 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { · 1559 unmodified lines | ||
| 1560 | 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 | 1592 | describe("worktree operations", () => { |