diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index da22794951fb..a73aa59d5516 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -84,6 +84,9 @@ export class GitWorkflowService extends Context.Service< readonly removeWorktree: ( input: VcsRemoveWorktreeInput, ) => Effect.Effect; + readonly pruneWorktrees: (input: { + readonly cwd: string; + }) => Effect.Effect; readonly createRef: ( input: VcsCreateRefInput, ) => Effect.Effect; @@ -319,6 +322,10 @@ export const make = Effect.gen(function* () { ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( Effect.andThen(git.removeWorktree(input)), ), + pruneWorktrees: (input) => + ensureGitCommand("GitWorkflowService.pruneWorktrees", input.cwd).pipe( + Effect.andThen(git.pruneWorktrees(input)), + ), createRef: (input) => ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( Effect.andThen(git.createRef(input)), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605a..e134e8c68afa 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -264,6 +264,11 @@ describe("ProviderCommandReactor", () => { : "renamed-branch", }), ); + const pruneWorktrees = vi.fn((_: { readonly cwd: string }) => Effect.void); + const createWorktree = vi.fn( + (input: { readonly refName: string; readonly path: string | null }) => + Effect.succeed({ worktree: { path: input.path ?? "", refName: input.refName } }), + ); const refreshStatus = vi.fn((_: string) => Effect.succeed({ isRepo: true, @@ -395,6 +400,8 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge( Layer.mock(GitWorkflowService.GitWorkflowService)({ renameBranch, + pruneWorktrees, + createWorktree, } satisfies Partial), ), Layer.provideMerge( @@ -499,6 +506,8 @@ describe("ProviderCommandReactor", () => { respondToUserInput, stopSession, renameBranch, + pruneWorktrees, + createWorktree, refreshStatus, generateBranchName, generateThreadTitle, @@ -1510,6 +1519,50 @@ describe("ProviderCommandReactor", () => { expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); }); + it("recreates a missing worktree from the thread branch before starting a turn", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const worktreePath = NodePath.join(harness.stateDir, "missing-worktree"); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-missing-worktree"), + threadId: ThreadId.make("thread-1"), + branch: "feature/restore", + worktreePath, + }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-missing-worktree"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-missing-worktree"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + expect(harness.pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-project" }); + expect(harness.createWorktree).toHaveBeenCalledWith({ + cwd: "/tmp/provider-project", + refName: "feature/restore", + path: worktreePath, + }); + expect(harness.createWorktree.mock.invocationCallOrder[0]).toBeLessThan( + harness.startSession.mock.invocationCallOrder[0]!, + ); + }); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index cfc95f2613fb..07abc55bd944 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -19,6 +19,7 @@ import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -305,6 +306,7 @@ const make = Effect.gen(function* () { const providerService = yield* ProviderService; const providerRegistry = yield* ProviderRegistry; const gitWorkflow = yield* GitWorkflowService; + const fileSystem = yield* FileSystem.FileSystem; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; @@ -428,6 +430,52 @@ const make = Effect.gen(function* () { .pipe(Effect.map(Option.getOrUndefined)); }); + /** + * Recreates a thread's worktree from its branch when the directory has + * disappeared. Provider sessions resume into the persisted cwd, so a missing + * worktree makes every later turn fail as a bogus "session not found". + * Best-effort: on failure the turn proceeds and reports the real error. + */ + const ensureThreadWorktree = Effect.fnUntraced(function* (thread: { + readonly id: ThreadId; + readonly projectId: ProjectId; + readonly branch: string | null; + readonly worktreePath: string | null; + }) { + const { worktreePath, branch } = thread; + if (!worktreePath || !branch) { + return; + } + const exists = yield* fileSystem.exists(worktreePath).pipe(Effect.orElseSucceed(() => true)); + if (exists) { + return; + } + const project = yield* resolveProject(thread.projectId); + if (!project) { + return; + } + const cwd = project.workspaceRoot; + yield* Effect.logWarning("provider command reactor recreating missing worktree", { + threadId: thread.id, + worktreePath, + branch, + }); + // A directory deleted without `git worktree remove` leaves an admin entry + // that makes `git worktree add` refuse the path; prune clears it. + yield* gitWorkflow.pruneWorktrees({ cwd }).pipe( + Effect.andThen(gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath })), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("provider command reactor failed to recreate worktree", { + threadId: thread.id, + worktreePath, + cause: Cause.pretty(cause), + }), + ), + ); + }); + const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { return yield* projectionSnapshotQuery .getThreadDetailById(threadId) @@ -1083,6 +1131,8 @@ const make = Effect.gen(function* () { return; } + yield* ensureThreadWorktree(thread); + const isFirstUserMessageTurn = thread.messages.filter((entry) => entry.role === "user").length === 1; if (isFirstUserMessageTurn) { diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index b9ef992122ae..d83a380e43d6 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -306,6 +306,10 @@ export class GitVcsDriver extends Context.Service< readonly removeWorktree: ( input: VcsRemoveWorktreeInput, ) => Effect.Effect; + /** Drops worktree admin entries whose directory is already gone (`git worktree prune`). */ + readonly pruneWorktrees: (input: { + readonly cwd: string; + }) => Effect.Effect; readonly renameBranch: ( input: GitRenameBranchInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index cd16c70291a4..9aca81a5a349 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2993,6 +2993,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); }); + const pruneWorktrees: GitVcsDriver.GitVcsDriver["Service"]["pruneWorktrees"] = Effect.fn( + "pruneWorktrees", + )(function* (input) { + yield* executeGit("GitVcsDriver.pruneWorktrees", input.cwd, ["worktree", "prune"], { + timeoutMs: 15_000, + fallbackErrorDetail: "git worktree prune failed", + }); + }); + const renameBranch: GitVcsDriver.GitVcsDriver["Service"]["renameBranch"] = Effect.fn( "renameBranch", )(function* (input) { @@ -3197,6 +3206,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)), setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)), removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)), + pruneWorktrees: (input) => withListRefsInvalidation(input.cwd, pruneWorktrees(input)), renameBranch: (input) => withListRefsInvalidation(input.cwd, renameBranch(input)), createRef: (input) => withListRefsInvalidation(input.cwd, createRef(input)), switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)),