From 5205e0e6ab9cb8c054b54b9252e0855b902471d5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:47:42 +1100 Subject: [PATCH 1/2] fix(server): stop a cancelled setup's branch rename before forgetting its worktree The background branch rename runs in the service-lifetime preparation scope, so cancelling worktree setup did not stop it. When the rename landed after the cleanup it recorded the removed worktree on the thread again, which made "cancels tracked setup before provider work is released" fail intermittently. Cleanup now interrupts the rename first. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/ThreadLaunchService.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 42b1d9cef1e4..90d3315c4d87 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -23,6 +23,7 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -317,6 +318,9 @@ const make = Effect.gen(function* () { let createdWorktreePath: string | null = null; let setupTerminalId: string | null = null; let workspaceRecorded = false; + // The background branch rename records the worktree again when it lands, + // so cleanup stops it before forgetting a removed worktree. + let branchRename: Fiber.Fiber | null = null; if (input.workspaceStrategy.type === "worktree") { yield* setupTracker.begin({ threadId, @@ -505,7 +509,7 @@ const make = Effect.gen(function* () { ) { const oldBranch = branch; const worktreeCwd = worktreePath; - yield* generateBranchNameFor(worktreeCwd, initialMessage).pipe( + branchRename = yield* generateBranchNameFor(worktreeCwd, initialMessage).pipe( Effect.flatMap(({ branch: newBranch, exactName }) => git.renameBranch({ cwd: worktreeCwd, @@ -641,6 +645,7 @@ const make = Effect.gen(function* () { // the thread recorded, so a retry reuses it, and removes one it never // recorded, which a retry would otherwise duplicate. if (tracked && createdWorktreePath && (cancelled || !workspaceRecorded)) { + if (branchRename !== null) yield* Fiber.interrupt(branchRename); if (setupTerminalId) yield* terminals .close({ threadId, terminalId: setupTerminalId, deleteHistory: true }) From e637841730bd5ed9b65b590b78688076846e3d0b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:47:31 +1100 Subject: [PATCH 2/2] test(server): prove a cancelled setup stops its in-flight branch rename The rename is held open until after the cancel, so the test fails on every run without the fix instead of only when the rename happens to land late. Co-Authored-By: Claude Opus 5.5 --- .../ThreadLaunchService.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 3012e09411ac..3d34030f8341 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -2451,6 +2451,55 @@ it.effect("cancels tracked setup before provider work is released", () => }), ); +it.effect("stops an in-flight branch rename when tracked setup is cancelled", () => + Effect.gen(function* () { + const setupEntered = yield* Deferred.make(); + const branchNameStarted = yield* Deferred.make(); + const allowBranchName = yield* Deferred.make(); + const branchNameInterrupted = yield* Deferred.make(); + const renameAttempted = yield* Deferred.make(); + const harness = makeHarness({ + renameBranch: (input) => + Deferred.succeed(renameAttempted, undefined).pipe(Effect.as({ branch: input.newBranch })), + runSetup: () => Deferred.succeed(setupEntered, undefined).pipe(Effect.andThen(Effect.never)), + generateBranchName: () => + Deferred.succeed(branchNameStarted, undefined).pipe( + Effect.andThen(Deferred.await(allowBranchName)), + Effect.as({ branch: "generated-branch" }), + Effect.onInterrupt(() => Deferred.succeed(branchNameInterrupted, undefined)), + ), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "launch:cancel-renaming", + thread: "thread:cancel-renaming", + message: "Start", + workspace: { type: "worktree", baseRef: "main" }, + }), + ); + yield* Deferred.await(setupEntered); + yield* Deferred.await(branchNameStarted); + assert.isTrue(yield* tracker.cancel(launched.threadId)); + // Let a rename that survived the cancel finish, then check it never ran. + yield* Deferred.succeed(allowBranchName, undefined); + yield* Effect.raceFirst( + Deferred.await(branchNameInterrupted), + Deferred.await(renameAttempted), + ); + assert.isTrue( + yield* Deferred.isDone(branchNameInterrupted), + "the cancelled setup's branch rename kept running", + ); + assert.equal(harness.renameBranch.mock.calls.length, 0); + assert.isNull((yield* threads.getThreadProjection(launched.threadId)).thread.worktreePath); + }).pipe(Effect.provide(harness.layer)); + }), +); + it.effect.each([0, 1])("releases an async setup before its completion with exit %s", (exitCode) => Effect.gen(function* () { const completion = yield* Deferred.make<{ exitCode: number | null; durationMs: number }>();