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<...@@ -84,6 +84,9 @@ export class GitWorkflowService extends Context.Service<
84 readonly removeWorktree: (84 readonly removeWorktree: (
85 input: VcsRemoveWorktreeInput,85 input: VcsRemoveWorktreeInput,
86 ) => Effect.Effect<void, GitCommandError>;86 ) => Effect.Effect<void, GitCommandError>;
87 readonly pruneWorktrees: (input: {
88 readonly cwd: string;
89 }) => Effect.Effect<void, GitCommandError>;
87 readonly createRef: (90 readonly createRef: (
88 input: VcsCreateRefInput,91 input: VcsCreateRefInput,
89 ) => Effect.Effect<VcsCreateRefResult, GitCommandError>;92 ) => Effect.Effect<VcsCreateRefResult, GitCommandError>;
...@@ -319,6 +322,10 @@ export const make = Effect.gen(function* () {...@@ -319,6 +322,10 @@ export const make = Effect.gen(function* () {
319 ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe(322 ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe(
320 Effect.andThen(git.removeWorktree(input)),323 Effect.andThen(git.removeWorktree(input)),
321 ),324 ),
325 pruneWorktrees: (input) =>
326 ensureGitCommand("GitWorkflowService.pruneWorktrees", input.cwd).pipe(
327 Effect.andThen(git.pruneWorktrees(input)),
328 ),
322 createRef: (input) =>329 createRef: (input) =>
323 ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe(330 ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe(
324 Effect.andThen(git.createRef(input)),331 Effect.andThen(git.createRef(input)),
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+53
...@@ -270,6 +270,11 @@ describe("ProviderCommandReactor", () => {...@@ -270,6 +270,11 @@ describe("ProviderCommandReactor", () => {
270 : "renamed-branch",270 : "renamed-branch",
271 }),271 }),
272 );272 );
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 );
273 const refreshStatus = vi.fn((_: string) =>278 const refreshStatus = vi.fn((_: string) =>
274 Effect.succeed({279 Effect.succeed({
275 isRepo: true,280 isRepo: true,
...@@ -402,6 +407,8 @@ describe("ProviderCommandReactor", () => {...@@ -402,6 +407,8 @@ describe("ProviderCommandReactor", () => {
402 Layer.provideMerge(407 Layer.provideMerge(
403 Layer.mock(GitWorkflowService.GitWorkflowService)({408 Layer.mock(GitWorkflowService.GitWorkflowService)({
404 renameBranch,409 renameBranch,
410 pruneWorktrees,
411 createWorktree,
405 } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>),412 } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>),
406 ),413 ),
407 Layer.provideMerge(414 Layer.provideMerge(
...@@ -506,6 +513,8 @@ describe("ProviderCommandReactor", () => {...@@ -506,6 +513,8 @@ describe("ProviderCommandReactor", () => {
506 respondToUserInput,513 respondToUserInput,
507 stopSession,514 stopSession,
508 renameBranch,515 renameBranch,
516 pruneWorktrees,
517 createWorktree,
509 refreshStatus,518 refreshStatus,
510 generateBranchName,519 generateBranchName,
511 generateThreadTitle,520 generateThreadTitle,
...@@ -1517,6 +1526,50 @@ describe("ProviderCommandReactor", () => {...@@ -1517,6 +1526,50 @@ describe("ProviderCommandReactor", () => {
1517 expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree");1526 expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree");
1518 });1527 });
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
1520 it("forwards codex model options through session start and turn send", async () => {1573 it("forwards codex model options through session start and turn send", async () => {
1521 const harness = await createHarness();1574 const harness = await createHarness();
1522 const now = "2026-01-01T00:00:00.000Z";1575 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";...@@ -19,6 +19,7 @@ import * as Crypto from "effect/Crypto";
19import * as Duration from "effect/Duration";19import * as Duration from "effect/Duration";
20import * as Effect from "effect/Effect";20import * as Effect from "effect/Effect";
21import * as Equal from "effect/Equal";21import * as Equal from "effect/Equal";
22import * as FileSystem from "effect/FileSystem";
22import * as Layer from "effect/Layer";23import * as Layer from "effect/Layer";
23import * as Option from "effect/Option";24import * as Option from "effect/Option";
24import * as Schema from "effect/Schema";25import * as Schema from "effect/Schema";
...@@ -305,6 +306,7 @@ const make = Effect.gen(function* () {...@@ -305,6 +306,7 @@ const make = Effect.gen(function* () {
305 const providerService = yield* ProviderService;306 const providerService = yield* ProviderService;
306 const providerRegistry = yield* ProviderRegistry;307 const providerRegistry = yield* ProviderRegistry;
307 const gitWorkflow = yield* GitWorkflowService;308 const gitWorkflow = yield* GitWorkflowService;
309 const fileSystem = yield* FileSystem.FileSystem;
308 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;310 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
309 const textGeneration = yield* TextGeneration;311 const textGeneration = yield* TextGeneration;
310 const serverSettingsService = yield* ServerSettingsService;312 const serverSettingsService = yield* ServerSettingsService;
...@@ -428,6 +430,52 @@ const make = Effect.gen(function* () {...@@ -428,6 +430,52 @@ const make = Effect.gen(function* () {
428 .pipe(Effect.map(Option.getOrUndefined));430 .pipe(Effect.map(Option.getOrUndefined));
429 });431 });
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
431 const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) {479 const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) {
432 return yield* projectionSnapshotQuery480 return yield* projectionSnapshotQuery
433 .getThreadDetailById(threadId)481 .getThreadDetailById(threadId)
...@@ -1083,6 +1131,8 @@ const make = Effect.gen(function* () {...@@ -1083,6 +1131,8 @@ const make = Effect.gen(function* () {
1083 return;1131 return;
1084 }1132 }
10851133
1134 yield* ensureThreadWorktree(thread);
1135
1086 const isFirstUserMessageTurn =1136 const isFirstUserMessageTurn =
1087 thread.messages.filter((entry) => entry.role === "user").length === 1;1137 thread.messages.filter((entry) => entry.role === "user").length === 1;
1088 if (isFirstUserMessageTurn) {1138 if (isFirstUserMessageTurn) {
apps/server/src/vcs/GitVcsDriver.ts+4
...@@ -310,6 +310,10 @@ export class GitVcsDriver extends Context.Service<...@@ -310,6 +310,10 @@ export class GitVcsDriver extends Context.Service<
310 readonly removeWorktree: (310 readonly removeWorktree: (
311 input: VcsRemoveWorktreeInput,311 input: VcsRemoveWorktreeInput,
312 ) => Effect.Effect<void, GitCommandError>;312 ) => 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>;
313 readonly renameBranch: (317 readonly renameBranch: (
314 input: GitRenameBranchInput,318 input: GitRenameBranchInput,
315 ) => Effect.Effect<GitRenameBranchResult, GitCommandError>;319 ) => Effect.Effect<GitRenameBranchResult, GitCommandError>;
apps/server/src/vcs/GitVcsDriverCore.ts+10
...@@ -3017,6 +3017,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -3017,6 +3017,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
3017 });3017 });
3018 });3018 });
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
3020 const renameBranch: GitVcsDriver.GitVcsDriver["Service"]["renameBranch"] = Effect.fn(3029 const renameBranch: GitVcsDriver.GitVcsDriver["Service"]["renameBranch"] = Effect.fn(
3021 "renameBranch",3030 "renameBranch",
3022 )(function* (input) {3031 )(function* (input) {
...@@ -3222,6 +3231,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -3222,6 +3231,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
3222 withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)),3231 withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)),
3223 setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)),3232 setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)),
3224 removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)),3233 removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)),
3234 pruneWorktrees: (input) => withListRefsInvalidation(input.cwd, pruneWorktrees(input)),
3225 renameBranch: (input) => withListRefsInvalidation(input.cwd, renameBranch(input)),3235 renameBranch: (input) => withListRefsInvalidation(input.cwd, renameBranch(input)),
3226 createRef: (input) => withListRefsInvalidation(input.cwd, createRef(input)),3236 createRef: (input) => withListRefsInvalidation(input.cwd, createRef(input)),
3227 switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)),3237 switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)),