Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 12 additions & 131 deletions apps/server/src/orchestration-v2/ProviderTurnStartService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -77,28 +77,6 @@ export class ProviderTurnStartError extends Schema.TaggedError<ProviderTurnStart
},
) {}

class WorktreeRecreateError extends Schema.TaggedError<WorktreeRecreateError>()(
"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>()(
"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(
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
Expand Down
93 changes: 92 additions & 1 deletion apps/server/src/terminal/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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" };
Expand Down Expand Up @@ -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<Fiber.Fiber<void>>();
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();
Expand Down
14 changes: 13 additions & 1 deletion apps/server/src/terminal/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ class TerminalProcessSignalError extends Schema.TaggedError<TerminalProcessSigna
}
}

export class TerminalCwdRecovery extends Context.Reference<
(error: TerminalCwdNotFoundError) => Effect.Effect<void, TerminalCwdNotFoundError>
>("t3/terminal/TerminalCwdRecovery", { defaultValue: () => (error) => Effect.fail(error) }) {}

export class TerminalManager extends Context.Service<
TerminalManager,
{
Expand Down Expand Up @@ -2141,14 +2145,22 @@ 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"
? new TerminalCwdNotFoundError({ cwd })
: 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 });
}
Expand Down
Loading
Loading