diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 8fca16a101725..d477d80c72cb4 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -874,6 +874,63 @@ effectIt.effect("does not resume a continuation stopped while recovery was in fl }), ); +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"); + 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, + worktreesDir: workspaceRoot, + fileSystem, + failReadsAfterRunning: true, + }); + yield* harness.start; + expect(harness.createWorktree).not.toHaveBeenCalled(); + expect(yield* fileSystem.readFileString(path.join(worktreePath, "notes.md"))).toBe("keep me"); + }).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/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.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", 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");