Skip to content

Commit 5fc32da

Browse files
authored
Clear stale worktree paths before starting sessions (#135)
- Fall back to the project workspace when a saved worktree no longer exists - Reset stale thread worktree metadata before launching provider sessions - Add coverage for stale worktree recovery
1 parent 24d94ed commit 5fc32da

2 files changed

Lines changed: 104 additions & 4 deletions

File tree

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import crypto from "node:crypto";
12
import fs from "node:fs";
23
import os from "node:os";
34
import path from "node:path";
@@ -95,13 +96,16 @@ describe("ProviderCommandReactor", () => {
9596
async function createHarness(input?: {
9697
readonly baseDir?: string;
9798
readonly threadModel?: string;
99+
readonly worktreePath?: string | null;
98100
}) {
99101
const now = new Date().toISOString();
100102
const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "okcode-reactor-"));
101103
createdBaseDirs.add(baseDir);
102104
const { stateDir } = deriveServerPathsSync(baseDir, undefined);
103105
createdStateDirs.add(stateDir);
104106
const threadModel = input?.threadModel ?? "gpt-5-codex";
107+
const projectWorkspaceRoot = path.join(baseDir, "project");
108+
fs.mkdirSync(projectWorkspaceRoot, { recursive: true });
105109
const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
106110
let nextSessionIndex = 1;
107111
const runtimeSessions: Array<ProviderSession> = [];
@@ -244,7 +248,7 @@ describe("ProviderCommandReactor", () => {
244248
commandId: CommandId.makeUnsafe("cmd-project-create"),
245249
projectId: asProjectId("project-1"),
246250
title: "Provider Project",
247-
workspaceRoot: "/tmp/provider-project",
251+
workspaceRoot: projectWorkspaceRoot,
248252
defaultModel: threadModel,
249253
createdAt: now,
250254
}),
@@ -260,7 +264,7 @@ describe("ProviderCommandReactor", () => {
260264
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
261265
runtimeMode: "approval-required",
262266
branch: null,
263-
worktreePath: null,
267+
worktreePath: input?.worktreePath ?? null,
264268
createdAt: now,
265269
}),
266270
);
@@ -275,6 +279,7 @@ describe("ProviderCommandReactor", () => {
275279
stopSession,
276280
renameBranch,
277281
generateBranchName,
282+
projectWorkspaceRoot,
278283
stateDir,
279284
drain,
280285
};
@@ -305,7 +310,7 @@ describe("ProviderCommandReactor", () => {
305310
await waitFor(() => harness.sendTurn.mock.calls.length === 1);
306311
expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1"));
307312
expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
308-
cwd: "/tmp/provider-project",
313+
cwd: harness.projectWorkspaceRoot,
309314
model: "gpt-5-codex",
310315
runtimeMode: "approval-required",
311316
});
@@ -316,6 +321,47 @@ describe("ProviderCommandReactor", () => {
316321
expect(thread?.session?.runtimeMode).toBe("approval-required");
317322
});
318323

324+
it("falls back to the project root and clears stale worktree paths before session start", async () => {
325+
const missingWorktreePath = path.join(
326+
os.tmpdir(),
327+
`okcode-missing-worktree-${crypto.randomUUID()}`,
328+
);
329+
const harness = await createHarness({
330+
worktreePath: missingWorktreePath,
331+
});
332+
const now = new Date().toISOString();
333+
334+
await Effect.runPromise(
335+
harness.engine.dispatch({
336+
type: "thread.turn.start",
337+
commandId: CommandId.makeUnsafe("cmd-turn-start-stale-worktree"),
338+
threadId: ThreadId.makeUnsafe("thread-1"),
339+
message: {
340+
messageId: asMessageId("user-message-stale-worktree"),
341+
role: "user",
342+
text: "recover stale worktree",
343+
attachments: [],
344+
},
345+
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
346+
runtimeMode: "approval-required",
347+
createdAt: now,
348+
}),
349+
);
350+
351+
await waitFor(() => harness.startSession.mock.calls.length === 1);
352+
expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
353+
cwd: harness.projectWorkspaceRoot,
354+
model: "gpt-5-codex",
355+
runtimeMode: "approval-required",
356+
});
357+
358+
const readModel = await Effect.runPromise(harness.engine.getReadModel());
359+
const thread = readModel.threads.find(
360+
(entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
361+
);
362+
expect(thread?.worktreePath).toBeNull();
363+
});
364+
319365
it("forwards codex model options through session start and turn send", async () => {
320366
const harness = await createHarness();
321367
const now = new Date().toISOString();

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import fs from "node:fs";
2+
13
import {
24
type ChatAttachment,
35
CommandId,
@@ -140,6 +142,44 @@ function buildGeneratedWorktreeBranchName(raw: string): string {
140142
return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`;
141143
}
142144

145+
function resolveSessionCwd(input: {
146+
readonly thread: {
147+
readonly id: ThreadId;
148+
readonly projectId: string;
149+
readonly worktreePath: string | null;
150+
};
151+
readonly projects: ReadonlyArray<{
152+
readonly id: string;
153+
readonly workspaceRoot: string;
154+
}>;
155+
}): {
156+
readonly cwd: string | undefined;
157+
readonly staleWorktreePath: string | null;
158+
} {
159+
const worktreePath = input.thread.worktreePath;
160+
if (worktreePath) {
161+
if (fs.existsSync(worktreePath)) {
162+
return {
163+
cwd: worktreePath,
164+
staleWorktreePath: null,
165+
};
166+
}
167+
168+
const workspaceRoot = input.projects.find(
169+
(project) => project.id === input.thread.projectId,
170+
)?.workspaceRoot;
171+
return {
172+
cwd: workspaceRoot,
173+
staleWorktreePath: worktreePath,
174+
};
175+
}
176+
177+
return {
178+
cwd: resolveThreadWorkspaceCwd(input),
179+
staleWorktreePath: null,
180+
};
181+
}
182+
143183
const make = Effect.gen(function* () {
144184
const orchestrationEngine = yield* OrchestrationEngineService;
145185
const providerService = yield* ProviderService;
@@ -254,11 +294,25 @@ const make = Effect.gen(function* () {
254294
}
255295
const preferredProvider: ProviderKind = currentProvider ?? threadProvider;
256296
const desiredModel = options?.model ?? thread.model;
257-
const effectiveCwd = resolveThreadWorkspaceCwd({
297+
const { cwd: effectiveCwd, staleWorktreePath } = resolveSessionCwd({
258298
thread,
259299
projects: readModel.projects,
260300
});
261301

302+
if (staleWorktreePath) {
303+
yield* Effect.logWarning("provider command reactor clearing stale worktree path", {
304+
threadId,
305+
staleWorktreePath,
306+
fallbackCwd: effectiveCwd ?? null,
307+
});
308+
yield* orchestrationEngine.dispatch({
309+
type: "thread.meta.update",
310+
commandId: serverCommandId("clear-stale-worktree-path"),
311+
threadId,
312+
worktreePath: null,
313+
});
314+
}
315+
262316
const resolveActiveSession = (threadId: ThreadId) =>
263317
providerService
264318
.listSessions()

0 commit comments

Comments
 (0)