History

fix(server): recreate a thread's worktree before starting a turn (#7839)

MacKinley Smith committed 2 months ago

Commit 01fc7d21 parent 17822fa5 files · +124 −0Browse files

Unified · Split

5 files
  1. apps/server/src/git/GitWorkflowService.ts +7 −0
  2. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +53 −0
  3. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +50 −0
  4. apps/server/src/vcs/GitVcsDriver.ts +4 −0
  5. apps/server/src/vcs/GitVcsDriverCore.ts +10 −0
apps/server/src/git/GitWorkflowService.ts+7−0
@@ -84,6 +84,9 @@ export class GitWorkflowService extends Context.Service< · 83 unmodified lines
8484 readonly removeWorktree: (
8585 input: VcsRemoveWorktreeInput,
8686 ) => Effect.Effect<void, GitCommandError>;
87+ readonly pruneWorktrees: (input: {
88+ readonly cwd: string;
89+ }) => Effect.Effect<void, GitCommandError>;
8790 readonly createRef: (
8891 input: VcsCreateRefInput,
8992 ) => Effect.Effect<VcsCreateRefResult, GitCommandError>;
@@ -319,6 +322,10 @@ export const make = Effect.gen(function* () { · 229 unmodified lines
319322 ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe(
320323 Effect.andThen(git.removeWorktree(input)),
321324 ),
325+ pruneWorktrees: (input) =>
326+ ensureGitCommand("GitWorkflowService.pruneWorktrees", input.cwd).pipe(
327+ Effect.andThen(git.pruneWorktrees(input)),
328+ ),
322329 createRef: (input) =>
323330 ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe(
324331 Effect.andThen(git.createRef(input)),
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+53−0
@@ -270,6 +270,11 @@ describe("ProviderCommandReactor", () => { · 269 unmodified lines
270270 : "renamed-branch",
271271 }),
272272 );
273+ const pruneWorktrees = vi.fn((_: { readonly cwd: string }) => Effect.void);
274+ const createWorktree = vi.fn(
275+ (input: { readonly refName: string; readonly path: string | null }) =>
276+ Effect.succeed({ worktree: { path: input.path ?? "", refName: input.refName } }),
277+ );
273278 const refreshStatus = vi.fn((_: string) =>
274279 Effect.succeed({
275280 isRepo: true,
@@ -402,6 +407,8 @@ describe("ProviderCommandReactor", () => { · 126 unmodified lines
402407 Layer.provideMerge(
403408 Layer.mock(GitWorkflowService.GitWorkflowService)({
404409 renameBranch,
410+ pruneWorktrees,
411+ createWorktree,
405412 } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>),
406413 ),
407414 Layer.provideMerge(
@@ -506,6 +513,8 @@ describe("ProviderCommandReactor", () => { · 98 unmodified lines
506513 respondToUserInput,
507514 stopSession,
508515 renameBranch,
516+ pruneWorktrees,
517+ createWorktree,
509518 refreshStatus,
510519 generateBranchName,
511520 generateThreadTitle,
@@ -1517,6 +1526,50 @@ describe("ProviderCommandReactor", () => { · 1005 unmodified lines
15171526 expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree");
15181527 });
15191528
1529+ it("recreates a missing worktree from the thread branch before starting a turn", async () => {
1530+ const harness = await createHarness();
1531+ const now = "2026-01-01T00:00:00.000Z";
1532+ const worktreePath = NodePath.join(harness.stateDir, "missing-worktree");
1533+
1534+ await harness.runEffect(
1535+ harness.engine.dispatch({
1536+ type: "thread.meta.update",
1537+ commandId: CommandId.make("cmd-thread-missing-worktree"),
1538+ threadId: ThreadId.make("thread-1"),
1539+ branch: "feature/restore",
1540+ worktreePath,
1541+ }),
1542+ );
1543+
1544+ await harness.runEffect(
1545+ harness.engine.dispatch({
1546+ type: "thread.turn.start",
1547+ commandId: CommandId.make("cmd-turn-start-missing-worktree"),
1548+ threadId: ThreadId.make("thread-1"),
1549+ message: {
1550+ messageId: asMessageId("user-message-missing-worktree"),
1551+ role: "user",
1552+ text: "continue",
1553+ attachments: [],
1554+ },
1555+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1556+ runtimeMode: "approval-required",
1557+ createdAt: now,
1558+ }),
1559+ );
1560+
1561+ await waitFor(() => harness.startSession.mock.calls.length === 1);
1562+ expect(harness.pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-project" });
1563+ expect(harness.createWorktree).toHaveBeenCalledWith({
1564+ cwd: "/tmp/provider-project",
1565+ refName: "feature/restore",
1566+ path: worktreePath,
1567+ });
1568+ expect(harness.createWorktree.mock.invocationCallOrder[0]).toBeLessThan(
1569+ harness.startSession.mock.invocationCallOrder[0]!,
1570+ );
1571+ });
1572+
15201573 it("forwards codex model options through session start and turn send", async () => {
15211574 const harness = await createHarness();
15221575 const now = "2026-01-01T00:00:00.000Z";
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+50−0
@@ -19,6 +19,7 @@ import * as Crypto from "effect/Crypto"; · 18 unmodified lines
1919 import * as Duration from "effect/Duration";
2020 import * as Effect from "effect/Effect";
2121 import * as Equal from "effect/Equal";
22+import * as FileSystem from "effect/FileSystem";
2223 import * as Layer from "effect/Layer";
2324 import * as Option from "effect/Option";
2425 import * as Schema from "effect/Schema";
@@ -305,6 +306,7 @@ const make = Effect.gen(function* () { · 280 unmodified lines
305306 const providerService = yield* ProviderService;
306307 const providerRegistry = yield* ProviderRegistry;
307308 const gitWorkflow = yield* GitWorkflowService;
309+ const fileSystem = yield* FileSystem.FileSystem;
308310 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
309311 const textGeneration = yield* TextGeneration;
310312 const serverSettingsService = yield* ServerSettingsService;
@@ -428,6 +430,52 @@ const make = Effect.gen(function* () { · 117 unmodified lines
428430 .pipe(Effect.map(Option.getOrUndefined));
429431 });
430432
433+ /**
434+ * Recreates a thread's worktree from its branch when the directory has
435+ * disappeared. Provider sessions resume into the persisted cwd, so a missing
436+ * worktree makes every later turn fail as a bogus "session not found".
437+ * Best-effort: on failure the turn proceeds and reports the real error.
438+ */
439+ const ensureThreadWorktree = Effect.fnUntraced(function* (thread: {
440+ readonly id: ThreadId;
441+ readonly projectId: ProjectId;
442+ readonly branch: string | null;
443+ readonly worktreePath: string | null;
444+ }) {
445+ const { worktreePath, branch } = thread;
446+ if (!worktreePath || !branch) {
447+ return;
448+ }
449+ const exists = yield* fileSystem.exists(worktreePath).pipe(Effect.orElseSucceed(() => true));
450+ if (exists) {
451+ return;
452+ }
453+ const project = yield* resolveProject(thread.projectId);
454+ if (!project) {
455+ return;
456+ }
457+ const cwd = project.workspaceRoot;
458+ yield* Effect.logWarning("provider command reactor recreating missing worktree", {
459+ threadId: thread.id,
460+ worktreePath,
461+ branch,
462+ });
463+ // A directory deleted without `git worktree remove` leaves an admin entry
464+ // that makes `git worktree add` refuse the path; prune clears it.
465+ yield* gitWorkflow.pruneWorktrees({ cwd }).pipe(
466+ Effect.andThen(gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath })),
467+ Effect.catchCause((cause) =>
468+ Cause.hasInterruptsOnly(cause)
469+ ? Effect.failCause(cause)
470+ : Effect.logWarning("provider command reactor failed to recreate worktree", {
471+ threadId: thread.id,
472+ worktreePath,
473+ cause: Cause.pretty(cause),
474+ }),
475+ ),
476+ );
477+ });
478+
431479 const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) {
432480 return yield* projectionSnapshotQuery
433481 .getThreadDetailById(threadId)
@@ -1083,6 +1131,8 @@ const make = Effect.gen(function* () { · 649 unmodified lines
10831131 return;
10841132 }
10851133
1134+ yield* ensureThreadWorktree(thread);
1135+
10861136 const isFirstUserMessageTurn =
10871137 thread.messages.filter((entry) => entry.role === "user").length === 1;
10881138 if (isFirstUserMessageTurn) {
apps/server/src/vcs/GitVcsDriver.ts+4−0
@@ -310,6 +310,10 @@ export class GitVcsDriver extends Context.Service< · 309 unmodified lines
310310 readonly removeWorktree: (
311311 input: VcsRemoveWorktreeInput,
312312 ) => Effect.Effect<void, GitCommandError>;
313+ /** Drops worktree admin entries whose directory is already gone (`git worktree prune`). */
314+ readonly pruneWorktrees: (input: {
315+ readonly cwd: string;
316+ }) => Effect.Effect<void, GitCommandError>;
313317 readonly renameBranch: (
314318 input: GitRenameBranchInput,
315319 ) => Effect.Effect<GitRenameBranchResult, GitCommandError>;
apps/server/src/vcs/GitVcsDriverCore.ts+10−0
@@ -3017,6 +3017,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 3016 unmodified lines
30173017 });
30183018 });
30193019
3020+ const pruneWorktrees: GitVcsDriver.GitVcsDriver["Service"]["pruneWorktrees"] = Effect.fn(
3021+ "pruneWorktrees",
3022+ )(function* (input) {
3023+ yield* executeGit("GitVcsDriver.pruneWorktrees", input.cwd, ["worktree", "prune"], {
3024+ timeoutMs: 15_000,
3025+ fallbackErrorDetail: "git worktree prune failed",
3026+ });
3027+ });
3028+
30203029 const renameBranch: GitVcsDriver.GitVcsDriver["Service"]["renameBranch"] = Effect.fn(
30213030 "renameBranch",
30223031 )(function* (input) {
@@ -3222,6 +3231,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 199 unmodified lines
32223231 withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)),
32233232 setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)),
32243233 removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)),
3234+ pruneWorktrees: (input) => withListRefsInvalidation(input.cwd, pruneWorktrees(input)),
32253235 renameBranch: (input) => withListRefsInvalidation(input.cwd, renameBranch(input)),
32263236 createRef: (input) => withListRefsInvalidation(input.cwd, createRef(input)),
32273237 switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)),