Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ export class GitWorkflowService extends Context.Service<
readonly removeWorktree: (
input: VcsRemoveWorktreeInput,
) => Effect.Effect<void, GitCommandError>;
readonly pruneWorktrees: (input: {
readonly cwd: string;
}) => Effect.Effect<void, GitCommandError>;
readonly createRef: (
input: VcsCreateRefInput,
) => Effect.Effect<VcsCreateRefResult, GitCommandError>;
Expand Down Expand Up @@ -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)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -395,6 +400,8 @@ describe("ProviderCommandReactor", () => {
Layer.provideMerge(
Layer.mock(GitWorkflowService.GitWorkflowService)({
renameBranch,
pruneWorktrees,
createWorktree,
} satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>),
),
Layer.provideMerge(
Expand Down Expand Up @@ -499,6 +506,8 @@ describe("ProviderCommandReactor", () => {
respondToUserInput,
stopSession,
renameBranch,
pruneWorktrees,
createWorktree,
refreshStatus,
generateBranchName,
generateThreadTitle,
Expand Down Expand Up @@ -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";
Expand Down
50 changes: 50 additions & 0 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,10 @@ export class GitVcsDriver extends Context.Service<
readonly removeWorktree: (
input: VcsRemoveWorktreeInput,
) => Effect.Effect<void, GitCommandError>;
/** Drops worktree admin entries whose directory is already gone (`git worktree prune`). */
readonly pruneWorktrees: (input: {
readonly cwd: string;
}) => Effect.Effect<void, GitCommandError>;
readonly renameBranch: (
input: GitRenameBranchInput,
) => Effect.Effect<GitRenameBranchResult, GitCommandError>;
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)),
Expand Down
Loading