From 2c1e7c8ce3ebaf23e9211a64643f2fc56ce567a4 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Fri, 18 Sep 2026 16:17:20 -0500 Subject: [PATCH] fix(server): recovering a pruned worktree no longer wedges every thread A turn start holds the worktree's workspace lease, a one-permit semaphore that is not reentrant. When the thread's managed worktree had been pruned, the recovery path re-created it and ran the setup script from inside that lease. Running the script opens a terminal, which takes the same lease, so the turn start waited on itself forever. The reactor worker is serial, so every later turn start on any thread queued behind it and the threads sat on "Working" with no provider process. The setup script now runs in a forked fiber, so it takes the lease once the turn start releases it. A setup failure is logged instead of failing the turn. The recovery test missed this because its setup runner mock never took the lease. It does now, and it reproduced the hang before the fix. Model: Claude Fable 5.1 via Claude Code. --- .../Layers/ProviderCommandReactor.test.ts | 9 +++++- .../Layers/ProviderCommandReactor.ts | 28 +++++++++++++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 8072e88b58f6..44bbd41352bc 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -73,6 +73,7 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { withWorkspaceLease } from "../../workspace/workspaceLease.ts"; import * as Clock from "effect/Clock"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerActivation } from "../../serverActivation.ts"; @@ -432,7 +433,13 @@ describe("ProviderCommandReactor", () => { }, }), ); - const runSetupScript = vi.fn(() => Effect.succeed({ status: "no-script" as const })); + // The real runner opens a terminal, which takes the worktree's workspace lease. + const runSetupScript = vi.fn((input: { readonly worktreePath: string }) => + withWorkspaceLease( + NodePath.resolve(input.worktreePath), + Effect.succeed({ status: "no-script" as const }), + ), + ); const pruneWorktrees = vi.fn((_: { readonly cwd: string }) => Effect.void); const refreshStatus = vi.fn((_: string) => Effect.succeed({ diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index fa95d2f765ac..5da70c802710 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -668,12 +668,28 @@ const make = Effect.gen(function* () { cwd: managedWorktree.projectCwd, ...recoveryPlan, }); - yield* projectSetupScriptRunner.runForThread({ - threadId: input.thread.id, - projectId: input.thread.projectId, - projectCwd: managedWorktree.projectCwd, - worktreePath: managedWorktree.path, - }); + // Opening the setup terminal takes this worktree's workspace lease, which + // the turn start already holds. Fork so it queues behind the turn start + // instead of deadlocking on it. + yield* projectSetupScriptRunner + .runForThread({ + threadId: input.thread.id, + projectId: input.thread.projectId, + projectCwd: managedWorktree.projectCwd, + worktreePath: managedWorktree.path, + }) + .pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logWarning("failed to run setup script for recreated worktree", { + threadId: input.thread.id, + worktreePath: managedWorktree.path, + cause: Cause.pretty(cause), + }), + ), + Effect.forkScoped, + ); yield* Effect.logInfo("recreated missing managed thread worktree", { threadId: input.thread.id, branch: input.thread.branch,