From b3054ad7d25541c5e2ccbb3258e65f4bd50a46c1 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:05:08 +0100 Subject: [PATCH 1/3] fix(server): terminals recreate a worktree removed by storage cleanup Co-Authored-By: Claude Sonnet 5.5 --- .../ProviderTurnStartService.ts | 143 ++------------ apps/server/src/terminal/Manager.test.ts | 90 ++++++++- apps/server/src/terminal/Manager.ts | 14 +- .../src/workspace/restoreMissingWorktree.ts | 174 ++++++++++++++++++ apps/server/src/ws.test.ts | 138 ++++++++++++++ apps/server/src/ws.ts | 16 +- 6 files changed, 440 insertions(+), 135 deletions(-) create mode 100644 apps/server/src/workspace/restoreMissingWorktree.ts diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 54b16d7aa2aef..f4c04ea3172b7 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -31,7 +31,7 @@ import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; import * as ServerSettings from "../serverSettings.ts"; -import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts"; +import { makeRestoreMissingWorktree } from "../workspace/restoreMissingWorktree.ts"; import * as EventSink from "./EventSink.ts"; import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; @@ -71,28 +71,6 @@ export class ProviderTurnStartError extends Schema.TaggedError()( - "WorktreeRecreateError", - { - worktreePath: Schema.String, - branch: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Could not recreate the worktree for branch '${this.branch}' at ${this.worktreePath}.`; - } -} - -class WorktreeRecreateNoBaseError extends Schema.TaggedError()( - "WorktreeRecreateNoBaseError", - { worktreePath: Schema.String, branch: Schema.String }, -) { - override get message(): string { - return `Could not recreate branch '${this.branch}' at ${this.worktreePath}: the project workspace has no commit to start it from.`; - } -} - const isProviderTurnStartError = Schema.is(ProviderTurnStartError); const isProviderAdapterOpenSessionError = Schema.is( ProviderAdapter.ProviderAdapterOpenSessionError, @@ -152,79 +130,13 @@ export const layer: Layer.Layer< const turnAnalytics = yield* ProviderUsageAnalytics.ProviderTurnAnalytics; const contextHandoffService = yield* ContextHandoffService.ContextHandoffServiceV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; - const projects = yield* ProjectService.ProjectService; const providerAuth = yield* ProviderAuthService.ProviderAuthService; const projectionStore = yield* ProjectionStore.ProjectionStoreV2; const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; const runExecution = yield* RunExecutionService.RunExecutionServiceV2; const runtimePolicy = yield* RuntimePolicy.RuntimePolicyV2; - const isManagedWorktreePath = yield* makeIsManagedWorktreePath; - - const resolveRecreateBase = Effect.fn("ProviderTurnStartService.resolveRecreateBase")( - function* (cwd: string) { - // Recovery can follow an external checkout, before the status cache expires. - yield* gitWorkflow.invalidateLocalStatus(cwd); - const { refName: currentBranch } = yield* gitWorkflow.localStatus({ cwd }); - if (currentBranch !== null) { - const refName = `refs/heads/${currentBranch}`; - return { refName, baseRefName: refName }; - } - if (yield* gitWorkflow.hasCommit({ cwd, refName: "refs/remotes/origin/HEAD" })) { - const { commitSha } = yield* gitWorkflow.resolveRemoteTrackingCommit({ - cwd, - refName: "HEAD", - fallbackRemoteName: "origin", - }); - // Start from the commit so the thread branch does not track the default branch. - return { refName: commitSha, baseRefName: "refs/remotes/origin/HEAD" }; - } - if (yield* gitWorkflow.hasCommit({ cwd, refName: "HEAD" })) { - return { refName: "HEAD", baseRefName: undefined }; - } - return null; - }, - ); - - const recreateMissingWorktree = Effect.fn("ProviderTurnStartService.recreateMissingWorktree")( - function* (input: { - readonly cwd: string; - readonly worktreePath: string; - readonly branch: string; - }) { - const { cwd, worktreePath, branch } = input; - yield* gitWorkflow.pruneWorktrees({ cwd }); - const localBranchNames = yield* gitWorkflow.listLocalBranchNames(cwd); - if (localBranchNames.includes(branch)) { - yield* gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath }); - return; - } - // Push keeps the tracking ref current, so recovery reads it without fetching. - const pushedRef = `refs/remotes/origin/${branch}`; - if (yield* gitWorkflow.hasCommit({ cwd, refName: pushedRef })) { - yield* gitWorkflow.createWorktree({ - cwd, - refName: pushedRef, - newRefName: branch, - path: worktreePath, - }); - return; - } - const base = yield* resolveRecreateBase(cwd); - if (base === null) { - return yield* new WorktreeRecreateNoBaseError({ worktreePath, branch }); - } - yield* gitWorkflow.createWorktree({ - cwd, - refName: base.refName, - newRefName: branch, - ...(base.baseRefName === undefined ? {} : { baseRefName: base.baseRefName }), - path: worktreePath, - }); - }, - ); + const restoreMissingWorktree = yield* makeRestoreMissingWorktree; // Build draining callbacks outside startup scope so they cannot retain full thread history. const makeRunControls = (input: { @@ -633,47 +545,16 @@ export const layer: Layer.Layer< return; } } - const { worktreePath, branch } = projection.thread; - if (worktreePath !== null && branch !== null) { - const exists = yield* fileSystem - .exists(worktreePath) - .pipe(Effect.orElseSucceed(() => true)); - if (!exists && (yield* isManagedWorktreePath(worktreePath))) { - const project = yield* projects.getById(projection.thread.projectId).pipe( - Effect.map(Option.getOrUndefined), - Effect.orElseSucceed(() => undefined), - ); - if (project !== undefined) { - yield* Effect.logWarning("provider turn start recreating missing worktree", { - threadId: projection.thread.id, - worktreePath, - branch, - }); - const recreateResult = yield* Effect.result( - recreateMissingWorktree({ - cwd: project.workspaceRoot, - worktreePath, - branch, - }).pipe( - Effect.mapError((cause) => - cause._tag === "WorktreeRecreateNoBaseError" - ? cause - : new WorktreeRecreateError({ worktreePath, branch, cause }), - ), - ), - ); - if (recreateResult._tag === "Failure") { - if (input.willRetry === true) return yield* recreateResult.failure; - yield* settleStartFailure({ - signal: "worktree-recreate-failure", - title: "Couldn't recreate thread workspace", - error: recreateResult.failure, - message: recreateResult.failure.message, - }); - return; - } - } - } + const recreateResult = yield* Effect.result(restoreMissingWorktree(projection.thread)); + if (recreateResult._tag === "Failure") { + if (input.willRetry === true) return yield* recreateResult.failure; + yield* settleStartFailure({ + signal: "worktree-recreate-failure", + title: "Couldn't recreate thread workspace", + error: recreateResult.failure, + message: recreateResult.failure.message, + }); + return; } const selectInheritedBackgroundItems = ( current: ProjectionStore.ProjectionRuntimeRecoveryState, diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 7accdb958372f..55fddc328c0a3 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -3,12 +3,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { DEFAULT_TERMINAL_ID, + ProjectId, + ThreadId, + TerminalCwdNotFoundError, type TerminalAttachStreamEvent, type TerminalEvent, type TerminalOpenInput, ProviderDriverKind, ProviderInstanceId, - ProjectId, TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -33,12 +35,16 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as TestClock from "effect/testing/TestClock"; import { expect, vi } from "vite-plus/test"; +import * as ServerConfig from "../config.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as TerminalManager from "./Manager.ts"; +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import { makeWithWorktreeRecovery } from "../workspace/restoreMissingWorktree.ts"; +import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; import { terminalOutputStream } from "./OutputProtocol.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; @@ -414,6 +420,88 @@ it.layer( ), { excludeTestServices: true }, )("TerminalManager", (it) => { + it.effect.each([ + { method: "open", location: "managed" }, + { method: "attach", location: "managed" }, + { method: "open", location: "subdirectory" }, + { method: "attach", location: "subdirectory" }, + { method: "open", location: "unmanaged" }, + { method: "open", location: "no-worktree" }, + ] as const)( + "recovers $method with a $location cwd without a cleanup gap", + ({ method, location }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { manager, ptyAdapter, baseDir } = yield* createManager(); + const worktreesDir = path.join(baseDir, "worktrees"); + const worktreePath = + location === "unmanaged" + ? path.join(baseDir, "missing") + : path.join(worktreesDir, "project", "feature"); + const cwd = location === "subdirectory" ? path.join(worktreePath, "src") : worktreePath; + const cleanupQueued = yield* Deferred.make>(); + let creations = 0; + const withRecovery = yield* makeWithWorktreeRecovery(() => + Effect.succeed({ + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + worktreePath: location === "no-worktree" ? null : worktreePath, + branch: "feature", + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ServerConfig.ServerConfig)({ + worktreesDir, + } as ServerConfig.ServerConfig["Service"]), + ServerSettings.layerTest(), + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeedSome({ workspaceRoot: baseDir } as never), + }), + Layer.mock(GitWorkflowService.GitWorkflowService)({ + pruneWorktrees: () => Effect.void, + listLocalBranchNames: () => Effect.succeed(["feature"]), + createWorktree: () => + Effect.gen(function* () { + creations++; + yield* fs.makeDirectory(cwd, { recursive: true }).pipe(Effect.orDie); + const cleanup = yield* withWorkspaceLease( + worktreePath, + Effect.sync(() => { + assert.equal(ptyAdapter.spawnInputs.length, 1); + }), + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.succeed(cleanupQueued, cleanup); + return {} as never; + }), + }), + ), + ), + ); + const input = openInput({ + cwd, + worktreePath: location === "subdirectory" ? null : worktreePath, + }); + const launch = (input: TerminalOpenInput) => + method === "attach" + ? manager.attachStream(input, () => Effect.void).pipe(Effect.asVoid) + : manager.open(input).pipe(Effect.asVoid); + if (location === "unmanaged" || location === "no-worktree") { + const error = yield* withRecovery(input, launch).pipe(Effect.flip); + assert.deepEqual(error, new TerminalCwdNotFoundError({ cwd })); + assert.equal(creations, 0); + assert.equal(ptyAdapter.spawnInputs.length, 0); + return; + } + yield* withRecovery(input, launch); + yield* Fiber.join(yield* Deferred.await(cleanupQueued)); + assert.equal(creations, 1); + assert.equal(ptyAdapter.spawnInputs.length, 1); + assert.isTrue(yield* fs.exists(cwd)); + }), + ); + it.effect("spawns lazily and reuses running terminal per thread", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 6a661db4e09d6..1be8d9499c712 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -165,6 +165,10 @@ class TerminalProcessSignalError extends Schema.TaggedError Effect.Effect +>("t3/terminal/TerminalCwdRecovery", { defaultValue: () => (error) => Effect.fail(error) }) {} + export class TerminalManager extends Context.Service< TerminalManager, { @@ -2117,7 +2121,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) { - const stats = yield* fileSystem.stat(cwd).pipe( + const stat = fileSystem.stat(cwd).pipe( Effect.catchTags({ PlatformError: (cause) => cause.reason._tag === "NotFound" @@ -2125,6 +2129,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func : new TerminalCwdStatError({ cwd, cause }), }), ); + const stats = yield* stat.pipe( + Effect.catchTags({ + TerminalCwdNotFoundError: (error) => + Effect.flatMap(TerminalCwdRecovery, (recover) => + recover(error).pipe(Effect.andThen(stat)), + ), + }), + ); if (stats.type !== "Directory") { return yield* new TerminalCwdNotDirectoryError({ cwd }); } diff --git a/apps/server/src/workspace/restoreMissingWorktree.ts b/apps/server/src/workspace/restoreMissingWorktree.ts new file mode 100644 index 0000000000000..4eb4580394de4 --- /dev/null +++ b/apps/server/src/workspace/restoreMissingWorktree.ts @@ -0,0 +1,174 @@ +import { ThreadId, type ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts"; +import { TerminalCwdRecovery } from "../terminal/Manager.ts"; + +class WorktreeRecreateError extends Schema.TaggedError()( + "WorktreeRecreateError", + { + worktreePath: Schema.String, + branch: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not recreate the worktree for branch '${this.branch}' at ${this.worktreePath}.`; + } +} + +class WorktreeRecreateNoBaseError extends Schema.TaggedError()( + "WorktreeRecreateNoBaseError", + { worktreePath: Schema.String, branch: Schema.String }, +) { + override get message(): string { + return `Could not recreate branch '${this.branch}' at ${this.worktreePath}: the project workspace has no commit to start it from.`; + } +} + +interface WorktreeThread { + readonly id: ThreadId; + readonly projectId: ProjectId; + readonly worktreePath: string | null; + readonly branch: string | null; +} + +export const makeRestoreMissingWorktree = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; + const projects = yield* ProjectService.ProjectService; + const isManagedWorktreePath = yield* makeIsManagedWorktreePath; + + const resolveRecreateBase = Effect.fn("restoreMissingWorktree.resolveRecreateBase")(function* ( + cwd: string, + ) { + // Recovery can follow an external checkout, before the status cache expires. + yield* gitWorkflow.invalidateLocalStatus(cwd); + const { refName: currentBranch } = yield* gitWorkflow.localStatus({ cwd }); + if (currentBranch !== null) { + const refName = `refs/heads/${currentBranch}`; + return { refName, baseRefName: refName }; + } + if (yield* gitWorkflow.hasCommit({ cwd, refName: "refs/remotes/origin/HEAD" })) { + const { commitSha } = yield* gitWorkflow.resolveRemoteTrackingCommit({ + cwd, + refName: "HEAD", + fallbackRemoteName: "origin", + }); + // Start from the commit so the thread branch does not track the default branch. + return { refName: commitSha, baseRefName: "refs/remotes/origin/HEAD" }; + } + if (yield* gitWorkflow.hasCommit({ cwd, refName: "HEAD" })) { + return { refName: "HEAD", baseRefName: undefined }; + } + return null; + }); + + const recreateMissingWorktree = Effect.fn("restoreMissingWorktree.recreate")(function* (input: { + readonly cwd: string; + readonly worktreePath: string; + readonly branch: string; + }) { + const { cwd, worktreePath, branch } = input; + yield* gitWorkflow.pruneWorktrees({ cwd }); + const localBranchNames = yield* gitWorkflow.listLocalBranchNames(cwd); + if (localBranchNames.includes(branch)) { + yield* gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath }); + return; + } + // Push keeps the tracking ref current, so recovery reads it without fetching. + const pushedRef = `refs/remotes/origin/${branch}`; + if (yield* gitWorkflow.hasCommit({ cwd, refName: pushedRef })) { + yield* gitWorkflow.createWorktree({ + cwd, + refName: pushedRef, + newRefName: branch, + path: worktreePath, + }); + return; + } + const base = yield* resolveRecreateBase(cwd); + if (base === null) { + return yield* new WorktreeRecreateNoBaseError({ worktreePath, branch }); + } + yield* gitWorkflow.createWorktree({ + cwd, + refName: base.refName, + newRefName: branch, + ...(base.baseRefName === undefined ? {} : { baseRefName: base.baseRefName }), + path: worktreePath, + }); + }); + + return Effect.fn("restoreMissingWorktree")(function* (thread: WorktreeThread) { + const { worktreePath, branch } = thread; + if (worktreePath === null || branch === null) return; + const exists = yield* fileSystem.exists(worktreePath).pipe(Effect.orElseSucceed(() => true)); + if (exists || !(yield* isManagedWorktreePath(worktreePath))) return; + const project = yield* projects.getById(thread.projectId).pipe( + Effect.map(Option.getOrUndefined), + Effect.orElseSucceed(() => undefined), + ); + if (project === undefined) return; + yield* Effect.logWarning("recreating missing worktree", { + threadId: thread.id, + worktreePath, + branch, + }); + yield* recreateMissingWorktree({ cwd: project.workspaceRoot, worktreePath, branch }).pipe( + Effect.mapError((cause) => + cause._tag === "WorktreeRecreateNoBaseError" + ? cause + : new WorktreeRecreateError({ worktreePath, branch, cause }), + ), + ); + }); +}); + +// Worktree setup uses terminals, so recovery must stay outside TerminalManager. +export const makeWithWorktreeRecovery = Effect.fn("restoreMissingWorktree.makeWithRecovery")( + function* ( + getThreadShell: (threadId: ThreadId) => Effect.Effect, + ) { + const path = yield* Path.Path; + const restoreMissingWorktree = yield* makeRestoreMissingWorktree; + return Effect.fn("restoreMissingWorktree.retryLaunch")(function* < + Input extends { + readonly threadId: string; + readonly cwd?: string | undefined; + readonly worktreePath?: string | null | undefined; + }, + A, + E, + R, + >(input: Input, launch: (input: Input) => Effect.Effect) { + const thread = yield* getThreadShell(ThreadId.make(input.threadId)).pipe( + Effect.orElseSucceed(() => null), + ); + if (thread?.worktreePath == null) return yield* launch(input); + const relative = path.relative(thread.worktreePath, input.cwd ?? thread.worktreePath); + if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) { + return yield* launch(input); + } + return yield* launch({ ...input, worktreePath: thread.worktreePath }).pipe( + Effect.provideService(TerminalCwdRecovery, (error) => + restoreMissingWorktree(thread).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to recreate missing worktree", { + threadId: thread.id, + worktreePath: thread.worktreePath, + cause, + }).pipe(Effect.andThen(Effect.fail(error))), + ), + ), + ), + ); + }); + }, +); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index ca53e5e394304..7ae045754abd3 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -4,6 +4,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { AuthSessionId, + AuthTerminalOperateScope, + TerminalCwdNotFoundError, + WsRpcGroup, + type TerminalSessionSnapshot, EnvironmentId, ProjectId, ThreadId, @@ -26,6 +30,8 @@ import * as Exit from "effect/Exit"; import * as Cause from "effect/Cause"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; @@ -35,6 +41,7 @@ import { RpcGroup, RpcMessage, RpcServer } from "effect/rpc"; import * as SqlClient from "effect/sql/SqlClient"; import * as Statement from "effect/sql/Statement"; +import * as RpcAuthorization from "./auth/RpcAuthorization.ts"; import * as AssetSigner from "./assets/AssetSigner.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as ServerBrowser from "./preview/ServerBrowser.ts"; @@ -1338,3 +1345,134 @@ it.effect("thread resume at its own high-water below the pruned boundary skips t assert.isTrue(Exit.isSuccess(exit)); }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), ); + +it.effect.each([WS_METHODS.terminalOpen, WS_METHODS.terminalAttach])( + "restores a cleaned-up worktree at the %s RPC boundary", + (method) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { worktreesDir } = yield* ServerConfigModule.ServerConfig; + const cwd = path.join(worktreesDir, "project", "feature"); + const threadId = ThreadId.make("terminal-recovery"); + const input = { threadId, terminalId: "default", cwd }; + const snapshot: TerminalSessionSnapshot = { + ...input, + worktreePath: cwd, + status: "running", + pid: 1, + history: "", + exitCode: null, + exitSignal: null, + label: "bash", + updatedAt: "2026-10-26T00:00:00Z", + }; + let attempts = 0; + const created: string[] = []; + const check = Effect.gen(function* () { + attempts++; + if (!(yield* fs.exists(cwd).pipe(Effect.orDie))) + return yield* new TerminalCwdNotFoundError({ cwd }); + return snapshot; + }); + const launch = check.pipe( + Effect.catchTags({ + TerminalCwdNotFoundError: (error) => + Effect.flatMap(TerminalManager.TerminalCwdRecovery, (recover) => + recover(error).pipe(Effect.andThen(check)), + ), + }), + ); + const services = Layer.mergeAll( + layerUnusedConfigRpcServices, + Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/unused" }), + Layer.mock(ExternalLauncher.ExternalLauncher)({}), + Layer.mock(UsageLimitSources.UsageLimitSources)({}), + Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({}), + Layer.mock(DirectEndpoints.DirectEndpoints)({}), + Layer.mock(ProviderRegistry.ProviderRegistry)({}), + Layer.mock(ServerEnvironment.ServerEnvironment)({}), + Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), + Layer.mock(TerminalManager.TerminalManager)({ + open: () => launch, + attachOutputStream: () => + Stream.fromEffect( + launch.pipe(Effect.map((snapshot) => ({ type: "snapshot" as const, snapshot }))), + ), + }), + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => + Effect.succeed({ + id: threadId, + projectId: ProjectId.make("project"), + worktreePath: cwd, + branch: "feature", + } as OrchestrationV2ThreadShell), + }), + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeedSome({ workspaceRoot: "/repo" } as never), + }), + Layer.mock(GitWorkflowService.GitWorkflowService)({ + pruneWorktrees: () => Effect.void, + listLocalBranchNames: () => Effect.succeed(["feature"]), + createWorktree: () => + Effect.gen(function* () { + created.push(cwd); + yield* fs.makeDirectory(cwd, { recursive: true }); + return {} as never; + }).pipe(Effect.orDie), + }), + ); + const responses = yield* Queue.unbounded(); + const server = yield* RpcServer.makeNoSerialization(WsRpcGroup, { + ...WS_RPC_SERVER_OPTIONS, + onFromServer: (response) => + response._tag === "Exit" + ? Queue.offer( + responses, + response.exit._tag === "Success" ? "success" : JSON.stringify(response.exit), + ) + : response._tag === "Chunk" + ? Queue.offer(responses, "success") + : Queue.offer(responses, JSON.stringify(response)), + }).pipe( + Effect.provide([ + RpcAuthorization.layer([AuthTerminalOperateScope]), + layerWsRpc( + { + sessionId: AuthSessionId.make("terminal-test"), + subject: "terminal-test", + method: "browser-session-cookie", + scopes: [AuthTerminalOperateScope], + }, + {}, + yield* ServerBrowser.ServerBrowser, + () => Effect.void, + { secretReferences: true }, + ).pipe(Layer.provide(services)), + ]), + ); + const requestId = RpcMessage.RequestId("terminal"); + yield* server.write(0, { + _tag: "Request", + id: requestId, + tag: method, + payload: input, + headers: Headers.empty, + }); + assert.equal(yield* Queue.take(responses), "success"); + assert.equal(attempts, 2); + assert.deepEqual(created, [cwd]); + yield* server.write(0, { _tag: "Interrupt", requestId, interruptors: [] }); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.fresh(layerConfigSources), + Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}), + Layer.mock(ServerBrowser.ServerBrowser)({}), + ), + ), + Effect.scoped, + ), +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9816f6c600823..9b8487e25ed11 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -188,6 +188,7 @@ import { invalidateSettingsSecrets } from "./sourceControl/sourceControlHostSecr import * as DatabaseStorage from "./persistence/DatabaseStorage.ts"; import * as StorageCleanup from "./storageCleanup.ts"; import * as TerminalManager from "./terminal/Manager.ts"; +import { makeWithWorktreeRecovery } from "./workspace/restoreMissingWorktree.ts"; import { terminalOutputStream, withTerminalOutputWindow } from "./terminal/OutputProtocol.ts"; import * as ServerBrowser from "./preview/ServerBrowser.ts"; import * as DeviceService from "./device/DeviceService.ts"; @@ -1454,6 +1455,7 @@ export const layerWsRpc = ( const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const terminalManager = yield* TerminalManager.TerminalManager; + const withWorktreeRecovery = yield* makeWithWorktreeRecovery(threadManagement.getThreadShell); const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; @@ -3147,9 +3149,19 @@ export const layerWsRpc = ( [WS_METHODS.reviewGetBranchCommits]: (input) => review.getBranchCommits(input), [WS_METHODS.reviewGetDiffPreview]: (input) => review.getDiffPreview(input), [WS_METHODS.reviewGetDiffFileContents]: (input) => review.getDiffFileContents(input), - [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input), + [WS_METHODS.terminalOpen]: (input) => withWorktreeRecovery(input, terminalManager.open), [WS_METHODS.terminalAttach]: (input, metadata) => - terminalManager.attachOutputStream(input, terminalViewerId(metadata)), + Stream.unwrap( + withWorktreeRecovery(input, (input) => + Effect.context().pipe( + Effect.map((context) => + terminalManager + .attachOutputStream(input, terminalViewerId(metadata)) + .pipe(Stream.provideContext(context)), + ), + ), + ), + ), [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input), [WS_METHODS.terminalResize]: (input, metadata) => terminalManager.resize(input, terminalViewerId(metadata)), From 96bf878a02f1659672cef89bd9191e7eb229a77f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 22:07:41 +0000 Subject: [PATCH 2/3] refactor(server): resolve terminal worktree recovery before launch Replace the generic launch wrapper with a helper that returns the input and recovery callback, so ws.ts provides it directly to open and attach without re-capturing context. Reuse isWithinRoot for the containment check and drop the RPC-boundary test that duplicated the TerminalManager cases. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/terminal/Manager.test.ts | 21 +-- .../src/workspace/restoreMissingWorktree.ts | 76 +++++----- apps/server/src/ws.test.ts | 134 ------------------ apps/server/src/ws.ts | 27 ++-- 4 files changed, 67 insertions(+), 191 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 3329de9e42311..078de1f9f1764 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -45,7 +45,7 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as TerminalManager from "./Manager.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; -import { makeWithWorktreeRecovery } from "../workspace/restoreMissingWorktree.ts"; +import { makePrepareTerminalLaunch } from "../workspace/restoreMissingWorktree.ts"; import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; import { terminalOutputStream } from "./OutputProtocol.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; @@ -427,7 +427,6 @@ it.layer( { method: "open", location: "managed" }, { method: "attach", location: "managed" }, { method: "open", location: "subdirectory" }, - { method: "attach", location: "subdirectory" }, { method: "open", location: "unmanaged" }, { method: "open", location: "no-worktree" }, ] as const)( @@ -445,7 +444,7 @@ it.layer( const cwd = location === "subdirectory" ? path.join(worktreePath, "src") : worktreePath; const cleanupQueued = yield* Deferred.make>(); let creations = 0; - const withRecovery = yield* makeWithWorktreeRecovery(() => + const prepareLaunch = yield* makePrepareTerminalLaunch(() => Effect.succeed({ id: ThreadId.make("thread-1"), projectId: ProjectId.make("project-1"), @@ -486,18 +485,22 @@ it.layer( cwd, worktreePath: location === "subdirectory" ? null : worktreePath, }); - const launch = (input: TerminalOpenInput) => - method === "attach" - ? manager.attachStream(input, () => Effect.void).pipe(Effect.asVoid) - : manager.open(input).pipe(Effect.asVoid); + const launch = prepareLaunch(input).pipe( + Effect.flatMap(({ input, recover }) => + (method === "attach" + ? manager.attachStream(input, () => Effect.void) + : manager.open(input) + ).pipe(Effect.provideService(TerminalManager.TerminalCwdRecovery, recover)), + ), + ); if (location === "unmanaged" || location === "no-worktree") { - const error = yield* withRecovery(input, launch).pipe(Effect.flip); + const error = yield* launch.pipe(Effect.flip); assert.deepEqual(error, new TerminalCwdNotFoundError({ cwd })); assert.equal(creations, 0); assert.equal(ptyAdapter.spawnInputs.length, 0); return; } - yield* withRecovery(input, launch); + yield* launch; yield* Fiber.join(yield* Deferred.await(cleanupQueued)); assert.equal(creations, 1); assert.equal(ptyAdapter.spawnInputs.length, 1); diff --git a/apps/server/src/workspace/restoreMissingWorktree.ts b/apps/server/src/workspace/restoreMissingWorktree.ts index 4eb4580394de4..b3d105439bd26 100644 --- a/apps/server/src/workspace/restoreMissingWorktree.ts +++ b/apps/server/src/workspace/restoreMissingWorktree.ts @@ -8,6 +8,7 @@ import * as Schema from "effect/Schema"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts"; +import { isWithinRoot } from "./KnownWorkspaces.ts"; import { TerminalCwdRecovery } from "../terminal/Manager.ts"; class WorktreeRecreateError extends Schema.TaggedError()( @@ -132,43 +133,42 @@ export const makeRestoreMissingWorktree = Effect.gen(function* () { }); // Worktree setup uses terminals, so recovery must stay outside TerminalManager. -export const makeWithWorktreeRecovery = Effect.fn("restoreMissingWorktree.makeWithRecovery")( - function* ( - getThreadShell: (threadId: ThreadId) => Effect.Effect, - ) { - const path = yield* Path.Path; - const restoreMissingWorktree = yield* makeRestoreMissingWorktree; - return Effect.fn("restoreMissingWorktree.retryLaunch")(function* < - Input extends { - readonly threadId: string; - readonly cwd?: string | undefined; - readonly worktreePath?: string | null | undefined; - }, - A, - E, - R, - >(input: Input, launch: (input: Input) => Effect.Effect) { - const thread = yield* getThreadShell(ThreadId.make(input.threadId)).pipe( - Effect.orElseSucceed(() => null), - ); - if (thread?.worktreePath == null) return yield* launch(input); - const relative = path.relative(thread.worktreePath, input.cwd ?? thread.worktreePath); - if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) { - return yield* launch(input); - } - return yield* launch({ ...input, worktreePath: thread.worktreePath }).pipe( - Effect.provideService(TerminalCwdRecovery, (error) => - restoreMissingWorktree(thread).pipe( - Effect.catch((cause) => - Effect.logWarning("failed to recreate missing worktree", { - threadId: thread.id, - worktreePath: thread.worktreePath, - cause, - }).pipe(Effect.andThen(Effect.fail(error))), - ), - ), +export const makePrepareTerminalLaunch = Effect.fn( + "restoreMissingWorktree.makePrepareTerminalLaunch", +)(function* ( + getThreadShell: (threadId: ThreadId) => Effect.Effect, +) { + const path = yield* Path.Path; + const restoreMissingWorktree = yield* makeRestoreMissingWorktree; + return Effect.fn("restoreMissingWorktree.prepareTerminalLaunch")(function* < + Input extends { + readonly threadId: string; + readonly cwd?: string | undefined; + readonly worktreePath?: string | null | undefined; + }, + >(input: Input) { + const thread = yield* getThreadShell(ThreadId.make(input.threadId)).pipe( + Effect.orElseSucceed(() => null), + ); + const worktreePath = thread?.worktreePath ?? null; + if ( + thread === null || + worktreePath === null || + !isWithinRoot(path, path.resolve(input.cwd ?? worktreePath), worktreePath) + ) { + return { input, recover: TerminalCwdRecovery.defaultValue() }; + } + const recover: TerminalCwdRecovery["Service"] = (error) => + restoreMissingWorktree(thread).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to recreate missing worktree", { + threadId: thread.id, + worktreePath, + cause, + }).pipe(Effect.andThen(Effect.fail(error))), ), ); - }); - }, -); + // The lease must cover the worktree root, even when the terminal opens in a subdirectory. + return { input: { ...input, worktreePath }, recover }; + }); +}); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 8606ba10cfdf4..7f52d5963a07d 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -7,9 +7,6 @@ import { AuthOrchestrationReadScope, AuthSessionId, AuthSourceControlWriteScope, - AuthTerminalOperateScope, - TerminalCwdNotFoundError, - type TerminalSessionSnapshot, EnvironmentId, EventId, GitManagerError, @@ -2105,137 +2102,6 @@ it.effect.each([ }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), ); -it.effect.each([WS_METHODS.terminalOpen, WS_METHODS.terminalAttach])( - "restores a cleaned-up worktree at the %s RPC boundary", - (method) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { worktreesDir } = yield* ServerConfigModule.ServerConfig; - const cwd = path.join(worktreesDir, "project", "feature"); - const threadId = ThreadId.make("terminal-recovery"); - const input = { threadId, terminalId: "default", cwd }; - const snapshot: TerminalSessionSnapshot = { - ...input, - worktreePath: cwd, - status: "running", - pid: 1, - history: "", - exitCode: null, - exitSignal: null, - label: "bash", - updatedAt: "2026-10-26T00:00:00Z", - }; - let attempts = 0; - const created: string[] = []; - const check = Effect.gen(function* () { - attempts++; - if (!(yield* fs.exists(cwd).pipe(Effect.orDie))) - return yield* new TerminalCwdNotFoundError({ cwd }); - return snapshot; - }); - const launch = check.pipe( - Effect.catchTags({ - TerminalCwdNotFoundError: (error) => - Effect.flatMap(TerminalManager.TerminalCwdRecovery, (recover) => - recover(error).pipe(Effect.andThen(check)), - ), - }), - ); - const services = Layer.mergeAll( - layerUnusedConfigRpcServices, - Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), - Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/unused" }), - Layer.mock(ExternalLauncher.ExternalLauncher)({}), - Layer.mock(UsageLimitSources.UsageLimitSources)({}), - Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({}), - Layer.mock(DirectEndpoints.DirectEndpoints)({}), - Layer.mock(ProviderRegistry.ProviderRegistry)({}), - Layer.mock(ServerEnvironment.ServerEnvironment)({}), - Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), - Layer.mock(TerminalManager.TerminalManager)({ - open: () => launch, - attachOutputStream: () => - Stream.fromEffect( - launch.pipe(Effect.map((snapshot) => ({ type: "snapshot" as const, snapshot }))), - ), - }), - Layer.mock(ThreadManagement.ThreadManagementService)({ - getThreadShell: () => - Effect.succeed({ - id: threadId, - projectId: ProjectId.make("project"), - worktreePath: cwd, - branch: "feature", - } as OrchestrationV2ThreadShell), - }), - Layer.mock(ProjectService.ProjectService)({ - getById: () => Effect.succeedSome({ workspaceRoot: "/repo" } as never), - }), - Layer.mock(GitWorkflowService.GitWorkflowService)({ - pruneWorktrees: () => Effect.void, - listLocalBranchNames: () => Effect.succeed(["feature"]), - createWorktree: () => - Effect.gen(function* () { - created.push(cwd); - yield* fs.makeDirectory(cwd, { recursive: true }); - return {} as never; - }).pipe(Effect.orDie), - }), - ); - const responses = yield* Queue.unbounded(); - const server = yield* RpcServer.makeNoSerialization(WsRpcGroup, { - ...WS_RPC_SERVER_OPTIONS, - onFromServer: (response) => - response._tag === "Exit" - ? Queue.offer( - responses, - response.exit._tag === "Success" ? "success" : JSON.stringify(response.exit), - ) - : response._tag === "Chunk" - ? Queue.offer(responses, "success") - : Queue.offer(responses, JSON.stringify(response)), - }).pipe( - Effect.provide([ - RpcAuthorization.layer([AuthTerminalOperateScope]), - layerWsRpc( - { - sessionId: AuthSessionId.make("terminal-test"), - subject: "terminal-test", - method: "browser-session-cookie", - scopes: [AuthTerminalOperateScope], - }, - {}, - yield* ServerBrowser.ServerBrowser, - () => Effect.void, - { secretReferences: true }, - ).pipe(Layer.provide(services)), - ]), - ); - const requestId = RpcMessage.RequestId("terminal"); - yield* server.write(0, { - _tag: "Request", - id: requestId, - tag: method, - payload: input, - headers: Headers.empty, - }); - assert.equal(yield* Queue.take(responses), "success"); - assert.equal(attempts, 2); - assert.deepEqual(created, [cwd]); - yield* server.write(0, { _tag: "Interrupt", requestId, interruptors: [] }); - }).pipe( - Effect.provide( - Layer.mergeAll( - Layer.fresh(layerConfigSources), - Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}), - Layer.mock(ServerBrowser.ServerBrowser)({}), - ), - ), - Effect.scoped, - ), -); - const acpDriver = ProviderDriverKind.make("acpRegistry"); const acpInstanceId = ProviderInstanceId.make("acpRegistry_test"); const acpProjectId = ProjectId.make("project:acp"); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d248c9425145c..4bcc46178ba64 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -191,7 +191,7 @@ import { invalidateSettingsSecrets } from "./sourceControl/sourceControlHostSecr import * as DatabaseStorage from "./persistence/DatabaseStorage.ts"; import * as StorageCleanup from "./storageCleanup.ts"; import * as TerminalManager from "./terminal/Manager.ts"; -import { makeWithWorktreeRecovery } from "./workspace/restoreMissingWorktree.ts"; +import { makePrepareTerminalLaunch } from "./workspace/restoreMissingWorktree.ts"; import { terminalOutputStream, withOutputWindow } from "./terminal/OutputProtocol.ts"; import * as ServerBrowser from "./preview/ServerBrowser.ts"; import * as DeviceService from "./device/DeviceService.ts"; @@ -1515,7 +1515,9 @@ export const layerWsRpc = ( const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const terminalManager = yield* TerminalManager.TerminalManager; - const withWorktreeRecovery = yield* makeWithWorktreeRecovery(threadManagement.getThreadShell); + const prepareTerminalLaunch = yield* makePrepareTerminalLaunch( + threadManagement.getThreadShell, + ); const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; @@ -3163,16 +3165,21 @@ export const layerWsRpc = ( [WS_METHODS.reviewGetBranchCommits]: (input) => review.getBranchCommits(input), [WS_METHODS.reviewGetDiffPreview]: (input) => review.getDiffPreview(input), [WS_METHODS.reviewGetDiffFileContents]: (input) => review.getDiffFileContents(input), - [WS_METHODS.terminalOpen]: (input) => withWorktreeRecovery(input, terminalManager.open), + [WS_METHODS.terminalOpen]: (input) => + prepareTerminalLaunch(input).pipe( + Effect.flatMap((launch) => + terminalManager + .open(launch.input) + .pipe(Effect.provideService(TerminalManager.TerminalCwdRecovery, launch.recover)), + ), + ), [WS_METHODS.terminalAttach]: (input, metadata) => Stream.unwrap( - withWorktreeRecovery(input, (input) => - Effect.context().pipe( - Effect.map((context) => - terminalManager - .attachOutputStream(input, terminalViewerId(metadata)) - .pipe(Stream.provideContext(context)), - ), + prepareTerminalLaunch(input).pipe( + Effect.map((launch) => + terminalManager + .attachOutputStream(launch.input, terminalViewerId(metadata)) + .pipe(Stream.provideService(TerminalManager.TerminalCwdRecovery, launch.recover)), ), ), ), From 4eab804e01c47c82374c8f6e9501d57873a06a07 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 22:09:50 +0000 Subject: [PATCH 3/3] fix(server): type terminal recovery callback explicitly AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/terminal/Manager.test.ts | 4 ++-- apps/server/src/workspace/restoreMissingWorktree.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 078de1f9f1764..7ac215dd1cd6e 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -488,8 +488,8 @@ it.layer( const launch = prepareLaunch(input).pipe( Effect.flatMap(({ input, recover }) => (method === "attach" - ? manager.attachStream(input, () => Effect.void) - : manager.open(input) + ? manager.attachStream(input, () => Effect.void).pipe(Effect.asVoid) + : manager.open(input).pipe(Effect.asVoid) ).pipe(Effect.provideService(TerminalManager.TerminalCwdRecovery, recover)), ), ); diff --git a/apps/server/src/workspace/restoreMissingWorktree.ts b/apps/server/src/workspace/restoreMissingWorktree.ts index b3d105439bd26..7a164b67d3696 100644 --- a/apps/server/src/workspace/restoreMissingWorktree.ts +++ b/apps/server/src/workspace/restoreMissingWorktree.ts @@ -1,4 +1,4 @@ -import { ThreadId, type ProjectId } from "@t3tools/contracts"; +import { ThreadId, type ProjectId, type TerminalCwdNotFoundError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -158,7 +158,7 @@ export const makePrepareTerminalLaunch = Effect.fn( ) { return { input, recover: TerminalCwdRecovery.defaultValue() }; } - const recover: TerminalCwdRecovery["Service"] = (error) => + const recover = (error: TerminalCwdNotFoundError) => restoreMissingWorktree(thread).pipe( Effect.catch((cause) => Effect.logWarning("failed to recreate missing worktree", {