Repository navigation
fix(server): storage cleanup treats symlinked spellings of a worktree as one checkout #25735
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bdb2c88
85a2a29
5e7e83d
433d154
7d989bf
c78e85e
176b9e1
892beb3
d172548
3c8c878
1eb6a28
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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; | ||
| } | ||
| 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<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)) | ||
| ) | ||
|
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]) => | ||
| ([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])) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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,20 +1331,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, 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"); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.