diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 1bbaa450636e2..03f35f0a93cde 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1212,6 +1212,52 @@ describe("subagent worktree ownership", () => { }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), ); + it.effect("keeps a checkout two top-level threads store through a symlinked parent", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeSettledAfterDays: 8, + }); + const linkedParent = `${yield* fixture.fs.makeTempDirectoryScoped({ prefix: "cleanup-link-" })}/worktrees`; + yield* fixture.fs.symlink(fixture.config.worktreesDir, linkedParent); + const settled = { branch: "feature", settledAt: at(-20 * DAY_MS) }; + fixture.setThreads([ + shell({ ...settled, worktreePath: fixture.worktree }), + shell({ ...settled, id: ThreadId.make("other"), worktreePath: `${linkedParent}/feature` }), + ]); + const report = yield* fixture.service.runNow; + expect(report.entries).toEqual([ + expect.objectContaining({ outcome: "kept", reason: "Shared by 2 threads" }), + ]); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(true); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect("keeps a checkout two top-level threads store in different case on Windows", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeSettledAfterDays: 8, + }).pipe(Effect.provideService(HostProcess.Platform, "win32")); + const settled = { branch: "feature", settledAt: at(-20 * DAY_MS) }; + fixture.setThreads([ + shell({ ...settled, worktreePath: fixture.worktree }), + shell({ + ...settled, + id: ThreadId.make("other"), + worktreePath: fixture.worktree.toUpperCase(), + }), + ]); + const report = yield* fixture.service.runNow; + expect(report.entries).toEqual([ + expect.objectContaining({ outcome: "kept", reason: "Shared by 2 threads" }), + ]); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(true); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + it.live.each([ "running", "pending", @@ -2299,18 +2345,30 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); - it.live.each(["before-check", "while-measuring"] as const)( - "keeps a worktree protected by a missing project root (%s)", - (timing) => + it.live.each([ + ["before-check", "stored"], + ["while-measuring", "stored"], + ["before-check", "symlinked parent"], + ["while-measuring", "symlinked parent"], + ] as const)( + "keeps a worktree protected by a missing project root (%s, %s spelling)", + ([timing, spelling]) => runCleanupTest( Effect.gen(function* () { const fixture = yield* pausedMeasurementFixture(); + let worktree = fixture.worktree; + if (spelling === "symlinked parent") { + const linkedParent = `${yield* fixture.fs.makeTempDirectoryScoped({ prefix: "cleanup-link-" })}/worktrees`; + yield* fixture.fs.symlink(fixture.config.worktreesDir, linkedParent); + worktree = `${linkedParent}/feature`; + fixture.setThreads([shell({ branch: "feature", worktreePath: worktree })]); + } if (timing === "before-check") - fixture.setProjectRoots([fixture.repo, `${fixture.worktree}/removed-checkout`]); + fixture.setProjectRoots([fixture.repo, `${worktree}/removed-checkout`]); const cleanup = yield* fixture.service.runNow.pipe(Effect.forkChild); if (timing === "while-measuring") { yield* Deferred.await(fixture.measuring); - fixture.setProjectRoots([fixture.repo, `${fixture.worktree}/removed-checkout`]); + fixture.setProjectRoots([fixture.repo, `${worktree}/removed-checkout`]); } yield* Deferred.succeed(fixture.resume, undefined); const report = yield* Fiber.join(cleanup); @@ -2615,6 +2673,23 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live("keeps a worktree for a live session in a missing folder under its stored alias", () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture(); + const linkedParent = `${yield* fixture.fs.makeTempDirectoryScoped({ prefix: "cleanup-link-" })}/worktrees`; + yield* fixture.fs.symlink(fixture.config.worktreesDir, linkedParent); + fixture.setThreads([shell({ branch: "feature", worktreePath: `${linkedParent}/feature` })]); + fixture.setOpenSessionCwd(`${linkedParent}/feature/removed-folder`); + const report = yield* fixture.service.runNow; + expect(report.entries[0]).toMatchObject({ + outcome: "kept", + reason: "Provider session is still open", + }); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(true); + }), + ), + ); it.live("compares the checked-out branch without a full status", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 6c2935d1c59d8..11f9a91787244 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -346,20 +346,6 @@ const make = Effect.gen(function* () { !path.isAbsolute(relative) ); }; - const hasTerminal = (worktreePath: string) => - [...liveTerminals.values()] - .flatMap((entries) => [...entries.values()]) - .some((terminal) => { - if (terminal.status !== "starting" && terminal.status !== "running") return false; - const cwd = path.resolve(terminal.cwd); - return ( - (terminal.worktreePath !== null && - pathKey(terminal.worktreePath) === pathKey(worktreePath)) || - pathKey(cwd) === pathKey(worktreePath) || - inside(worktreePath, cwd) - ); - }); - // pnpm imports packages by clone or reflink when its store shares a device // with the worktree. Those files have one link but share blocks with the // store, so removing them frees almost nothing. A store on another device @@ -500,18 +486,21 @@ const make = Effect.gen(function* () { }); const containsProjectRoot = Effect.fn("StorageCleanup.containsProjectRoot")(function* ( - worktreePath: string, + worktreePaths: ReadonlyArray, projects: ReadonlyArray<{ readonly workspaceRoot: string }>, ) { + const contains = (projectPath: string) => + worktreePaths.some( + (worktreePath) => + pathKey(projectPath) === pathKey(worktreePath) || inside(worktreePath, projectPath), + ); for (const project of projects) { const projectPath = path.resolve(project.workspaceRoot); - if (pathKey(projectPath) === pathKey(worktreePath) || inside(worktreePath, projectPath)) - return true; + if (contains(projectPath)) return true; const realPath = yield* fs .realPath(projectPath) .pipe(Effect.orElseSucceed(() => projectPath)); - if (pathKey(realPath) === pathKey(worktreePath) || inside(worktreePath, realPath)) - return true; + if (contains(realPath)) return true; } return false; }); @@ -1040,20 +1029,58 @@ const make = Effect.gen(function* () { const threadsById = new Map(snapshot.threads.map((thread) => [thread.id, thread])); const refreshedDefaultRefs = new Map>(); const defaultBranches = new Map(); - const groups = Map.groupBy( - snapshot.threads.filter((thread) => thread.worktreePath !== null), - (thread) => path.resolve(thread.worktreePath!), + // Threads may store one checkout through different symlinked spellings; compare real paths. + // Keys are only compared, never opened, so they take pathKey's Windows case folding. + const canonicalPaths = new Map(); + const canonical = Effect.fnUntraced(function* (target: string) { + const resolved = path.resolve(target); + const cached = canonicalPaths.get(resolved); + if (cached !== undefined) return cached; + const realPath = yield* fs.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)); + const key = pathKey(realPath); + canonicalPaths.set(resolved, key); + return key; + }); + // A cwd in a deleted folder cannot be resolved, so stored spellings are compared too. + const occupies = Effect.fnUntraced(function* ( + worktreePaths: ReadonlyArray, + target: string, + ) { + const targets = [path.resolve(target), yield* canonical(target)]; + return worktreePaths.some((worktree) => + targets.some((cwd) => pathKey(cwd) === pathKey(worktree) || inside(worktree, cwd)), + ); + }); + const hasTerminal = Effect.fnUntraced(function* (worktreePaths: ReadonlyArray) { + for (const terminal of [...liveTerminals.values()].flatMap((entries) => [ + ...entries.values(), + ])) { + if (terminal.status !== "starting" && terminal.status !== "running") continue; + if ( + (terminal.worktreePath !== null && + (yield* occupies(worktreePaths, terminal.worktreePath))) || + (yield* occupies(worktreePaths, terminal.cwd)) + ) + return true; + } + return false; + }); + const liveThreads = snapshot.threads.filter((thread) => thread.worktreePath !== null); + const liveKeys = yield* Effect.forEach(liveThreads, (thread) => + canonical(thread.worktreePath!), ); - const deletedByPath = Map.groupBy(deletedThreads, (thread) => - path.resolve(thread.worktreePath!), + const groups = Map.groupBy(liveThreads, (_thread, index) => liveKeys[index]!); + const deletedKeys = yield* Effect.forEach(deletedThreads, (thread) => + canonical(thread.worktreePath!), ); + const deletedByPath = Map.groupBy(deletedThreads, (_thread, index) => deletedKeys[index]!); const candidates = [ ...[...groups].map( - ([worktreePath, group]) => + ([worktreeKey, group]) => // A deleted thread still owns the worktree its remaining subagents share, // but only deletion cleanup acts on it; otherwise the live rules apply. deletedByPath - .get(worktreePath) + .get(worktreeKey) ?.find( (deleted) => resolveWorktreeCleanup(serverSettings, deleted.projectId).worktreeOnDelete && @@ -1062,13 +1089,15 @@ const make = Effect.gen(function* () { worktreeOwner(group) ?? group[0]!, ), - ...deletedThreads.filter((thread) => !groups.has(path.resolve(thread.worktreePath!))), + ...deletedThreads.filter((_thread, index) => !groups.has(deletedKeys[index]!)), ]; for (const thread of candidates) { const settings = resolveWorktreeCleanup(serverSettings, thread.projectId); if (!worktreeCleanupEnabled(settings)) continue; const worktreePath = path.resolve(thread.worktreePath!); - const group = groups.get(worktreePath) ?? []; + const worktreeKey = yield* canonical(worktreePath); + const worktreePaths = [worktreePath, worktreeKey]; + const group = groups.get(worktreeKey) ?? []; const activityAt = Math.max(...group.map(storageCleanupActivityAt)); const deleted = "workspaceRoot" in thread; const settlementChain = deleted @@ -1122,7 +1151,7 @@ const make = Effect.gen(function* () { return keep(waitForStop("thread is running or has pending work")); if (group.some((member) => member.branch !== thread.branch)) return keep("a subagent thread's branch differs from this worktree's branch"); - if (hasTerminal(worktreePath)) return keep(waitForStop("open terminal")); + if (yield* hasTerminal(worktreePaths)) return keep(waitForStop("open terminal")); if ( [...group, ...settlementChain.slice(1)].some( (member) => @@ -1168,7 +1197,7 @@ const make = Effect.gen(function* () { return keep(notDueReason); const pathReason = yield* worktreePathReason(worktreePath); if (pathReason !== null) return keep(pathReason); - if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) + if (yield* containsProjectRoot(worktreePaths, [project, ...snapshot.projects])) return keep("contains a project checkout"); if (yield* branchChanged(worktreePath, thread.branch)) return keep("repository or branch changed"); @@ -1274,12 +1303,14 @@ const make = Effect.gen(function* () { // Re-read this path's threads and sessions so new activity cancels removal; full per-worktree snapshots take seconds. const blockedSinceCheck = Effect.fnUntraced(function* () { const latestProjects = yield* projectStore.listShells(); - if (yield* containsProjectRoot(worktreePath, [project, ...latestProjects])) + if (yield* containsProjectRoot(worktreePaths, [project, ...latestProjects])) return "contains a project checkout"; - const latest = (yield* projections.getThreadWorktreePaths({ - includeArchived: true, - })).filter((entry) => path.resolve(entry.worktreePath) === worktreePath); - if (hasTerminal(worktreePath)) return waitForStop("open terminal"); + const bindings = yield* projections.getThreadWorktreePaths({ includeArchived: true }); + const bindingKeys = yield* Effect.forEach(bindings, (binding) => + canonical(binding.worktreePath), + ); + const latest = bindings.filter((_binding, index) => bindingKeys[index] === worktreeKey); + if (yield* hasTerminal(worktreePaths)) return waitForStop("open terminal"); if (deleted) { if ( !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) @@ -1300,12 +1331,15 @@ const make = Effect.gen(function* () { projections.getThreadShell(entry.threadId), ); const latestGroup = latestShells.filter((shell) => shell !== null); + const latestKeys = yield* Effect.forEach(latestGroup, (shell) => + shell.worktreePath === null ? Effect.succeed(null) : canonical(shell.worktreePath), + ); const latestOwner = worktreeOwner(deleted ? [thread, ...latestGroup] : latestGroup); if ( latestGroup.length !== group.length || latestOwner?.id !== thread.id || latestOwner?.branch !== thread.branch || - latestGroup.some((shell) => { + latestGroup.some((shell, index) => { const previous = group.find((member) => member.id === shell.id); return ( previous === undefined || @@ -1313,7 +1347,7 @@ const make = Effect.gen(function* () { !Equal.equals(shell.settledAt, previous.settledAt) || (shell.settledAt !== null && !Equal.equals(shell.updatedAt, previous.updatedAt)) || !storageCleanupThreadIdle(shell, now, serverSettings) || - path.resolve(shell.worktreePath!) !== worktreePath || + latestKeys[index] !== worktreeKey || storageCleanupActivityAt(shell) !== storageCleanupActivityAt(previous) ); }) @@ -1350,9 +1384,8 @@ const make = Effect.gen(function* () { ); // Ready persisted sessions may have released their runtime; only live sessions protect the path. for (const session of sessions) { - const cwd = path.resolve(session.cwd); if ( - (pathKey(cwd) === pathKey(worktreePath) || inside(worktreePath, cwd)) && + (yield* occupies(worktreePaths, session.cwd)) && (yield* providerSessions.isLive(session.id)) ) return waitForStop("provider session is still open");