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) => {...@@ -11614,9 +11614,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11614 );11614 );
1161511615
11616 it.effect.each([11616 it.effect.each([
11617 { caseName: "async setup scripts let the turn start before the script exits", async: true },11617 {
11618 { caseName: "sync setup scripts hold the turn until the script exits", async: false },11618 caseName: "async setup scripts let the turn start before the script exits",
11619 ])("$caseName", ({ async }) =>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 }) =>
11620 Effect.gen(function* () {11633 Effect.gen(function* () {
11621 const dispatchedCommands: Array<OrchestrationCommand> = [];11634 const dispatchedCommands: Array<OrchestrationCommand> = [];
11622 const scriptExit = yield* Deferred.make<void>();11635 const scriptExit = yield* Deferred.make<void>();
...@@ -11750,6 +11763,26 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -11750,6 +11763,26 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11750 assert.equal(stageStatus(running, "agent"), "pending");11763 assert.equal(stageStatus(running, "agent"), "pending");
11751 assert.isFalse(turnStarted());11764 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
11753 // The client that sent the message goes away mid-setup (a reload or a11786 // The client that sent the message goes away mid-setup (a reload or a
11754 // dropped socket). The bootstrap belongs to the server, not the11787 // dropped socket). The bootstrap belongs to the server, not the
11755 // connection: the thread already exists for every client, so it must11788 // connection: the thread already exists for every client, so it must
apps/server/src/ws.ts+3
...@@ -1620,6 +1620,7 @@ const makeWsRpcLayer = (...@@ -1620,6 +1620,7 @@ const makeWsRpcLayer = (
1620 );1620 );
16211621
1622 const settledBootstrapProgram = bootstrapProgram.pipe(1622 const settledBootstrapProgram = bootstrapProgram.pipe(
1623 Effect.interruptible,
1623 Effect.catchCause((cause) => {1624 Effect.catchCause((cause) => {
1624 const dispatchError = toBootstrapDispatchCommandCauseError(cause);1625 const dispatchError = toBootstrapDispatchCommandCauseError(cause);
1625 if (Cause.hasInterruptsOnly(cause)) {1626 if (Cause.hasInterruptsOnly(cause)) {
...@@ -1687,6 +1688,8 @@ const makeWsRpcLayer = (...@@ -1687,6 +1688,8 @@ const makeWsRpcLayer = (
1687 ),1688 ),
1688 ).pipe(Effect.andThen(cleanupAndFail(cause, dispatchError)));1689 ).pipe(Effect.andThen(cleanupAndFail(cause, dispatchError)));
1689 }),1690 }),
1691 // Cancellation must finish recording and rollback after the bootstrap is interrupted.
1692 Effect.uninterruptible,
1690 );1693 );
16911694
1692 // The bootstrap outlives the connection that asked for it: a reload1695 // The bootstrap outlives the connection that asked for it: a reload