From ca9bb1b717550232cb5a60a01b8840c1ef7beadd Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Wed, 4 Nov 2026 16:14:02 +0530 Subject: [PATCH 1/5] fix(server): run setup scripts when restoring a removed worktree --- ...ProjectSettingsUpgrade.integration.test.ts | 2 + .../ProviderTurnStartService.test.ts | 102 +++++++++++++++- .../ProviderTurnStartService.testkit.ts | 5 +- .../ProviderTurnStartService.ts | 113 +++++++++++++----- .../src/orchestration-v2/runtimeLayer.test.ts | 6 +- .../src/workspace/restoreMissingWorktree.ts | 2 + 6 files changed, 194 insertions(+), 36 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts index b27b6604b1435..166e7394f7736 100644 --- a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts +++ b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts @@ -21,6 +21,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { runMigrations } from "../persistence/Migrations.ts"; import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; import * as ProjectService from "../project/ProjectService.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; @@ -170,6 +171,7 @@ const layerRuntime = (dbPath: string) => { pruneWorktrees: () => Effect.void, }), ), + Layer.provide(Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({})), Layer.provide(layerPlatform), ); }; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 62f095b6fd913..7ada1ec2cfc12 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -43,6 +43,7 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import { layerRealGit } from "./ProviderTurnStartService.testkit.ts"; import * as ProjectService from "../project/ProjectService.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; @@ -133,17 +134,26 @@ function startWithMissingWorktree( ); const startRootRun = vi.fn(() => Effect.void); const pruneWorktrees = vi.fn(() => Effect.void); - const createWorktree = vi.fn(() => Effect.succeed({} as never)); + let worktreeExists = false; + const createWorktree = vi.fn(() => + Effect.sync(() => { + worktreeExists = true; + return {} as never; + }), + ); const listLocalBranchNames = vi.fn(() => Effect.succeed(["feature/restore"])); const layer = ProviderTurnStart.layer.pipe( Layer.provide( Layer.mergeAll( + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ + runForThread: () => Effect.succeed({ status: "no-script" }), + }), Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({}), Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }), IdAllocator.layer, Layer.mock(TurnItemPositionStore.TurnItemPositionStoreV2)({}), Layer.succeed(FileSystem.FileSystem, { - exists: () => Effect.succeed(false), + exists: () => Effect.succeed(worktreeExists), realPath: (target: string) => normalize(target) in realPaths ? Effect.succeed(realPaths[normalize(target)]) @@ -281,6 +291,7 @@ function makeLocalCommandHarness(input: { readonly workspaceRoot?: string; readonly gitWorkflow?: GitWorkflow.GitWorkflowService["Service"]; readonly fileSystem?: FileSystem.FileSystem; + readonly runSetup?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"]; readonly previousNativeSession?: boolean; readonly previousMessages?: ReadonlyArray; readonly logoutFailure?: string; @@ -977,6 +988,9 @@ function makeLocalCommandHarness(input: { }), IdAllocator.layer, Layer.mock(TurnItemPositionStore.TurnItemPositionStoreV2)({}), + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ + runForThread: input.runSetup ?? (() => Effect.succeed({ status: "no-script" })), + }), layerWorktreeLocation(input.worktreesDir), input.fileSystem === undefined ? FileSystem.layerNoop({ exists: () => Effect.succeed(true) }) @@ -1182,11 +1196,28 @@ effectIt.effect( worktreesDir, fileSystem, gitWorkflow, + runSetup: (setup) => + withWorkspaceLease( + setup.worktreePath, + Effect.succeed({ + status: "started", + scriptId: "setup", + scriptName: "Setup", + scriptCommand: "create setup marker", + terminalId: "setup-test", + cwd: setup.worktreePath, + async: false, + completion: fileSystem + .writeFileString(path.join(setup.worktreePath, "setup-ran"), "ready") + .pipe(Effect.as({ exitCode: 0, durationMs: 0 }), Effect.orDie), + }), + ), failReadsAfterRunning: true, }); const open = harness.open.getMockImplementation()!; harness.open.mockImplementation(() => Effect.gen(function* () { + expect(yield* fileSystem.exists(path.join(worktreePath, "setup-ran"))).toBe(true); expect(yield* git(worktreePath, ["branch", "--show-current"])).toBe("feature/restore"); expect(yield* git(worktreePath, ["rev-parse", "HEAD"])).toBe(expected.tip); for (const file of ["thread.txt", "project.txt"]) { @@ -1220,6 +1251,73 @@ effectIt.effect( }).pipe(Effect.scoped, Effect.provide(layerRealGit)), ); +effectIt.effect.each([ + { async: false, exitCode: 0 }, + { async: false, exitCode: 1 }, + { async: false, exitCode: null }, + { async: true, exitCode: 0 }, +])("restored setup respects async=$async and exitCode=$exitCode", (input) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const worktreesDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-restore-setup-" }); + const worktreePath = path.join(worktreesDir, "project", "restore"); + const setupStarted = yield* Deferred.make(); + const setupDone = yield* Deferred.make(); + const completionObserved = yield* Deferred.make(); + const harness = makeLocalCommandHarness({ + text: "Continue", + worktreesDir, + worktreePath, + fileSystem, + failReadsAfterRunning: true, + runSetup: () => + withWorkspaceLease(worktreePath, Deferred.succeed(setupStarted, undefined)).pipe( + Effect.as({ + status: "started", + scriptId: "setup", + scriptName: "Setup", + scriptCommand: "setup", + terminalId: "setup-test", + cwd: worktreePath, + async: input.async, + completion: Deferred.await(setupDone).pipe( + Effect.tap(() => Deferred.succeed(completionObserved, undefined)), + ), + }), + ), + }); + harness.createWorktree.mockImplementation(() => + fileSystem + .makeDirectory(worktreePath, { recursive: true }) + .pipe(Effect.as({} as never), Effect.orDie), + ); + + const startup = yield* harness.start.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(setupStarted); + if (input.async) yield* Fiber.join(startup); + else expect(harness.open).not.toHaveBeenCalled(); + yield* Deferred.succeed(setupDone, { exitCode: input.exitCode, durationMs: 0 }); + yield* Deferred.await(completionObserved); + yield* Fiber.join(startup); + + const failed = !input.async && input.exitCode !== 0; + expect(harness.projection().runs.at(-1)?.status).toBe(failed ? "failed" : "running"); + expect(harness.open).toHaveBeenCalledTimes(failed ? 0 : 1); + if (failed) { + expect(harness.projection().turnItems).toMatchObject([ + { + type: "error", + title: "Couldn't set up restored workspace", + failure: { + message: `Setup script exited with ${input.exitCode ?? "no exit code"} in '${worktreePath}'.`, + }, + }, + ]); + } + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), +); + effectIt.effect( "preserves recreation interrupts and retries, then fails the final attempt before provider start", () => diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts index fb3fb72fd76e3..cab3d8d1598bd 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts @@ -20,7 +20,10 @@ import * as Layer from "effect/Layer"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; import * as ProjectService from "../project/ProjectService.ts"; -export const layer = Layer.merge( +export const layer = Layer.mergeAll( + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ + runForThread: () => Effect.succeed({ status: "no-script" }), + }), Layer.mock(GitWorkflow.GitWorkflowService)({ pruneWorktrees: () => Effect.void, createWorktree: () => Effect.succeed({} as never), diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 8f01d117e1739..aed754feeb198 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -33,6 +33,7 @@ import { import { withTurnStartReservation, withWorkspaceLease } from "../workspace/workspaceLease.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectService from "../project/ProjectService.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; import * as ServerSettings from "../serverSettings.ts"; import { makeRestoreMissingWorktree } from "../workspace/restoreMissingWorktree.ts"; @@ -77,6 +78,15 @@ export class ProviderTurnStartError extends Schema.TaggedError()( + "WorktreeSetupScriptFailedError", + { worktreePath: Schema.String, exitCode: Schema.NullOr(Schema.Number) }, +) { + override get message(): string { + return `Setup script exited with ${this.exitCode ?? "no exit code"} in '${this.worktreePath}'.`; + } +} + const isProviderTurnStartError = Schema.is(ProviderTurnStartError); const isProviderAdapterProtocolError = Schema.is(ProviderAdapter.ProviderAdapterProtocolError); const isProviderAdapterOpenSessionError = Schema.is( @@ -141,6 +151,7 @@ export const layer: Layer.Layer< | ServerSettings.ServerSettingsService | GitWorkflowService.GitWorkflowService | ProjectService.ProjectService + | ProjectSetupScriptRunner.ProjectSetupScriptRunner | ProviderAuthService.ProviderAuthService | ProjectionStore.ProjectionStoreV2 | ProviderSessionManager.ProviderSessionManagerV2 @@ -157,6 +168,7 @@ export const layer: Layer.Layer< const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const projects = yield* ProjectService.ProjectService; + const setupScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const providerAuth = yield* ProviderAuthService.ProviderAuthService; const projectionStore = yield* ProjectionStore.ProjectionStoreV2; const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; @@ -575,6 +587,42 @@ export const layer: Layer.Layer< }); return; } + if (recreateResult.success !== undefined) { + const { project, worktreePath } = recreateResult.success; + // Opening the setup terminal takes its own workspace lease. + return Effect.gen(function* () { + const setupResult = yield* Effect.result( + Effect.gen(function* () { + const setup = yield* setupScripts.runForThread({ + threadId: projection.thread.id, + projectId: project.id, + projectCwd: project.workspaceRoot, + worktreePath, + project, + observeCompletion: {}, + }); + if (setup.status !== "started" || !setup.completion) return; + if (setup.async) { + yield* setup.completion.pipe(Effect.forkDetach); + } else { + const { exitCode } = yield* setup.completion; + if (exitCode !== 0) + return yield* new WorktreeSetupScriptFailedError({ worktreePath, exitCode }); + } + }), + ); + if (setupResult._tag === "Failure") { + yield* settleStartFailure({ + signal: "worktree-setup-failure", + title: "Couldn't set up restored workspace", + error: setupResult.failure, + message: setupResult.failure.message, + }); + return; + } + yield* startWithWorkspaceLease(input); + }); + } const selectInheritedBackgroundItems = ( current: ProjectionStore.ProjectionRuntimeRecoveryState, ): ReturnType => @@ -1759,38 +1807,39 @@ export const layer: Layer.Layer< }); }); - return ProviderTurnStartServiceV2.of({ - start: Effect.fn("ProviderTurnStartService.start")( - function* (input) { - // Storage cleanup removes worktrees under the same lease. Only startup - // holds it; the provider turn runs after it is released. - const { thread } = yield* projectionStore.getThreadRecords(input.threadId, []); - const cwd = - thread.worktreePath ?? - (yield* projects - .getById(thread.projectId, { includeDeleted: true }) - .pipe(Effect.map((project) => Option.getOrUndefined(project)?.workspaceRoot))); - const providerTurn = yield* cwd === undefined - ? start(input) - : withTurnStartReservation( - fileSystem, - path, - path.resolve(cwd), - thread.worktreePath === null - ? start(input) - : withWorkspaceLease(path.resolve(thread.worktreePath), start(input)), - ); - if (providerTurn !== undefined) yield* providerTurn; - }, - (effect, input) => - effect.pipe( - Effect.mapError((cause) => - isProviderTurnStartError(cause) - ? cause - : new ProviderTurnStartError({ runId: input.runId, cause }), - ), + const startWithWorkspaceLease: ProviderTurnStartServiceV2Shape["start"] = Effect.fn( + "ProviderTurnStartService.start", + )( + function* (input) { + // Storage cleanup removes worktrees under the same lease. Only startup + // holds it; the provider turn runs after it is released. + const { thread } = yield* projectionStore.getThreadRecords(input.threadId, []); + const cwd = + thread.worktreePath ?? + (yield* projects + .getById(thread.projectId, { includeDeleted: true }) + .pipe(Effect.map((project) => Option.getOrUndefined(project)?.workspaceRoot))); + const providerTurn = yield* cwd === undefined + ? start(input) + : withTurnStartReservation( + fileSystem, + path, + path.resolve(cwd), + thread.worktreePath === null + ? start(input) + : withWorkspaceLease(path.resolve(thread.worktreePath), start(input)), + ); + if (providerTurn !== undefined) yield* providerTurn; + }, + (effect, input) => + effect.pipe( + Effect.mapError((cause) => + isProviderTurnStartError(cause) + ? cause + : new ProviderTurnStartError({ runId: input.runId, cause }), ), - ), - }); + ), + ); + return ProviderTurnStartServiceV2.of({ start: startWithWorkspaceLease }); }), ); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index e62cd8725de92..602de572455db 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -59,6 +59,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; import * as ProjectService from "../project/ProjectService.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; @@ -100,7 +101,10 @@ import * as ThreadLaunchService from "./ThreadLaunchService.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; -const layerPlatformTest = Layer.merge( +const layerPlatformTest = Layer.mergeAll( + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ + runForThread: () => Effect.succeed({ status: "no-script" }), + }), Layer.mergeAll(NodeServices.layer, NodeCrypto.layer), Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ resolveLink: () => Effect.die("unused title link"), diff --git a/apps/server/src/workspace/restoreMissingWorktree.ts b/apps/server/src/workspace/restoreMissingWorktree.ts index ee423c47d06ab..4395f981f91aa 100644 --- a/apps/server/src/workspace/restoreMissingWorktree.ts +++ b/apps/server/src/workspace/restoreMissingWorktree.ts @@ -134,6 +134,7 @@ export const makeRestoreMissingWorktree = Effect.gen(function* () { : new WorktreeRecreateError({ worktreePath, branch, cause }), ), ); + return { project, worktreePath }; }); }); @@ -165,6 +166,7 @@ export const makePrepareTerminalLaunch = Effect.fn( } const recover = (error: TerminalCwdNotFoundError) => restoreMissingWorktree(thread).pipe( + Effect.asVoid, Effect.catch((cause) => Effect.logWarning("failed to recreate missing worktree", { threadId: thread.id, From 5a23f8b40c5c90d78277748b1c01eca567f3d33a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 4 Nov 2026 14:27:56 +0000 Subject: [PATCH 2/5] fix(server): restart setup terminals before restoring worktrees --- .../src/project/ProjectSetupScriptRunner.test.ts | 13 +++++++++++-- apps/server/src/project/ProjectSetupScriptRunner.ts | 13 +++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 034b168e52c79..2b9c0252b3dd9 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -62,6 +62,10 @@ const makeHarness = Effect.fn(function* (options: { return () => listeners.delete(listener); }), write: (input) => Queue.offer(writes, input.data).pipe(Effect.asVoid), + close: () => + Effect.forEach([...listeners], (listener) => listener({ ...event, type: "closed" }), { + discard: true, + }), closeIdle: () => Effect.void, closeOnSettle: () => Effect.void, }); @@ -153,7 +157,7 @@ describe("ProjectSetupScriptRunner", () => { it.effect("shows a startup prompt and types only once the line editor starts", () => Effect.gen(function* () { - const { result, writes, outputLines, emit } = yield* setup(); + const { result, completion, writes, outputLines, emit } = yield* setup(); expect(result).toMatchObject({ status: "started", terminalId }); yield* emit({ ...event, type: "output", data: "Missing Node.\r\nInstall it? [y/N]: " }); @@ -164,7 +168,12 @@ describe("ProjectSetupScriptRunner", () => { yield* emit({ ...event, type: "activity", hasRunningSubprocess: false, label: "zsh" }); yield* emit({ ...event, type: "output", data: "\r\n% \x1b[?2004h" }); - expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + const command = yield* Queue.take(writes); + expect(command).toMatch(/^ \( vp i\r\);/); + const sentinel = command.match(/__T3_SETUP_DONE___[a-f0-9]+:/)?.[0]; + expect(sentinel).toBeDefined(); + yield* emit({ ...event, type: "output", data: `${sentinel}0\r\n% ` }); + expect(yield* completion).toMatchObject({ exitCode: 0 }); }), ); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index a049bc89b01a1..84c5819e2cc9e 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -447,6 +447,19 @@ export const make = Effect.gen(function* () { trigger === "settle" ? `settle-${script.id}-${(yield* random.uuidV4()).slice(0, 8)}` : `setup-${script.id}`; + // A surviving setup shell may still hold a deleted worktree directory. + if (trigger === "setup") { + yield* terminalManager.close({ threadId: input.threadId, terminalId }).pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "openTerminal", + cause, + }), + ), + ); + } const cwd = input.worktreePath; const env = { ...projectScriptRuntimeEnv({ From 38b08e57c94c9d10aae7ba8d3756cc5e4feff385 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 4 Nov 2026 14:47:00 +0000 Subject: [PATCH 3/5] fix(server): cancel pending worktree setup with its turn --- .../project/ProjectSetupScriptRunner.test.ts | 36 +++++++++++++------ .../src/project/ProjectSetupScriptRunner.ts | 10 +++++- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 2b9c0252b3dd9..729cd0f7771fd 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -207,16 +207,32 @@ describe("ProjectSetupScriptRunner", () => { }), ); - it.effect("drops its terminal listeners when cancelled while the terminal opens", () => - Effect.gen(function* () { - const opening = yield* Deferred.make(); - const { start, listeners } = yield* makeHarness({ opening }); - const fiber = yield* Effect.forkChild(start); - yield* Deferred.await(opening); - expect(listeners.size).toBe(2); - yield* Fiber.interrupt(fiber); - expect(listeners.size).toBe(0); - }), + it.effect.each(["opening", "completion"] as const)( + "stops pending setup when cancelled during %s", + (phase) => + Effect.gen(function* () { + const opening = yield* Deferred.make(); + const { start, listeners, writes, emit } = yield* makeHarness( + phase === "opening" ? { opening } : {}, + ); + const pending = + phase === "opening" + ? start.pipe(Effect.asVoid) + : yield* start.pipe( + Effect.map((result) => + result.status === "started" && result.completion + ? result.completion.pipe(Effect.asVoid) + : Effect.die("setup did not start"), + ), + ); + const fiber = yield* pending.pipe(Effect.forkChild({ startImmediately: true })); + if (phase === "opening") yield* Deferred.await(opening); + expect(listeners.size).toBe(2); + yield* Fiber.interrupt(fiber); + expect(listeners.size).toBe(0); + yield* emit({ ...event, type: "output", data: "\r\n% \x1b[?2004h" }); + expect(yield* Queue.size(writes)).toBe(0); + }), ); it.effect.each([false, true])( diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 84c5819e2cc9e..ec983afc0a116 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -12,6 +12,7 @@ import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -544,7 +545,7 @@ export const make = Effect.gen(function* () { // Return once the terminal exists so callers can show it while a startup prompt waits. // PowerShell and cmd have no line-editor signal. - yield* ( + const commandWriter = yield* ( completionShell === "powershell" || completionShell === null ? Effect.succeed("ready" as const) : readiness.wait @@ -570,6 +571,13 @@ export const make = Effect.gen(function* () { ); const completion = observed?.completion.pipe( + Effect.onInterrupt(() => + Fiber.interrupt(commandWriter).pipe( + Effect.andThen(Effect.sync(() => readiness.unsubscribe())), + Effect.andThen(terminalManager.close({ threadId: input.threadId, terminalId })), + Effect.ignore, + ), + ), Effect.tap(({ exitCode }) => exitCode === 0 ? terminalManager.closeIdle({ threadId: input.threadId, terminalId }) From b6c373a55cb1b2d93b8423d385467431a261b696 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 7 Nov 2026 17:18:24 +0530 Subject: [PATCH 4/5] test(server): cover replacing a surviving setup shell --- .../project/ProjectSetupScriptRunner.test.ts | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 729cd0f7771fd..03839337996d3 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -41,9 +41,12 @@ const makeHarness = Effect.fn(function* (options: { readonly command?: string; readonly history?: string; readonly opening?: Deferred.Deferred; + readonly previousSession?: TerminalSessionSnapshot; readonly shell?: string | null; }) { const listeners = new Set<(event: TerminalEvent) => Effect.Effect>(); + const openedSessions: TerminalSessionSnapshot[] = []; + let session = options.previousSession; const writes = yield* Queue.unbounded(); const outputLines = yield* Queue.unbounded(); const terminals = Layer.mock(TerminalManager.TerminalManager)({ @@ -51,7 +54,13 @@ const makeHarness = Effect.fn(function* (options: { (options.opening ? Deferred.succeed(options.opening, undefined).pipe(Effect.andThen(Effect.never)) : Effect.void - ).pipe(Effect.as(openSnapshot(options.history ?? ""))), + ).pipe( + Effect.map(() => { + session ??= openSnapshot(options.history ?? ""); + openedSessions.push(session); + return session; + }), + ), getShell: () => options.shell === null ? Effect.fail(new TerminalNotRunningError({ threadId, terminalId })) @@ -63,9 +72,15 @@ const makeHarness = Effect.fn(function* (options: { }), write: (input) => Queue.offer(writes, input.data).pipe(Effect.asVoid), close: () => - Effect.forEach([...listeners], (listener) => listener({ ...event, type: "closed" }), { - discard: true, - }), + Effect.sync(() => { + session = undefined; + }).pipe( + Effect.andThen( + Effect.forEach([...listeners], (listener) => listener({ ...event, type: "closed" }), { + discard: true, + }), + ), + ), closeIdle: () => Effect.void, closeOnSettle: () => Effect.void, }); @@ -102,13 +117,14 @@ const makeHarness = Effect.fn(function* (options: { }); const emit = (terminalEvent: TerminalEvent) => Effect.forEach([...listeners], (listener) => listener(terminalEvent), { discard: true }); - return { start, listeners, writes, outputLines, emit }; + return { start, listeners, openedSessions, writes, outputLines, emit }; }); const setup = Effect.fn(function* ( options: { readonly command?: string; readonly history?: string; + readonly previousSession?: TerminalSessionSnapshot; readonly shell?: string | null; } = {}, ) { @@ -155,10 +171,13 @@ describe("ProjectSetupScriptRunner", () => { }), ); - it.effect("shows a startup prompt and types only once the line editor starts", () => + it.effect("replaces a surviving setup shell and types only once the line editor starts", () => Effect.gen(function* () { - const { result, completion, writes, outputLines, emit } = yield* setup(); + const { result, completion, openedSessions, writes, outputLines, emit } = yield* setup({ + previousSession: openSnapshot("previous setup in the deleted checkout"), + }); expect(result).toMatchObject({ status: "started", terminalId }); + expect(openedSessions).toEqual([openSnapshot("")]); yield* emit({ ...event, type: "output", data: "Missing Node.\r\nInstall it? [y/N]: " }); yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" }); From ac079a270d0931519290b2222b6f8f32d9f4d0a7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:24:51 +0000 Subject: [PATCH 5/5] test(server): trim overlapping restored-worktree setup cases AI-assisted; verified with the tests and tools listed above. --- .../ProviderTurnStartService.test.ts | 20 +----- .../ProviderTurnStartService.ts | 64 +++++++++---------- 2 files changed, 33 insertions(+), 51 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 7ada1ec2cfc12..fb0fd21b83bec 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1196,28 +1196,11 @@ effectIt.effect( worktreesDir, fileSystem, gitWorkflow, - runSetup: (setup) => - withWorkspaceLease( - setup.worktreePath, - Effect.succeed({ - status: "started", - scriptId: "setup", - scriptName: "Setup", - scriptCommand: "create setup marker", - terminalId: "setup-test", - cwd: setup.worktreePath, - async: false, - completion: fileSystem - .writeFileString(path.join(setup.worktreePath, "setup-ran"), "ready") - .pipe(Effect.as({ exitCode: 0, durationMs: 0 }), Effect.orDie), - }), - ), failReadsAfterRunning: true, }); const open = harness.open.getMockImplementation()!; harness.open.mockImplementation(() => Effect.gen(function* () { - expect(yield* fileSystem.exists(path.join(worktreePath, "setup-ran"))).toBe(true); expect(yield* git(worktreePath, ["branch", "--show-current"])).toBe("feature/restore"); expect(yield* git(worktreePath, ["rev-parse", "HEAD"])).toBe(expected.tip); for (const file of ["thread.txt", "project.txt"]) { @@ -1254,7 +1237,6 @@ effectIt.effect( effectIt.effect.each([ { async: false, exitCode: 0 }, { async: false, exitCode: 1 }, - { async: false, exitCode: null }, { async: true, exitCode: 0 }, ])("restored setup respects async=$async and exitCode=$exitCode", (input) => Effect.gen(function* () { @@ -1310,7 +1292,7 @@ effectIt.effect.each([ type: "error", title: "Couldn't set up restored workspace", failure: { - message: `Setup script exited with ${input.exitCode ?? "no exit code"} in '${worktreePath}'.`, + message: `Setup script exited with ${input.exitCode} in '${worktreePath}'.`, }, }, ]); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index aed754feeb198..042614cd0e0f8 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -589,39 +589,39 @@ export const layer: Layer.Layer< } if (recreateResult.success !== undefined) { const { project, worktreePath } = recreateResult.success; - // Opening the setup terminal takes its own workspace lease. - return Effect.gen(function* () { - const setupResult = yield* Effect.result( - Effect.gen(function* () { - const setup = yield* setupScripts.runForThread({ - threadId: projection.thread.id, - projectId: project.id, - projectCwd: project.workspaceRoot, - worktreePath, - project, - observeCompletion: {}, + // Opening the setup terminal takes its own workspace lease. + return Effect.gen(function* () { + const setupResult = yield* Effect.result( + Effect.gen(function* () { + const setup = yield* setupScripts.runForThread({ + threadId: projection.thread.id, + projectId: project.id, + projectCwd: project.workspaceRoot, + worktreePath, + project, + observeCompletion: {}, + }); + if (setup.status !== "started" || !setup.completion) return; + if (setup.async) { + yield* setup.completion.pipe(Effect.forkDetach); + } else { + const { exitCode } = yield* setup.completion; + if (exitCode !== 0) + return yield* new WorktreeSetupScriptFailedError({ worktreePath, exitCode }); + } + }), + ); + if (setupResult._tag === "Failure") { + yield* settleStartFailure({ + signal: "worktree-setup-failure", + title: "Couldn't set up restored workspace", + error: setupResult.failure, + message: setupResult.failure.message, }); - if (setup.status !== "started" || !setup.completion) return; - if (setup.async) { - yield* setup.completion.pipe(Effect.forkDetach); - } else { - const { exitCode } = yield* setup.completion; - if (exitCode !== 0) - return yield* new WorktreeSetupScriptFailedError({ worktreePath, exitCode }); - } - }), - ); - if (setupResult._tag === "Failure") { - yield* settleStartFailure({ - signal: "worktree-setup-failure", - title: "Couldn't set up restored workspace", - error: setupResult.failure, - message: setupResult.failure.message, - }); - return; - } - yield* startWithWorkspaceLease(input); - }); + return; + } + yield* startWithWorkspaceLease(input); + }); } const selectInheritedBackgroundItems = ( current: ProjectionStore.ProjectionRuntimeRecoveryState,