From 1b2e43018c8305978eeb2ec67024bb46bef0ba0d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 10:37:57 +0000 Subject: [PATCH 1/2] test(server): tests reproduce issue 24390 A managed worktree folder that holds only node_modules (no .git) is not recreated at turn start, and storage cleanup fails on it with NotFound instead of keeping it as not a linked worktree. Co-Authored-By: Claude Opus 5.5 --- .../ProviderTurnStartService.test.ts | 24 +++++++++++++++++++ apps/server/src/storageCleanup.test.ts | 19 +++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 8fca16a101725..c26d35043f035 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -874,6 +874,30 @@ effectIt.effect("does not resume a continuation stopped while recovery was in fl }), ); +effectIt.effect("recreates a managed worktree folder that holds no checkout", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-start-" }); + const worktreePath = path.join(workspaceRoot, "project", "worktree"); + // What a junction-skipping `git worktree remove` leaves behind: no `.git`, no files. + yield* fileSystem.makeDirectory(path.join(worktreePath, "node_modules"), { recursive: true }); + const harness = makeLocalCommandHarness({ + text: "Continue", + worktreePath, + worktreesDir: workspaceRoot, + fileSystem, + failReadsAfterRunning: true, + }); + yield* harness.start; + expect(harness.createWorktree).toHaveBeenCalledWith({ + cwd: "/tmp/native-account-project", + refName: "feature/restore", + path: worktreePath, + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + effectIt.effect.each(["cleanup-first", "startup-first"])( "coordinates provider startup with %s", (order) => diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index b8332de492884..388628d1e0fb4 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -664,6 +664,25 @@ it.live("preserves late tracked edits when submodules were never initialized", ( ), ); +it.live("keeps a managed worktree folder left with only node_modules", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command } = yield* cleanupFixture(); + yield* fs.makeDirectory(`${worktree}/node_modules/pkg`, { recursive: true }); + for (const entry of yield* fs.readDirectory(worktree)) { + if (entry !== "node_modules") yield* fs.remove(`${worktree}/${entry}`, { recursive: true }); + } + yield* command(repo, ["worktree", "prune"]); + const report = yield* service.runNow; + expect(report.entries.find((entry) => entry.path === worktree)).toMatchObject({ + outcome: "kept", + reason: "Not a linked worktree", + }); + expect(yield* fs.exists(`${worktree}/node_modules/pkg`)).toBe(true); + }), + ), +); + describe("subagent worktree ownership", () => { it.effect.each([ "idle", From 3d76e10d01793340d68fc5bc89165dc190f4cdc6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 10:42:34 +0000 Subject: [PATCH 2/2] fix(server): a managed worktree left with only node_modules is recreated A git worktree remove that skips a junctioned node_modules (or .next) leaves the managed folder without a checkout. Turn start now clears that leftover and recreates the thread's worktree, and storage cleanup keeps such a folder as not a linked worktree instead of failing on the missing .git. Co-Authored-By: Claude Opus 5.5 --- .../ProviderTurnStartService.test.ts | 47 ++++++++++++++++--- .../ProviderTurnStartService.ts | 23 ++++++++- apps/server/src/storageCleanup.ts | 3 +- 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index c26d35043f035..d477d80c72cb4 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -874,14 +874,50 @@ effectIt.effect("does not resume a continuation stopped while recovery was in fl }), ); -effectIt.effect("recreates a managed worktree folder that holds no checkout", () => +effectIt.effect.each([["node_modules"], ["node_modules", ".next"]])( + "recreates a managed worktree folder that holds only %s", + (leftovers) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-start-" }); + const worktreePath = path.join(workspaceRoot, "project", "worktree"); + // What a junction-skipping `git worktree remove` leaves behind: no `.git`, no files. + for (const leftover of leftovers) { + yield* fileSystem.makeDirectory(path.join(worktreePath, leftover), { recursive: true }); + } + const harness = makeLocalCommandHarness({ + text: "Continue", + worktreePath, + worktreesDir: workspaceRoot, + fileSystem, + failReadsAfterRunning: true, + }); + // `git worktree add` refuses a non-empty target, so the leftover must be gone first. + harness.createWorktree.mockImplementation(() => + fileSystem.exists(worktreePath).pipe( + Effect.tap((exists) => Effect.sync(() => expect(exists).toBe(false))), + Effect.as({} as never), + Effect.orDie, + ), + ); + yield* harness.start; + expect(harness.createWorktree).toHaveBeenCalledWith({ + cwd: "/tmp/native-account-project", + refName: "feature/restore", + path: worktreePath, + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +effectIt.effect("leaves a managed worktree folder with other files untouched", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-start-" }); const worktreePath = path.join(workspaceRoot, "project", "worktree"); - // What a junction-skipping `git worktree remove` leaves behind: no `.git`, no files. yield* fileSystem.makeDirectory(path.join(worktreePath, "node_modules"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(worktreePath, "notes.md"), "keep me"); const harness = makeLocalCommandHarness({ text: "Continue", worktreePath, @@ -890,11 +926,8 @@ effectIt.effect("recreates a managed worktree folder that holds no checkout", () failReadsAfterRunning: true, }); yield* harness.start; - expect(harness.createWorktree).toHaveBeenCalledWith({ - cwd: "/tmp/native-account-project", - refName: "feature/restore", - path: worktreePath, - }); + expect(harness.createWorktree).not.toHaveBeenCalled(); + expect(yield* fileSystem.readFileString(path.join(worktreePath, "notes.md"))).toBe("keep me"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 4eb988ec43089..65abfef4678ce 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -69,6 +69,9 @@ export class ProviderTurnStartError extends Schema.TaggedError true)); - if (!exists && (yield* isManagedWorktreePath(worktreePath))) { + const managed = yield* isManagedWorktreePath(worktreePath); + // A `git worktree remove` that skips a junctioned `node_modules` leaves + // a folder with no checkout. Only that leftover is cleared, never user files. + const leftover = + exists && + managed && + !(yield* fileSystem + .exists(path.join(worktreePath, ".git")) + .pipe(Effect.orElseSucceed(() => true))) && + (yield* fileSystem.readDirectory(worktreePath).pipe( + Effect.map((entries) => entries.every((entry) => LEFTOVER_WORKTREE_ENTRIES.has(entry))), + Effect.orElseSucceed(() => false), + )); + if (managed && (!exists || leftover)) { const project = yield* projects.getById(projection.thread.projectId).pipe( Effect.map(Option.getOrUndefined), Effect.orElseSucceed(() => undefined), @@ -501,7 +517,10 @@ export const layer: Layer.Layer< worktreePath, branch, }); - yield* gitWorkflow.pruneWorktrees({ cwd: project.workspaceRoot }).pipe( + yield* ( + leftover ? fileSystem.remove(worktreePath, { recursive: true }) : Effect.void + ).pipe( + Effect.andThen(gitWorkflow.pruneWorktrees({ cwd: project.workspaceRoot })), Effect.andThen( gitWorkflow.createWorktree({ cwd: project.workspaceRoot, diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 471e78df2a3b1..52bc2185515e6 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -819,7 +819,8 @@ const make = Effect.gen(function* () { return keep("outside the managed worktree folder"); if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) return keep("contains a project checkout"); - if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") + const gitPath = path.join(worktreePath, ".git"); + if (!(yield* fs.exists(gitPath)) || (yield* fs.stat(gitPath)).type !== "File") return keep("not a linked worktree"); if (yield* branchChanged(worktreePath, thread.branch)) return keep("repository or branch changed");