authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-16 18:42:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 18:42:24-07:00
log3bee4c286e586c2eb7f8d196a65b4b5ea6a95241
treec87367c6e4b4228fa32bb5d55648a96700c009cd
parent962bf62921c0ecbc9320f9b071e16b1d41484aad
signature Signed by PGP key B5690EEEBB952194

fix(server): settle cancelled worktree setup before rollback (#12176)


2 files changed, 39 insertions(+), 3 deletions(-)

apps/server/src/server.test.ts+36-3
......@@ -11614,9 +11614,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1161411614 );
1161511615
1161611616 it.effect.each([
11617 { caseName: "async setup scripts let the turn start before the script exits", async: true },
11618 { caseName: "sync setup scripts hold the turn until the script exits", async: false },
11619 ])("$caseName", ({ async }) =>
11617 {
11618 caseName: "async setup scripts let the turn start before the script exits",
11619 async: true,
11620 cancel: false,
11621 },
11622 {
11623 caseName: "sync setup scripts hold the turn until the script exits",
11624 async: false,
11625 cancel: false,
11626 },
11627 {
11628 caseName: "cancelling worktree setup publishes its outcome and cleans up the thread",
11629 async: false,
11630 cancel: true,
11631 },
11632 ])("$caseName", ({ async, cancel }) =>
1162011633 Effect.gen(function* () {
1162111634 const dispatchedCommands: Array<OrchestrationCommand> = [];
1162211635 const scriptExit = yield* Deferred.make<void>();
......@@ -11750,6 +11763,26 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1175011763 assert.equal(stageStatus(running, "agent"), "pending");
1175111764 assert.isFalse(turnStarted());
1175211765
11766 if (cancel) {
11767 const cancelled = yield* Effect.scoped(
11768 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.worktreeSetupCancel]({ threadId })),
11769 );
11770 assert.isTrue(cancelled.cancelled);
11771 assertTrue(dispatchedCommands.some((command) => command.type === "thread.delete"));
11772 const outcome = dispatchedCommands.findLast(
11773 (command) =>
11774 command.type === "thread.activity.append" && command.activity.kind === "worktree-setup",
11775 );
11776 assertTrue(outcome?.type === "thread.activity.append");
11777 assert.propertyVal(outcome.activity.payload, "phase", "cancelled");
11778 const result = yield* Fiber.join(dispatchFiber).pipe(Effect.result);
11779 assertTrue(result._tag === "Failure");
11780 assert.propertyVal(result.failure, "message", "Worktree setup cancelled.");
11781 assert.propertyVal(result.failure, "bootstrapThreadDisposition", "deleted");
11782 assert.isFalse(turnStarted());
11783 return;
11784 }
11785
1175311786 // The client that sent the message goes away mid-setup (a reload or a
1175411787 // dropped socket). The bootstrap belongs to the server, not the
1175511788 // connection: the thread already exists for every client, so it must
apps/server/src/ws.ts+3
......@@ -1620,6 +1620,7 @@ const makeWsRpcLayer = (
16201620 );
16211621
16221622 const settledBootstrapProgram = bootstrapProgram.pipe(
1623 Effect.interruptible,
16231624 Effect.catchCause((cause) => {
16241625 const dispatchError = toBootstrapDispatchCommandCauseError(cause);
16251626 if (Cause.hasInterruptsOnly(cause)) {
......@@ -1687,6 +1688,8 @@ const makeWsRpcLayer = (
16871688 ),
16881689 ).pipe(Effect.andThen(cleanupAndFail(cause, dispatchError)));
16891690 }),
1691 // Cancellation must finish recording and rollback after the bootstrap is interrupted.
1692 Effect.uninterruptible,
16901693 );
16911694
16921695 // The bootstrap outlives the connection that asked for it: a reload