Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts";
import { runMigrations } from "../persistence/Migrations.ts";
import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
Expand Down Expand Up @@ -170,6 +171,7 @@ const layerRuntime = (dbPath: string) => {
pruneWorktrees: () => Effect.void,
}),
),
Layer.provide(Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({})),
Layer.provide(layerPlatform),
);
};
Expand Down
84 changes: 82 additions & 2 deletions apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import { layerRealGit } from "./ProviderTurnStartService.testkit.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
import * as ProviderAuthService from "../provider/ProviderAuthService.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as ContextHandoffService from "./ContextHandoffService.ts";
Expand Down Expand Up @@ -133,17 +134,26 @@ function startWithMissingWorktree(
);
const startRootRun = vi.fn(() => Effect.void);
const pruneWorktrees = vi.fn(() => Effect.void);
const createWorktree = vi.fn(() => Effect.succeed({} as never));
let worktreeExists = false;
const createWorktree = vi.fn(() =>
Effect.sync(() => {
worktreeExists = true;
return {} as never;
}),
);
const listLocalBranchNames = vi.fn(() => Effect.succeed(["feature/restore"]));
const layer = ProviderTurnStart.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({
runForThread: () => Effect.succeed({ status: "no-script" }),
}),
Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({}),
Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }),
IdAllocator.layer,
Layer.mock(TurnItemPositionStore.TurnItemPositionStoreV2)({}),
Layer.succeed(FileSystem.FileSystem, {
exists: () => Effect.succeed(false),
exists: () => Effect.succeed(worktreeExists),
realPath: (target: string) =>
normalize(target) in realPaths
? Effect.succeed(realPaths[normalize(target)])
Expand Down Expand Up @@ -281,6 +291,7 @@ function makeLocalCommandHarness(input: {
readonly workspaceRoot?: string;
readonly gitWorkflow?: GitWorkflow.GitWorkflowService["Service"];
readonly fileSystem?: FileSystem.FileSystem;
readonly runSetup?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"];
readonly previousNativeSession?: boolean;
readonly previousMessages?: ReadonlyArray<string>;
readonly logoutFailure?: string;
Expand Down Expand Up @@ -977,6 +988,9 @@ function makeLocalCommandHarness(input: {
}),
IdAllocator.layer,
Layer.mock(TurnItemPositionStore.TurnItemPositionStoreV2)({}),
Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({
runForThread: input.runSetup ?? (() => Effect.succeed({ status: "no-script" })),
}),
layerWorktreeLocation(input.worktreesDir),
input.fileSystem === undefined
? FileSystem.layerNoop({ exists: () => Effect.succeed(true) })
Expand Down Expand Up @@ -1220,6 +1234,72 @@ effectIt.effect(
}).pipe(Effect.scoped, Effect.provide(layerRealGit)),
);

effectIt.effect.each([
{ async: false, exitCode: 0 },
{ async: false, exitCode: 1 },
{ async: true, exitCode: 0 },
])("restored setup respects async=$async and exitCode=$exitCode", (input) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const worktreesDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-restore-setup-" });
const worktreePath = path.join(worktreesDir, "project", "restore");
const setupStarted = yield* Deferred.make<void>();
const setupDone = yield* Deferred.make<ProjectSetupScriptRunner.ProjectSetupScriptCompletion>();
const completionObserved = yield* Deferred.make<void>();
const harness = makeLocalCommandHarness({
text: "Continue",
worktreesDir,
worktreePath,
fileSystem,
failReadsAfterRunning: true,
runSetup: () =>
withWorkspaceLease(worktreePath, Deferred.succeed(setupStarted, undefined)).pipe(
Effect.as({
status: "started",
scriptId: "setup",
scriptName: "Setup",
scriptCommand: "setup",
terminalId: "setup-test",
cwd: worktreePath,
async: input.async,
completion: Deferred.await(setupDone).pipe(
Effect.tap(() => Deferred.succeed(completionObserved, undefined)),
),
}),
),
});
harness.createWorktree.mockImplementation(() =>
fileSystem
.makeDirectory(worktreePath, { recursive: true })
.pipe(Effect.as({} as never), Effect.orDie),
);

const startup = yield* harness.start.pipe(Effect.forkChild({ startImmediately: true }));
yield* Deferred.await(setupStarted);
if (input.async) yield* Fiber.join(startup);
else expect(harness.open).not.toHaveBeenCalled();
yield* Deferred.succeed(setupDone, { exitCode: input.exitCode, durationMs: 0 });
yield* Deferred.await(completionObserved);
yield* Fiber.join(startup);

const failed = !input.async && input.exitCode !== 0;
expect(harness.projection().runs.at(-1)?.status).toBe(failed ? "failed" : "running");
expect(harness.open).toHaveBeenCalledTimes(failed ? 0 : 1);
if (failed) {
expect(harness.projection().turnItems).toMatchObject([
{
type: "error",
title: "Couldn't set up restored workspace",
failure: {
message: `Setup script exited with ${input.exitCode} in '${worktreePath}'.`,
},
},
]);
}
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
);

effectIt.effect(
"preserves recreation interrupts and retries, then fails the final attempt before provider start",
() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import * as Layer from "effect/Layer";
import * as GitWorkflow from "../git/GitWorkflowService.ts";
import * as ProjectService from "../project/ProjectService.ts";

export const layer = Layer.merge(
export const layer = Layer.mergeAll(
Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({
runForThread: () => Effect.succeed({ status: "no-script" }),
}),
Layer.mock(GitWorkflow.GitWorkflowService)({
pruneWorktrees: () => Effect.void,
createWorktree: () => Effect.succeed({} as never),
Expand Down
113 changes: 81 additions & 32 deletions apps/server/src/orchestration-v2/ProviderTurnStartService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import { withTurnStartReservation, withWorkspaceLease } from "../workspace/workspaceLease.ts";
import * as GitWorkflowService from "../git/GitWorkflowService.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
import * as ProviderAuthService from "../provider/ProviderAuthService.ts";
import * as ServerSettings from "../serverSettings.ts";
import { makeRestoreMissingWorktree } from "../workspace/restoreMissingWorktree.ts";
Expand Down Expand Up @@ -77,6 +78,15 @@ export class ProviderTurnStartError extends Schema.TaggedError<ProviderTurnStart
},
) {}

class WorktreeSetupScriptFailedError extends Schema.TaggedError<WorktreeSetupScriptFailedError>()(
"WorktreeSetupScriptFailedError",
{ worktreePath: Schema.String, exitCode: Schema.NullOr(Schema.Number) },
) {
override get message(): string {
return `Setup script exited with ${this.exitCode ?? "no exit code"} in '${this.worktreePath}'.`;
}
}

const isProviderTurnStartError = Schema.is(ProviderTurnStartError);
const isProviderAdapterProtocolError = Schema.is(ProviderAdapter.ProviderAdapterProtocolError);
const isProviderAdapterOpenSessionError = Schema.is(
Expand Down Expand Up @@ -141,6 +151,7 @@ export const layer: Layer.Layer<
| ServerSettings.ServerSettingsService
| GitWorkflowService.GitWorkflowService
| ProjectService.ProjectService
| ProjectSetupScriptRunner.ProjectSetupScriptRunner
| ProviderAuthService.ProviderAuthService
| ProjectionStore.ProjectionStoreV2
| ProviderSessionManager.ProviderSessionManagerV2
Expand All @@ -157,6 +168,7 @@ export const layer: Layer.Layer<
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const projects = yield* ProjectService.ProjectService;
const setupScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
const providerAuth = yield* ProviderAuthService.ProviderAuthService;
const projectionStore = yield* ProjectionStore.ProjectionStoreV2;
const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2;
Expand Down Expand Up @@ -575,6 +587,42 @@ export const layer: Layer.Layer<
});
return;
}
if (recreateResult.success !== undefined) {
const { project, worktreePath } = recreateResult.success;
// Opening the setup terminal takes its own workspace lease.
return Effect.gen(function* () {
const setupResult = yield* Effect.result(
Effect.gen(function* () {
const setup = yield* setupScripts.runForThread({
threadId: projection.thread.id,
projectId: project.id,
projectCwd: project.workspaceRoot,
worktreePath,
project,
observeCompletion: {},
});
if (setup.status !== "started" || !setup.completion) return;
if (setup.async) {
yield* setup.completion.pipe(Effect.forkDetach);
} else {
const { exitCode } = yield* setup.completion;
if (exitCode !== 0)
return yield* new WorktreeSetupScriptFailedError({ worktreePath, exitCode });
}
}),
);
if (setupResult._tag === "Failure") {
yield* settleStartFailure({
signal: "worktree-setup-failure",
title: "Couldn't set up restored workspace",
error: setupResult.failure,
message: setupResult.failure.message,
});
return;
}
yield* startWithWorkspaceLease(input);
});
}
const selectInheritedBackgroundItems = (
current: ProjectionStore.ProjectionRuntimeRecoveryState,
): ReturnType<typeof RunExecutionService.selectInheritedBackgroundTurnItems> =>
Expand Down Expand Up @@ -1759,38 +1807,39 @@ export const layer: Layer.Layer<
});
});

return ProviderTurnStartServiceV2.of({
start: Effect.fn("ProviderTurnStartService.start")(
function* (input) {
// Storage cleanup removes worktrees under the same lease. Only startup
// holds it; the provider turn runs after it is released.
const { thread } = yield* projectionStore.getThreadRecords(input.threadId, []);
const cwd =
thread.worktreePath ??
(yield* projects
.getById(thread.projectId, { includeDeleted: true })
.pipe(Effect.map((project) => Option.getOrUndefined(project)?.workspaceRoot)));
const providerTurn = yield* cwd === undefined
? start(input)
: withTurnStartReservation(
fileSystem,
path,
path.resolve(cwd),
thread.worktreePath === null
? start(input)
: withWorkspaceLease(path.resolve(thread.worktreePath), start(input)),
);
if (providerTurn !== undefined) yield* providerTurn;
},
(effect, input) =>
effect.pipe(
Effect.mapError((cause) =>
isProviderTurnStartError(cause)
? cause
: new ProviderTurnStartError({ runId: input.runId, cause }),
),
const startWithWorkspaceLease: ProviderTurnStartServiceV2Shape["start"] = Effect.fn(
"ProviderTurnStartService.start",
)(
function* (input) {
// Storage cleanup removes worktrees under the same lease. Only startup
// holds it; the provider turn runs after it is released.
const { thread } = yield* projectionStore.getThreadRecords(input.threadId, []);
const cwd =
thread.worktreePath ??
(yield* projects
.getById(thread.projectId, { includeDeleted: true })
.pipe(Effect.map((project) => Option.getOrUndefined(project)?.workspaceRoot)));
const providerTurn = yield* cwd === undefined
? start(input)
: withTurnStartReservation(
fileSystem,
path,
path.resolve(cwd),
thread.worktreePath === null
? start(input)
: withWorkspaceLease(path.resolve(thread.worktreePath), start(input)),
);
if (providerTurn !== undefined) yield* providerTurn;
},
(effect, input) =>
effect.pipe(
Effect.mapError((cause) =>
isProviderTurnStartError(cause)
? cause
: new ProviderTurnStartError({ runId: input.runId, cause }),
),
),
});
),
);
return ProviderTurnStartServiceV2.of({ start: startWithWorkspaceLease });
}),
);
6 changes: 5 additions & 1 deletion apps/server/src/orchestration-v2/runtimeLayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts";
import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";
import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts";
import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
Expand Down Expand Up @@ -100,7 +101,10 @@ import * as ThreadLaunchService from "./ThreadLaunchService.ts";
import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";

const layerPlatformTest = Layer.merge(
const layerPlatformTest = Layer.mergeAll(
Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({
runForThread: () => Effect.succeed({ status: "no-script" }),
}),
Layer.mergeAll(NodeServices.layer, NodeCrypto.layer),
Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
resolveLink: () => Effect.die("unused title link"),
Expand Down
Loading
Loading