Skip to content

Commit 0604547

Browse files
yordisjuliusmarmingeclaude
authored
fix(server): a thread whose worktree was deleted recovers on the next message (#18359)
Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent a4fbea8 commit 0604547

4 files changed

Lines changed: 338 additions & 56 deletions

File tree

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

Lines changed: 185 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { expect, it, vi } from "vite-plus/test";
33
import { it as effectIt } from "@effect/vitest";
44
import {
55
CheckpointScopeId,
6+
GitCommandError,
67
MessageId,
78
NodeId,
89
ProviderSessionId,
@@ -33,6 +34,8 @@ import * as Schema from "effect/Schema";
3334
import * as ServerConfig from "../config.ts";
3435
import { withWorkspaceLease } from "../workspace/workspaceLease.ts";
3536
import * as GitWorkflow from "../git/GitWorkflowService.ts";
37+
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
38+
import { layerRealGit } from "./ProviderTurnStartService.testkit.ts";
3639
import * as ProjectService from "../project/ProjectService.ts";
3740
import * as ProviderAuthService from "../provider/ProviderAuthService.ts";
3841
import * as ServerSettings from "../serverSettings.ts";
@@ -121,6 +124,7 @@ function startWithMissingWorktree(
121124
const startRootRun = vi.fn(() => Effect.void);
122125
const pruneWorktrees = vi.fn(() => Effect.void);
123126
const createWorktree = vi.fn(() => Effect.succeed({} as never));
127+
const listLocalBranchNames = vi.fn(() => Effect.succeed(["feature/restore"]));
124128
const layer = ProviderTurnStart.layer.pipe(
125129
Layer.provide(
126130
Layer.mergeAll(
@@ -142,7 +146,11 @@ function startWithMissingWorktree(
142146
),
143147
} as never),
144148
layerWorktreeLocation(),
145-
Layer.mock(GitWorkflow.GitWorkflowService)({ pruneWorktrees, createWorktree }),
149+
Layer.mock(GitWorkflow.GitWorkflowService)({
150+
pruneWorktrees,
151+
createWorktree,
152+
listLocalBranchNames,
153+
}),
146154
Layer.mock(ProjectService.ProjectService)({
147155
getById: () =>
148156
Effect.succeedSome({ workspaceRoot: "/tmp/provider-turn-start-project" } as never),
@@ -257,6 +265,8 @@ function makeLocalCommandHarness(input: {
257265
readonly text: string;
258266
readonly worktreePath?: string;
259267
readonly worktreesDir?: string;
268+
readonly workspaceRoot?: string;
269+
readonly gitWorkflow?: GitWorkflow.GitWorkflowService["Service"];
260270
readonly fileSystem?: FileSystem.FileSystem;
261271
readonly previousNativeSession?: boolean;
262272
readonly previousMessages?: ReadonlyArray<string>;
@@ -664,7 +674,9 @@ function makeLocalCommandHarness(input: {
664674
({ thread }: Parameters<RuntimePolicy.RuntimePolicyV2Shape["resolve"]>[0]) =>
665675
Effect.succeed({ cwd: thread.worktreePath ?? "/tmp/native-account-command" } as never),
666676
);
667-
const createWorktree = vi.fn(() => Effect.succeed({} as never));
677+
const createWorktree = vi.fn<GitWorkflow.GitWorkflowService["Service"]["createWorktree"]>(() =>
678+
Effect.succeed({} as never),
679+
);
668680
const getThreadRecords = vi.fn<ProjectionStore.ProjectionStoreV2Shape["getThreadRecords"]>(() =>
669681
Effect.succeed(projection),
670682
);
@@ -690,13 +702,18 @@ function makeLocalCommandHarness(input: {
690702
input.fileSystem === undefined
691703
? FileSystem.layerNoop({ exists: () => Effect.succeed(true) })
692704
: Layer.succeed(FileSystem.FileSystem, input.fileSystem),
693-
Layer.mock(GitWorkflow.GitWorkflowService)({
694-
pruneWorktrees: () => Effect.void,
695-
createWorktree,
696-
}),
705+
input.gitWorkflow === undefined
706+
? Layer.mock(GitWorkflow.GitWorkflowService)({
707+
pruneWorktrees: () => Effect.void,
708+
listLocalBranchNames: () => Effect.succeed(["feature/restore"]),
709+
createWorktree,
710+
})
711+
: Layer.succeed(GitWorkflow.GitWorkflowService, input.gitWorkflow),
697712
Layer.mock(ProjectService.ProjectService)({
698713
getById: () =>
699-
Effect.succeedSome({ workspaceRoot: "/tmp/native-account-project" } as never),
714+
Effect.succeedSome({
715+
workspaceRoot: input.workspaceRoot ?? "/tmp/native-account-project",
716+
} as never),
700717
}),
701718
Layer.mock(ProjectionStore.ProjectionStoreV2)({
702719
getThreadRecords,
@@ -770,6 +787,167 @@ function makeLocalCommandHarness(input: {
770787
};
771788
}
772789

790+
effectIt.effect(
791+
"recovers the thread branch tip, falls back to the current project branch, and rejects detached HEAD",
792+
() =>
793+
Effect.gen(function* () {
794+
const fileSystem = yield* FileSystem.FileSystem;
795+
const path = yield* Path.Path;
796+
const gitWorkflow = yield* GitWorkflow.GitWorkflowService;
797+
const driver = yield* GitVcsDriver.GitVcsDriver;
798+
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-recreate-repo-" });
799+
const workspaceRoot = path.join(root, "project");
800+
const worktreesDir = path.join(root, "worktrees");
801+
const worktreePath = path.join(worktreesDir, "project", "restore");
802+
yield* fileSystem.makeDirectory(workspaceRoot);
803+
const git = (cwd: string, args: ReadonlyArray<string>) =>
804+
driver
805+
.execute({ operation: "test.worktreeRecovery", cwd, args })
806+
.pipe(Effect.map((result) => result.stdout.trim()));
807+
yield* git(workspaceRoot, ["init", "-b", "main"]);
808+
yield* git(workspaceRoot, ["config", "user.name", "Test"]);
809+
yield* git(workspaceRoot, ["config", "user.email", "test@example.com"]);
810+
yield* git(workspaceRoot, ["commit", "--allow-empty", "-m", "base"]);
811+
yield* git(workspaceRoot, ["worktree", "add", "-b", "feature/restore", worktreePath]);
812+
yield* fileSystem.writeFileString(path.join(worktreePath, "thread.txt"), "thread work");
813+
yield* git(worktreePath, ["add", "."]);
814+
yield* git(worktreePath, ["commit", "-m", "thread tip"]);
815+
const threadTip = yield* git(worktreePath, ["rev-parse", "HEAD"]);
816+
yield* git(workspaceRoot, ["switch", "-c", "project-current"]);
817+
yield* fileSystem.writeFileString(
818+
path.join(workspaceRoot, "project.txt"),
819+
"new project code",
820+
);
821+
yield* git(workspaceRoot, ["add", "."]);
822+
yield* git(workspaceRoot, ["commit", "-m", "project tip"]);
823+
const projectTip = yield* git(workspaceRoot, ["rev-parse", "HEAD"]);
824+
expect(projectTip).not.toBe(threadTip);
825+
// Tags must neither hide the thread branch nor select a different recovery tip.
826+
yield* git(workspaceRoot, ["tag", "feature/restore", projectTip]);
827+
yield* git(workspaceRoot, ["tag", "project-current", threadTip]);
828+
829+
// Reuse the workflow across turns so recovery cannot select a cached project branch.
830+
for (const scenario of ["retained", "deleted", "detached"] as const) {
831+
yield* fileSystem.remove(worktreePath, { recursive: true });
832+
if (scenario !== "retained") {
833+
yield* git(workspaceRoot, ["worktree", "prune"]);
834+
yield* git(workspaceRoot, ["branch", "-D", "feature/restore"]);
835+
}
836+
if (scenario === "detached") {
837+
yield* git(workspaceRoot, ["switch", "--detach"]);
838+
expect(yield* git(workspaceRoot, ["branch", "--show-current"])).toBe("");
839+
}
840+
const harness = makeLocalCommandHarness({
841+
text: "Continue",
842+
workspaceRoot,
843+
worktreePath,
844+
worktreesDir,
845+
fileSystem,
846+
gitWorkflow,
847+
failReadsAfterRunning: true,
848+
});
849+
const open = harness.open.getMockImplementation()!;
850+
harness.open.mockImplementation(() =>
851+
Effect.gen(function* () {
852+
expect(yield* git(worktreePath, ["branch", "--show-current"])).toBe("feature/restore");
853+
expect(yield* git(worktreePath, ["rev-parse", "HEAD"])).toBe(
854+
scenario === "retained" ? threadTip : projectTip,
855+
);
856+
expect(
857+
yield* fileSystem.readFileString(
858+
path.join(worktreePath, scenario === "retained" ? "thread.txt" : "project.txt"),
859+
),
860+
).toBe(scenario === "retained" ? "thread work" : "new project code");
861+
return yield* open();
862+
}).pipe(Effect.orDie),
863+
);
864+
865+
yield* harness.start;
866+
867+
if (scenario === "detached") {
868+
expect(harness.open).not.toHaveBeenCalled();
869+
expect(harness.startRootRun).not.toHaveBeenCalled();
870+
expect(harness.projection().runs.at(-1)?.status).toBe("failed");
871+
expect(harness.projection().turnItems).toMatchObject([
872+
{ type: "error", failure: { message: expect.stringContaining("no current branch") } },
873+
]);
874+
expect(yield* fileSystem.exists(worktreePath)).toBe(false);
875+
expect(yield* git(workspaceRoot, ["branch", "--list", "feature/restore"])).toBe("");
876+
} else {
877+
expect(harness.open).toHaveBeenCalledOnce();
878+
expect(harness.startRootRun).toHaveBeenCalledWith(
879+
expect.objectContaining({
880+
runtimePolicy: expect.objectContaining({ cwd: worktreePath }),
881+
}),
882+
);
883+
expect(harness.projection().runs.at(-1)?.status).toBe("running");
884+
}
885+
}
886+
}).pipe(Effect.scoped, Effect.provide(layerRealGit)),
887+
);
888+
889+
effectIt.effect(
890+
"preserves recreation interrupts and retries, then fails the final attempt before provider start",
891+
() =>
892+
Effect.gen(function* () {
893+
const fileSystem = yield* FileSystem.FileSystem;
894+
const path = yield* Path.Path;
895+
const worktreesDir = yield* fileSystem.makeTempDirectoryScoped({
896+
prefix: "t3-recreate-failure-",
897+
});
898+
const worktreePath = path.join(worktreesDir, "project", "restore");
899+
const harness = makeLocalCommandHarness({
900+
text: "Continue",
901+
worktreesDir,
902+
worktreePath,
903+
fileSystem,
904+
failReadsAfterRunning: true,
905+
});
906+
harness.createWorktree.mockImplementationOnce(() => Effect.interrupt);
907+
harness.createWorktree.mockImplementation(() =>
908+
Effect.fail(
909+
new GitCommandError({
910+
operation: "worktree-add",
911+
command: "git worktree add",
912+
cwd: "/tmp/native-account-project",
913+
detail: "fatal: private git diagnostic",
914+
}),
915+
),
916+
);
917+
918+
const interrupted = yield* Effect.exit(harness.start);
919+
expect(interrupted._tag).toBe("Failure");
920+
if (interrupted._tag === "Failure")
921+
expect(Cause.hasInterruptsOnly(interrupted.cause)).toBe(true);
922+
expect(harness.events).toEqual([]);
923+
expect(harness.projection().runs.at(-1)?.status).toBe("starting");
924+
925+
const retry = yield* Effect.result(harness.startWithRetry);
926+
expect(retry).toMatchObject({ _tag: "Failure", failure: { _tag: "ProviderTurnStartError" } });
927+
expect(harness.events).toEqual([]);
928+
expect(harness.projection().runs.at(-1)?.status).toBe("starting");
929+
930+
yield* harness.start;
931+
expect(harness.createWorktree).toHaveBeenCalledTimes(3);
932+
expect(harness.writeIfRunCurrent).toHaveBeenCalledOnce();
933+
expect(harness.open).not.toHaveBeenCalled();
934+
expect(harness.startRootRun).not.toHaveBeenCalled();
935+
const projection = harness.projection();
936+
expect(projection.runs.at(-1)).toMatchObject({ status: "failed", startedAt: null });
937+
expect(projection.attempts[0]).toMatchObject({ status: "failed", startedAt: null });
938+
expect(projection.nodes[0]).toMatchObject({ status: "failed", startedAt: null });
939+
expect(projection.turnItems).toMatchObject([
940+
{
941+
type: "error",
942+
failure: {
943+
message: `Could not recreate the worktree for branch 'feature/restore' at ${worktreePath}.`,
944+
},
945+
},
946+
]);
947+
expect(JSON.stringify(harness.events)).not.toContain("private git diagnostic");
948+
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
949+
);
950+
773951
effectIt.effect("recovers an abandoned failed turn before continuing its native conversation", () =>
774952
Effect.gen(function* () {
775953
const harness = makeLocalCommandHarness({

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
import { SourceControlProviderError, SourceControlProviderKind } from "@t3tools/contracts";
2+
import * as NodeServices from "@effect/platform-node/NodeServices";
3+
import * as ServerConfig from "../config.ts";
4+
import * as GitManager from "../git/GitManager.ts";
5+
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
6+
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
7+
import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts";
8+
import * as VcsProcess from "../vcs/VcsProcess.ts";
9+
import * as ServerSettings from "../serverSettings.ts";
10+
import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
11+
import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
12+
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
13+
import * as TextGeneration from "../textGeneration/TextGeneration.ts";
14+
import * as ProjectionStore from "./ProjectionStore.ts";
15+
import * as ProjectStore from "./ProjectStore.ts";
116
import * as Effect from "effect/Effect";
217
import * as Layer from "effect/Layer";
318

@@ -13,3 +28,35 @@ export const layer = Layer.merge(
1328
getById: () => Effect.succeedNone,
1429
}),
1530
);
31+
32+
// Keep repository detection, branch selection and worktree creation on the real Git path.
33+
export const layerRealGit = GitWorkflow.layer.pipe(
34+
Layer.provide(GitManager.layer),
35+
Layer.provide(
36+
Layer.mergeAll(
37+
Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
38+
resolveLink: () => undefined,
39+
resolve: ({ cwd }) =>
40+
Effect.fail(
41+
new SourceControlProviderError({
42+
provider: SourceControlProviderKind.make("unknown"),
43+
operation: "resolve",
44+
cwd,
45+
detail: "No source control provider in tests",
46+
}),
47+
),
48+
}),
49+
Layer.mock(TextGeneration.TextGeneration)({}),
50+
Layer.mock(ProviderRegistry.ProviderRegistry)({}),
51+
Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}),
52+
Layer.mock(ProjectionStore.ProjectionStoreV2)({}),
53+
Layer.mock(ProjectStore.ProjectStoreV2)({}),
54+
ServerSettings.layerTest(),
55+
),
56+
),
57+
Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))),
58+
Layer.provideMerge(GitVcsDriver.layer),
59+
Layer.provideMerge(VcsProcess.layer),
60+
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-recreate-" })),
61+
Layer.provideMerge(NodeServices.layer),
62+
);

0 commit comments

Comments
 (0)