Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
194 changes: 187 additions & 7 deletions apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { expect, it, vi } from "vite-plus/test";
import { it as effectIt } from "@effect/vitest";
import {
CheckpointScopeId,
GitCommandError,
MessageId,
NodeId,
ProviderSessionId,
Expand Down Expand Up @@ -32,6 +33,8 @@ import * as Schema from "effect/Schema";
import * as ServerConfig from "../config.ts";
import { withWorkspaceLease } from "../workspace/workspaceLease.ts";
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 ProviderAuthService from "../provider/ProviderAuthService.ts";
import * as ServerSettings from "../serverSettings.ts";
Expand Down Expand Up @@ -121,6 +124,7 @@ function startWithMissingWorktree(
const startRootRun = vi.fn(() => Effect.void);
const pruneWorktrees = vi.fn(() => Effect.void);
const createWorktree = vi.fn(() => Effect.succeed({} as never));
const listLocalBranchNames = vi.fn(() => Effect.succeed(["feature/restore"]));
const layer = ProviderTurnStart.layer.pipe(
Layer.provide(
Layer.mergeAll(
Expand All @@ -143,7 +147,11 @@ function startWithMissingWorktree(
),
} as never),
layerWorktreeLocation(),
Layer.mock(GitWorkflow.GitWorkflowService)({ pruneWorktrees, createWorktree }),
Layer.mock(GitWorkflow.GitWorkflowService)({
pruneWorktrees,
createWorktree,
listLocalBranchNames,
}),
Layer.mock(ProjectService.ProjectService)({
getById: () =>
Effect.succeed(
Expand Down Expand Up @@ -277,6 +285,8 @@ function makeLocalCommandHarness(input: {
readonly text: string;
readonly worktreePath?: string;
readonly worktreesDir?: string;
readonly workspaceRoot?: string;
readonly gitWorkflow?: GitWorkflow.GitWorkflowService["Service"];
readonly fileSystem?: FileSystem.FileSystem;
readonly previousNativeSession?: boolean;
readonly previousMessages?: ReadonlyArray<string>;
Expand Down Expand Up @@ -605,7 +615,9 @@ function makeLocalCommandHarness(input: {
({ thread }: Parameters<RuntimePolicy.RuntimePolicyV2Shape["resolve"]>[0]) =>
Effect.succeed({ cwd: thread.worktreePath ?? "/tmp/native-account-command" } as never),
);
const createWorktree = vi.fn(() => Effect.succeed({} as never));
const createWorktree = vi.fn<GitWorkflow.GitWorkflowService["Service"]["createWorktree"]>(() =>
Effect.succeed({} as never),
);
const getThreadRecords = vi.fn<ProjectionStore.ProjectionStoreV2Shape["getThreadRecords"]>(() =>
Effect.succeed(projection),
);
Expand All @@ -631,13 +643,20 @@ function makeLocalCommandHarness(input: {
input.fileSystem === undefined
? FileSystem.layerNoop({ exists: () => Effect.succeed(true) })
: Layer.succeed(FileSystem.FileSystem, input.fileSystem),
Layer.mock(GitWorkflow.GitWorkflowService)({
pruneWorktrees: () => Effect.void,
createWorktree,
}),
input.gitWorkflow === undefined
? Layer.mock(GitWorkflow.GitWorkflowService)({
pruneWorktrees: () => Effect.void,
listLocalBranchNames: () => Effect.succeed(["feature/restore"]),
createWorktree,
})
: Layer.succeed(GitWorkflow.GitWorkflowService, input.gitWorkflow),
Layer.mock(ProjectService.ProjectService)({
getById: () =>
Effect.succeed(Option.some({ workspaceRoot: "/tmp/native-account-project" } as never)),
Effect.succeed(
Option.some({
workspaceRoot: input.workspaceRoot ?? "/tmp/native-account-project",
} as never),
),
}),
Layer.mock(ProjectionStore.ProjectionStoreV2)({
getThreadRecords,
Expand Down Expand Up @@ -698,6 +717,167 @@ function makeLocalCommandHarness(input: {
};
}

effectIt.effect(
"recovers the thread branch tip, falls back to the current project branch, and rejects detached HEAD",
() =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const gitWorkflow = yield* GitWorkflow.GitWorkflowService;
const driver = yield* GitVcsDriver.GitVcsDriver;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-recreate-repo-" });
const workspaceRoot = path.join(root, "project");
const worktreesDir = path.join(root, "worktrees");
const worktreePath = path.join(worktreesDir, "project", "restore");
yield* fileSystem.makeDirectory(workspaceRoot);
const git = (cwd: string, args: ReadonlyArray<string>) =>
driver
.execute({ operation: "test.worktreeRecovery", cwd, args })
.pipe(Effect.map((result) => result.stdout.trim()));
yield* git(workspaceRoot, ["init", "-b", "main"]);
yield* git(workspaceRoot, ["config", "user.name", "Test"]);
yield* git(workspaceRoot, ["config", "user.email", "test@example.com"]);
yield* git(workspaceRoot, ["commit", "--allow-empty", "-m", "base"]);
yield* git(workspaceRoot, ["worktree", "add", "-b", "feature/restore", worktreePath]);
yield* fileSystem.writeFileString(path.join(worktreePath, "thread.txt"), "thread work");
yield* git(worktreePath, ["add", "."]);
yield* git(worktreePath, ["commit", "-m", "thread tip"]);
const threadTip = yield* git(worktreePath, ["rev-parse", "HEAD"]);
yield* git(workspaceRoot, ["switch", "-c", "project-current"]);
yield* fileSystem.writeFileString(
path.join(workspaceRoot, "project.txt"),
"new project code",
);
yield* git(workspaceRoot, ["add", "."]);
yield* git(workspaceRoot, ["commit", "-m", "project tip"]);
const projectTip = yield* git(workspaceRoot, ["rev-parse", "HEAD"]);
expect(projectTip).not.toBe(threadTip);
// Tags must neither hide the thread branch nor select a different recovery tip.
yield* git(workspaceRoot, ["tag", "feature/restore", projectTip]);
yield* git(workspaceRoot, ["tag", "project-current", threadTip]);

// Reuse the workflow across turns so recovery cannot select a cached project branch.
for (const scenario of ["retained", "deleted", "detached"] as const) {
yield* fileSystem.remove(worktreePath, { recursive: true });
if (scenario !== "retained") {
yield* git(workspaceRoot, ["worktree", "prune"]);
yield* git(workspaceRoot, ["branch", "-D", "feature/restore"]);
}
if (scenario === "detached") {
yield* git(workspaceRoot, ["switch", "--detach"]);
expect(yield* git(workspaceRoot, ["branch", "--show-current"])).toBe("");
}
const harness = makeLocalCommandHarness({
text: "Continue",
workspaceRoot,
worktreePath,
worktreesDir,
fileSystem,
gitWorkflow,
failReadsAfterRunning: true,
});
const open = harness.open.getMockImplementation()!;
harness.open.mockImplementation(() =>
Effect.gen(function* () {
expect(yield* git(worktreePath, ["branch", "--show-current"])).toBe("feature/restore");
expect(yield* git(worktreePath, ["rev-parse", "HEAD"])).toBe(
scenario === "retained" ? threadTip : projectTip,
);
expect(
yield* fileSystem.readFileString(
path.join(worktreePath, scenario === "retained" ? "thread.txt" : "project.txt"),
),
).toBe(scenario === "retained" ? "thread work" : "new project code");
return yield* open();
}).pipe(Effect.orDie),
);

yield* harness.start;

if (scenario === "detached") {
expect(harness.open).not.toHaveBeenCalled();
expect(harness.startRootRun).not.toHaveBeenCalled();
expect(harness.projection().runs.at(-1)?.status).toBe("failed");
expect(harness.projection().turnItems).toMatchObject([
{ type: "error", failure: { message: expect.stringContaining("no current branch") } },
]);
expect(yield* fileSystem.exists(worktreePath)).toBe(false);
expect(yield* git(workspaceRoot, ["branch", "--list", "feature/restore"])).toBe("");
} else {
expect(harness.open).toHaveBeenCalledOnce();
expect(harness.startRootRun).toHaveBeenCalledWith(
expect.objectContaining({
runtimePolicy: expect.objectContaining({ cwd: worktreePath }),
}),
);
expect(harness.projection().runs.at(-1)?.status).toBe("running");
}
}
}).pipe(Effect.scoped, Effect.provide(layerRealGit)),
);

effectIt.effect(
"preserves recreation interrupts and retries, then fails the final attempt before provider start",
() =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const worktreesDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-recreate-failure-",
});
const worktreePath = path.join(worktreesDir, "project", "restore");
const harness = makeLocalCommandHarness({
text: "Continue",
worktreesDir,
worktreePath,
fileSystem,
failReadsAfterRunning: true,
});
harness.createWorktree.mockImplementationOnce(() => Effect.interrupt);
harness.createWorktree.mockImplementation(() =>
Effect.fail(
new GitCommandError({
operation: "worktree-add",
command: "git worktree add",
cwd: "/tmp/native-account-project",
detail: "fatal: private git diagnostic",
}),
),
);

const interrupted = yield* Effect.exit(harness.start);
expect(interrupted._tag).toBe("Failure");
if (interrupted._tag === "Failure")
expect(Cause.hasInterruptsOnly(interrupted.cause)).toBe(true);
expect(harness.events).toEqual([]);
expect(harness.projection().runs.at(-1)?.status).toBe("starting");

const retry = yield* Effect.result(harness.startWithRetry);
expect(retry).toMatchObject({ _tag: "Failure", failure: { _tag: "ProviderTurnStartError" } });
expect(harness.events).toEqual([]);
expect(harness.projection().runs.at(-1)?.status).toBe("starting");

yield* harness.start;
expect(harness.createWorktree).toHaveBeenCalledTimes(3);
expect(harness.writeIfRunCurrent).toHaveBeenCalledOnce();
expect(harness.open).not.toHaveBeenCalled();
expect(harness.startRootRun).not.toHaveBeenCalled();
const projection = harness.projection();
expect(projection.runs.at(-1)).toMatchObject({ status: "failed", startedAt: null });
expect(projection.attempts[0]).toMatchObject({ status: "failed", startedAt: null });
expect(projection.nodes[0]).toMatchObject({ status: "failed", startedAt: null });
expect(projection.turnItems).toMatchObject([
{
type: "error",
failure: {
message: `Could not recreate the worktree for branch 'feature/restore' at ${worktreePath}.`,
},
},
]);
expect(JSON.stringify(harness.events)).not.toContain("private git diagnostic");
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

effectIt.effect.each(["cleanup-first", "startup-first"])(
"coordinates provider startup with %s",
(order) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as ServerConfig from "../config.ts";
import * as GitManager from "../git/GitManager.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import * as TextGeneration from "../textGeneration/TextGeneration.ts";
import * as ProjectionStore from "./ProjectionStore.ts";
import * as ProjectStore from "./ProjectStore.ts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
Expand All @@ -14,3 +28,26 @@ export const layer = Layer.merge(
getById: () => Effect.succeed(Option.none()),
}),
);

// Keep repository detection, branch selection and worktree creation on the real Git path.
export const layerRealGit = GitWorkflow.layer.pipe(
Layer.provide(GitManager.layer),
Layer.provide(
Layer.mergeAll(
Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
resolveLink: () => undefined,
}),
Layer.mock(TextGeneration.TextGeneration)({}),
Layer.mock(ProviderRegistry.ProviderRegistry)({}),
Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}),
Layer.mock(ProjectionStore.ProjectionStoreV2)({}),
Layer.mock(ProjectStore.ProjectStoreV2)({}),
ServerSettings.layerTest(),
),
),
Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))),
Layer.provideMerge(GitVcsDriver.layer),
Layer.provideMerge(VcsProcess.layer),
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-recreate-" })),
Layer.provideMerge(NodeServices.layer),
);
Loading
Loading