diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 648e12e5a2aa8..f8d78f708773d 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -35,7 +35,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"; @@ -77,28 +77,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 isProviderAdapterProtocolError = Schema.is(ProviderAdapter.ProviderAdapterProtocolError); const isProviderAdapterOpenSessionError = Schema.is( @@ -176,80 +154,14 @@ 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 restoreMissingWorktree = yield* makeRestoreMissingWorktree; const turnItemPositions = yield* TurnItemPositionStore.TurnItemPositionStoreV2; - 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, - }); - }, - ); // Build draining callbacks outside startup scope so they cannot retain full thread history. const makeRunControls = (input: { @@ -650,47 +562,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 60b1dd131d3cc..7ac215dd1cd6e 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -4,12 +4,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, retainedTerminalHistory, TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; @@ -35,12 +37,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 { makePrepareTerminalLaunch } from "../workspace/restoreMissingWorktree.ts"; +import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; import { terminalOutputStream } from "./OutputProtocol.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; import packageJson from "../../package.json" with { type: "json" }; @@ -417,6 +423,91 @@ it.layer( ), { excludeTestServices: true }, )("TerminalManager", (it) => { + it.effect.each([ + { method: "open", location: "managed" }, + { method: "attach", location: "managed" }, + { method: "open", 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 prepareLaunch = yield* makePrepareTerminalLaunch(() => + 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 = prepareLaunch(input).pipe( + Effect.flatMap(({ input, recover }) => + (method === "attach" + ? manager.attachStream(input, () => Effect.void).pipe(Effect.asVoid) + : manager.open(input).pipe(Effect.asVoid) + ).pipe(Effect.provideService(TerminalManager.TerminalCwdRecovery, recover)), + ), + ); + if (location === "unmanaged" || location === "no-worktree") { + 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* 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 d5f6b2edee914..f3497c0915223 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -167,6 +167,10 @@ class TerminalProcessSignalError extends Schema.TaggedError Effect.Effect +>("t3/terminal/TerminalCwdRecovery", { defaultValue: () => (error) => Effect.fail(error) }) {} + export class TerminalManager extends Context.Service< TerminalManager, { @@ -2141,7 +2145,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" @@ -2149,6 +2153,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..7a164b67d3696 --- /dev/null +++ b/apps/server/src/workspace/restoreMissingWorktree.ts @@ -0,0 +1,174 @@ +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"; +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 { isWithinRoot } from "./KnownWorkspaces.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 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 = (error: TerminalCwdNotFoundError) => + 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.ts b/apps/server/src/ws.ts index 5e81541ff3530..4bcc46178ba64 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -191,6 +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 { 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"; @@ -1514,6 +1515,9 @@ export const layerWsRpc = ( const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const terminalManager = yield* TerminalManager.TerminalManager; + const prepareTerminalLaunch = yield* makePrepareTerminalLaunch( + threadManagement.getThreadShell, + ); const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; @@ -3161,9 +3165,24 @@ 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) => + prepareTerminalLaunch(input).pipe( + Effect.flatMap((launch) => + terminalManager + .open(launch.input) + .pipe(Effect.provideService(TerminalManager.TerminalCwdRecovery, launch.recover)), + ), + ), [WS_METHODS.terminalAttach]: (input, metadata) => - terminalManager.attachOutputStream(input, terminalViewerId(metadata)), + Stream.unwrap( + prepareTerminalLaunch(input).pipe( + Effect.map((launch) => + terminalManager + .attachOutputStream(launch.input, terminalViewerId(metadata)) + .pipe(Stream.provideService(TerminalManager.TerminalCwdRecovery, launch.recover)), + ), + ), + ), [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input), [WS_METHODS.terminalResize]: (input, metadata) => terminalManager.resize(input, terminalViewerId(metadata)),