Skip to content

Commit da345d2

Browse files
fix(server): terminals recreate a worktree removed by storage cleanup (#24156)
Co-authored-by: Julius Marminge <[email protected]>
1 parent 058d953 commit da345d2

5 files changed

Lines changed: 312 additions & 135 deletions

File tree

‎apps/server/src/orchestration-v2/ProviderTurnStartService.ts‎

Lines changed: 12 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import * as GitWorkflowService from "../git/GitWorkflowService.ts";
3535
import * as ProjectService from "../project/ProjectService.ts";
3636
import * as ProviderAuthService from "../provider/ProviderAuthService.ts";
3737
import * as ServerSettings from "../serverSettings.ts";
38-
import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts";
38+
import { makeRestoreMissingWorktree } from "../workspace/restoreMissingWorktree.ts";
3939
import * as EventSink from "./EventSink.ts";
4040
import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts";
4141
import * as ContextHandoffService from "./ContextHandoffService.ts";
@@ -77,28 +77,6 @@ export class ProviderTurnStartError extends Schema.TaggedError<ProviderTurnStart
7777
},
7878
) {}
7979

80-
class WorktreeRecreateError extends Schema.TaggedError<WorktreeRecreateError>()(
81-
"WorktreeRecreateError",
82-
{
83-
worktreePath: Schema.String,
84-
branch: Schema.String,
85-
cause: Schema.Defect(),
86-
},
87-
) {
88-
override get message(): string {
89-
return `Could not recreate the worktree for branch '${this.branch}' at ${this.worktreePath}.`;
90-
}
91-
}
92-
93-
class WorktreeRecreateNoBaseError extends Schema.TaggedError<WorktreeRecreateNoBaseError>()(
94-
"WorktreeRecreateNoBaseError",
95-
{ worktreePath: Schema.String, branch: Schema.String },
96-
) {
97-
override get message(): string {
98-
return `Could not recreate branch '${this.branch}' at ${this.worktreePath}: the project workspace has no commit to start it from.`;
99-
}
100-
}
101-
10280
const isProviderTurnStartError = Schema.is(ProviderTurnStartError);
10381
const isProviderAdapterProtocolError = Schema.is(ProviderAdapter.ProviderAdapterProtocolError);
10482
const isProviderAdapterOpenSessionError = Schema.is(
@@ -176,80 +154,14 @@ export const layer: Layer.Layer<
176154
const turnAnalytics = yield* ProviderUsageAnalytics.ProviderTurnAnalytics;
177155
const contextHandoffService = yield* ContextHandoffService.ContextHandoffServiceV2;
178156
const idAllocator = yield* IdAllocator.IdAllocatorV2;
179-
const fileSystem = yield* FileSystem.FileSystem;
180157
const path = yield* Path.Path;
181-
const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;
182-
const projects = yield* ProjectService.ProjectService;
183158
const providerAuth = yield* ProviderAuthService.ProviderAuthService;
184159
const projectionStore = yield* ProjectionStore.ProjectionStoreV2;
185160
const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2;
186161
const runExecution = yield* RunExecutionService.RunExecutionServiceV2;
187162
const runtimePolicy = yield* RuntimePolicy.RuntimePolicyV2;
163+
const restoreMissingWorktree = yield* makeRestoreMissingWorktree;
188164
const turnItemPositions = yield* TurnItemPositionStore.TurnItemPositionStoreV2;
189-
const isManagedWorktreePath = yield* makeIsManagedWorktreePath;
190-
191-
const resolveRecreateBase = Effect.fn("ProviderTurnStartService.resolveRecreateBase")(
192-
function* (cwd: string) {
193-
// Recovery can follow an external checkout, before the status cache expires.
194-
yield* gitWorkflow.invalidateLocalStatus(cwd);
195-
const { refName: currentBranch } = yield* gitWorkflow.localStatus({ cwd });
196-
if (currentBranch !== null) {
197-
const refName = `refs/heads/${currentBranch}`;
198-
return { refName, baseRefName: refName };
199-
}
200-
if (yield* gitWorkflow.hasCommit({ cwd, refName: "refs/remotes/origin/HEAD" })) {
201-
const { commitSha } = yield* gitWorkflow.resolveRemoteTrackingCommit({
202-
cwd,
203-
refName: "HEAD",
204-
fallbackRemoteName: "origin",
205-
});
206-
// Start from the commit so the thread branch does not track the default branch.
207-
return { refName: commitSha, baseRefName: "refs/remotes/origin/HEAD" };
208-
}
209-
if (yield* gitWorkflow.hasCommit({ cwd, refName: "HEAD" })) {
210-
return { refName: "HEAD", baseRefName: undefined };
211-
}
212-
return null;
213-
},
214-
);
215-
216-
const recreateMissingWorktree = Effect.fn("ProviderTurnStartService.recreateMissingWorktree")(
217-
function* (input: {
218-
readonly cwd: string;
219-
readonly worktreePath: string;
220-
readonly branch: string;
221-
}) {
222-
const { cwd, worktreePath, branch } = input;
223-
yield* gitWorkflow.pruneWorktrees({ cwd });
224-
const localBranchNames = yield* gitWorkflow.listLocalBranchNames(cwd);
225-
if (localBranchNames.includes(branch)) {
226-
yield* gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath });
227-
return;
228-
}
229-
// Push keeps the tracking ref current, so recovery reads it without fetching.
230-
const pushedRef = `refs/remotes/origin/${branch}`;
231-
if (yield* gitWorkflow.hasCommit({ cwd, refName: pushedRef })) {
232-
yield* gitWorkflow.createWorktree({
233-
cwd,
234-
refName: pushedRef,
235-
newRefName: branch,
236-
path: worktreePath,
237-
});
238-
return;
239-
}
240-
const base = yield* resolveRecreateBase(cwd);
241-
if (base === null) {
242-
return yield* new WorktreeRecreateNoBaseError({ worktreePath, branch });
243-
}
244-
yield* gitWorkflow.createWorktree({
245-
cwd,
246-
refName: base.refName,
247-
newRefName: branch,
248-
...(base.baseRefName === undefined ? {} : { baseRefName: base.baseRefName }),
249-
path: worktreePath,
250-
});
251-
},
252-
);
253165

254166
// Build draining callbacks outside startup scope so they cannot retain full thread history.
255167
const makeRunControls = (input: {
@@ -650,47 +562,16 @@ export const layer: Layer.Layer<
650562
return;
651563
}
652564
}
653-
const { worktreePath, branch } = projection.thread;
654-
if (worktreePath !== null && branch !== null) {
655-
const exists = yield* fileSystem
656-
.exists(worktreePath)
657-
.pipe(Effect.orElseSucceed(() => true));
658-
if (!exists && (yield* isManagedWorktreePath(worktreePath))) {
659-
const project = yield* projects.getById(projection.thread.projectId).pipe(
660-
Effect.map(Option.getOrUndefined),
661-
Effect.orElseSucceed(() => undefined),
662-
);
663-
if (project !== undefined) {
664-
yield* Effect.logWarning("provider turn start recreating missing worktree", {
665-
threadId: projection.thread.id,
666-
worktreePath,
667-
branch,
668-
});
669-
const recreateResult = yield* Effect.result(
670-
recreateMissingWorktree({
671-
cwd: project.workspaceRoot,
672-
worktreePath,
673-
branch,
674-
}).pipe(
675-
Effect.mapError((cause) =>
676-
cause._tag === "WorktreeRecreateNoBaseError"
677-
? cause
678-
: new WorktreeRecreateError({ worktreePath, branch, cause }),
679-
),
680-
),
681-
);
682-
if (recreateResult._tag === "Failure") {
683-
if (input.willRetry === true) return yield* recreateResult.failure;
684-
yield* settleStartFailure({
685-
signal: "worktree-recreate-failure",
686-
title: "Couldn't recreate thread workspace",
687-
error: recreateResult.failure,
688-
message: recreateResult.failure.message,
689-
});
690-
return;
691-
}
692-
}
693-
}
565+
const recreateResult = yield* Effect.result(restoreMissingWorktree(projection.thread));
566+
if (recreateResult._tag === "Failure") {
567+
if (input.willRetry === true) return yield* recreateResult.failure;
568+
yield* settleStartFailure({
569+
signal: "worktree-recreate-failure",
570+
title: "Couldn't recreate thread workspace",
571+
error: recreateResult.failure,
572+
message: recreateResult.failure.message,
573+
});
574+
return;
694575
}
695576
const selectInheritedBackgroundItems = (
696577
current: ProjectionStore.ProjectionRuntimeRecoveryState,

‎apps/server/src/terminal/Manager.test.ts‎

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
44
import { assert, it } from "@effect/vitest";
55
import {
66
DEFAULT_TERMINAL_ID,
7+
ProjectId,
8+
ThreadId,
9+
TerminalCwdNotFoundError,
710
type TerminalAttachStreamEvent,
811
type TerminalEvent,
912
type TerminalOpenInput,
1013
ProviderDriverKind,
1114
ProviderInstanceId,
12-
ProjectId,
1315
retainedTerminalHistory,
1416
TerminalProviderInstanceNotFoundError,
1517
} from "@t3tools/contracts";
@@ -36,12 +38,16 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
3638
import * as TestClock from "effect/testing/TestClock";
3739
import { expect, vi } from "vite-plus/test";
3840

41+
import * as ServerConfig from "../config.ts";
3942
import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
4043
import * as ProcessRunner from "../processRunner.ts";
4144
import * as ServerSettings from "../serverSettings.ts";
4245
import * as ProjectService from "../project/ProjectService.ts";
4346
import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
4447
import * as TerminalManager from "./Manager.ts";
48+
import * as GitWorkflowService from "../git/GitWorkflowService.ts";
49+
import { makePrepareTerminalLaunch } from "../workspace/restoreMissingWorktree.ts";
50+
import { withWorkspaceLease } from "../workspace/workspaceLease.ts";
4551
import { terminalOutputStream } from "./OutputProtocol.ts";
4652
import * as PtyAdapter from "@t3tools/shared/PtyAdapter";
4753
import packageJson from "../../package.json" with { type: "json" };
@@ -453,6 +459,91 @@ it.layer(
453459
),
454460
{ excludeTestServices: true },
455461
)("TerminalManager", (it) => {
462+
it.effect.each([
463+
{ method: "open", location: "managed" },
464+
{ method: "attach", location: "managed" },
465+
{ method: "open", location: "subdirectory" },
466+
{ method: "open", location: "unmanaged" },
467+
{ method: "open", location: "no-worktree" },
468+
] as const)(
469+
"recovers $method with a $location cwd without a cleanup gap",
470+
({ method, location }) =>
471+
Effect.gen(function* () {
472+
const fs = yield* FileSystem.FileSystem;
473+
const path = yield* Path.Path;
474+
const { manager, ptyAdapter, baseDir } = yield* createManager();
475+
const worktreesDir = path.join(baseDir, "worktrees");
476+
const worktreePath =
477+
location === "unmanaged"
478+
? path.join(baseDir, "missing")
479+
: path.join(worktreesDir, "project", "feature");
480+
const cwd = location === "subdirectory" ? path.join(worktreePath, "src") : worktreePath;
481+
const cleanupQueued = yield* Deferred.make<Fiber.Fiber<void>>();
482+
let creations = 0;
483+
const prepareLaunch = yield* makePrepareTerminalLaunch(() =>
484+
Effect.succeed({
485+
id: ThreadId.make("thread-1"),
486+
projectId: ProjectId.make("project-1"),
487+
worktreePath: location === "no-worktree" ? null : worktreePath,
488+
branch: "feature",
489+
}),
490+
).pipe(
491+
Effect.provide(
492+
Layer.mergeAll(
493+
Layer.mock(ServerConfig.ServerConfig)({
494+
worktreesDir,
495+
} as ServerConfig.ServerConfig["Service"]),
496+
ServerSettings.layerTest(),
497+
Layer.mock(ProjectService.ProjectService)({
498+
getById: () => Effect.succeedSome({ workspaceRoot: baseDir } as never),
499+
}),
500+
Layer.mock(GitWorkflowService.GitWorkflowService)({
501+
pruneWorktrees: () => Effect.void,
502+
listLocalBranchNames: () => Effect.succeed(["feature"]),
503+
createWorktree: () =>
504+
Effect.gen(function* () {
505+
creations++;
506+
yield* fs.makeDirectory(cwd, { recursive: true }).pipe(Effect.orDie);
507+
const cleanup = yield* withWorkspaceLease(
508+
worktreePath,
509+
Effect.sync(() => {
510+
assert.equal(ptyAdapter.spawnInputs.length, 1);
511+
}),
512+
).pipe(Effect.forkChild({ startImmediately: true }));
513+
yield* Deferred.succeed(cleanupQueued, cleanup);
514+
return {} as never;
515+
}),
516+
}),
517+
),
518+
),
519+
);
520+
const input = openInput({
521+
cwd,
522+
worktreePath: location === "subdirectory" ? null : worktreePath,
523+
});
524+
const launch = prepareLaunch(input).pipe(
525+
Effect.flatMap(({ input, recover }) =>
526+
(method === "attach"
527+
? manager.attachStream(input, () => Effect.void).pipe(Effect.asVoid)
528+
: manager.open(input).pipe(Effect.asVoid)
529+
).pipe(Effect.provideService(TerminalManager.TerminalCwdRecovery, recover)),
530+
),
531+
);
532+
if (location === "unmanaged" || location === "no-worktree") {
533+
const error = yield* launch.pipe(Effect.flip);
534+
assert.deepEqual(error, new TerminalCwdNotFoundError({ cwd }));
535+
assert.equal(creations, 0);
536+
assert.equal(ptyAdapter.spawnInputs.length, 0);
537+
return;
538+
}
539+
yield* launch;
540+
yield* Fiber.join(yield* Deferred.await(cleanupQueued));
541+
assert.equal(creations, 1);
542+
assert.equal(ptyAdapter.spawnInputs.length, 1);
543+
assert.isTrue(yield* fs.exists(cwd));
544+
}),
545+
);
546+
456547
it.effect("spawns lazily and reuses running terminal per thread", () =>
457548
Effect.gen(function* () {
458549
const { manager, ptyAdapter } = yield* createManager();

‎apps/server/src/terminal/Manager.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@ class TerminalProcessSignalError extends Schema.TaggedError<TerminalProcessSigna
167167
}
168168
}
169169

170+
export class TerminalCwdRecovery extends Context.Reference<
171+
(error: TerminalCwdNotFoundError) => Effect.Effect<void, TerminalCwdNotFoundError>
172+
>("t3/terminal/TerminalCwdRecovery", { defaultValue: () => (error) => Effect.fail(error) }) {}
173+
170174
export class TerminalManager extends Context.Service<
171175
TerminalManager,
172176
{
@@ -2206,14 +2210,22 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
22062210
});
22072211

22082212
const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) {
2209-
const stats = yield* fileSystem.stat(cwd).pipe(
2213+
const stat = fileSystem.stat(cwd).pipe(
22102214
Effect.catchTags({
22112215
PlatformError: (cause) =>
22122216
cause.reason._tag === "NotFound"
22132217
? new TerminalCwdNotFoundError({ cwd })
22142218
: new TerminalCwdStatError({ cwd, cause }),
22152219
}),
22162220
);
2221+
const stats = yield* stat.pipe(
2222+
Effect.catchTags({
2223+
TerminalCwdNotFoundError: (error) =>
2224+
Effect.flatMap(TerminalCwdRecovery, (recover) =>
2225+
recover(error).pipe(Effect.andThen(stat)),
2226+
),
2227+
}),
2228+
);
22172229
if (stats.type !== "Directory") {
22182230
return yield* new TerminalCwdNotDirectoryError({ cwd });
22192231
}

0 commit comments

Comments
 (0)