Skip to content
85 changes: 80 additions & 5 deletions apps/server/src/storageCleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,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",
Expand Down Expand Up @@ -1291,18 +1337,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);
Expand Down Expand Up @@ -1533,6 +1591,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* () {
Expand Down
107 changes: 70 additions & 37 deletions apps/server/src/storageCleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,20 +240,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 &&
path.resolve(terminal.worktreePath) === worktreePath) ||
cwd === 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
Expand Down Expand Up @@ -335,18 +321,21 @@ const make = Effect.gen(function* () {
});

const containsProjectRoot = Effect.fn("StorageCleanup.containsProjectRoot")(function* (
worktreePath: string,
worktreePaths: ReadonlyArray<string>,
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
return false;
});
Expand Down Expand Up @@ -733,13 +722,51 @@ const make = Effect.gen(function* () {
const snapshot = yield* readThreads();
const refreshedDefaultRefs = new Map<string, Set<string>>();
const defaultBranches = new Map<string, string | null>();
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<string, string>();
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<string>,
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<string>) {
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))
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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]) =>
Expand All @@ -755,13 +782,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 members = deleted ? [thread, ...group] : group;
Expand Down Expand Up @@ -793,15 +822,15 @@ const make = Effect.gen(function* () {
return keep("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("open terminal");
if (yield* hasTerminal(worktreePaths)) return keep("open terminal");
// Compare canonical roots while allowing symlinked parents, never symlinked worktree directories.
const realPath = yield* fs.realPath(worktreePath);
const realParent = yield* fs.realPath(path.dirname(worktreePath));
if (realPath !== path.join(realParent, path.basename(worktreePath)))
return keep("worktree is a symbolic link");
if (!roots.some((root) => inside(root, realPath)))
return keep("outside the managed worktree folder");
if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects]))
if (yield* containsProjectRoot(worktreePaths, [project, ...snapshot.projects]))
return keep("contains a project checkout");
if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File")
return keep("not a linked worktree");
Expand Down Expand Up @@ -919,12 +948,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 "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 "open terminal";
if (deleted) {
if (
!resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId)
Expand All @@ -942,20 +973,23 @@ 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 ||
shell.branch !== thread.branch ||
!Equal.equals(shell.settledAt, previous.settledAt) ||
(shell.settledAt !== null && !Equal.equals(shell.updatedAt, previous.updatedAt)) ||
!storageCleanupThreadIdle(shell, now) ||
path.resolve(shell.worktreePath!) !== worktreePath ||
latestKeys[index] !== worktreeKey ||
storageCleanupActivityAt(shell) !== storageCleanupActivityAt(previous)
);
})
Expand All @@ -971,9 +1005,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 (
(cwd === worktreePath || inside(worktreePath, cwd)) &&
(yield* occupies(worktreePaths, session.cwd)) &&
(yield* providerSessions.isLive(session.id))
)
return "provider session is still open";
Expand Down
Loading