authorgravatar for smithmackinley@gmail.comMacKinley Smith <smithmackinley@gmail.com> 2026-08-24 04:48:33-06:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 03:48:33-07:00
log01fc7d228dd6794ffb35b57752d0218559de5e89
tree35e3cbec18e8a7ea3f51e19c8fc01184d7962907
parent17822fab708a90e82ac961e3b4895932667ce419
signature Signed by PGP key B5690EEEBB952194

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


5 files changed, 124 insertions(+), 0 deletions(-)

apps/server/src/git/GitWorkflowService.ts+7
......@@ -84,6 +84,9 @@ export class GitWorkflowService extends Context.Service<
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* () {
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
......@@ -270,6 +270,11 @@ describe("ProviderCommandReactor", () => {
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", () => {
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", () => {
506513 respondToUserInput,
507514 stopSession,
508515 renameBranch,
516 pruneWorktrees,
517 createWorktree,
509518 refreshStatus,
510519 generateBranchName,
511520 generateThreadTitle,
......@@ -1517,6 +1526,50 @@ describe("ProviderCommandReactor", () => {
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
......@@ -19,6 +19,7 @@ import * as Crypto from "effect/Crypto";
1919import * as Duration from "effect/Duration";
2020import * as Effect from "effect/Effect";
2121import * as Equal from "effect/Equal";
22import * as FileSystem from "effect/FileSystem";
2223import * as Layer from "effect/Layer";
2324import * as Option from "effect/Option";
2425import * as Schema from "effect/Schema";
......@@ -305,6 +306,7 @@ const make = Effect.gen(function* () {
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* () {
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* () {
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
......@@ -310,6 +310,10 @@ export class GitVcsDriver extends Context.Service<
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
......@@ -3017,6 +3017,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
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*
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)),