From 51c0812a4536387f4d0ea50dca179fb750153354 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 18:31:21 -0700 Subject: [PATCH] fix(server): settle cancelled worktree setup before rollback --- apps/server/src/server.test.ts | 39 +++++++++++++++++++++++++++++++--- apps/server/src/ws.ts | 3 +++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2a16bbfe0074..d11129a1842a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -11614,9 +11614,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); it.effect.each([ - { caseName: "async setup scripts let the turn start before the script exits", async: true }, - { caseName: "sync setup scripts hold the turn until the script exits", async: false }, - ])("$caseName", ({ async }) => + { + caseName: "async setup scripts let the turn start before the script exits", + async: true, + cancel: false, + }, + { + caseName: "sync setup scripts hold the turn until the script exits", + async: false, + cancel: false, + }, + { + caseName: "cancelling worktree setup publishes its outcome and cleans up the thread", + async: false, + cancel: true, + }, + ])("$caseName", ({ async, cancel }) => Effect.gen(function* () { const dispatchedCommands: Array = []; const scriptExit = yield* Deferred.make(); @@ -11750,6 +11763,26 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(stageStatus(running, "agent"), "pending"); assert.isFalse(turnStarted()); + if (cancel) { + const cancelled = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.worktreeSetupCancel]({ threadId })), + ); + assert.isTrue(cancelled.cancelled); + assertTrue(dispatchedCommands.some((command) => command.type === "thread.delete")); + const outcome = dispatchedCommands.findLast( + (command) => + command.type === "thread.activity.append" && command.activity.kind === "worktree-setup", + ); + assertTrue(outcome?.type === "thread.activity.append"); + assert.propertyVal(outcome.activity.payload, "phase", "cancelled"); + const result = yield* Fiber.join(dispatchFiber).pipe(Effect.result); + assertTrue(result._tag === "Failure"); + assert.propertyVal(result.failure, "message", "Worktree setup cancelled."); + assert.propertyVal(result.failure, "bootstrapThreadDisposition", "deleted"); + assert.isFalse(turnStarted()); + return; + } + // The client that sent the message goes away mid-setup (a reload or a // dropped socket). The bootstrap belongs to the server, not the // connection: the thread already exists for every client, so it must diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8dfd33ea4b1c..fe1b202599de 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1620,6 +1620,7 @@ const makeWsRpcLayer = ( ); const settledBootstrapProgram = bootstrapProgram.pipe( + Effect.interruptible, Effect.catchCause((cause) => { const dispatchError = toBootstrapDispatchCommandCauseError(cause); if (Cause.hasInterruptsOnly(cause)) { @@ -1687,6 +1688,8 @@ const makeWsRpcLayer = ( ), ).pipe(Effect.andThen(cleanupAndFail(cause, dispatchError))); }), + // Cancellation must finish recording and rollback after the bootstrap is interrupted. + Effect.uninterruptible, ); // The bootstrap outlives the connection that asked for it: a reload