From bdb2c88cac1f08c2fe54d9493776cccc3e9ccc0e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 02:13:55 +0000 Subject: [PATCH 1/6] test(server): reproduce #9245 symlinked worktree spellings splitting shared ownership Two top-level threads that store one checkout as its real path and as a path through a symlinked parent are grouped separately by storage cleanup, so the "shared by N threads" guard never fires and the checkout is removed. This test reproduces #9245 and fails until cleanup compares canonical paths. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index c05d4aed4cfbf..16bf49bad0cea 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -799,6 +799,28 @@ 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.live.each([ "running", "pending", From 85a2a2940967fa4d413164bb61ca76a4176a60b5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 02:25:00 +0000 Subject: [PATCH 2/6] fix(server): storage cleanup treats symlinked spellings of a worktree as one checkout Storage cleanup decided worktree identity with path.resolve, which does not follow symlinks. Two threads storing one checkout as its real path and through a symlinked parent landed in separate one-member groups, so the "shared by N threads" guard never fired and the checkout was removed while another thread still used it. Cleanup now groups threads, deleted threads, thread bindings, terminals and provider sessions by the checkout's real path, resolved once per path per sweep and falling back to path.resolve when the path no longer exists. Report entries, existence checks and removal still use the candidate thread's own spelling, and a worktree directory that is itself a symlink is still kept. Fixes pingdotgg/t3code#9245 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/storageCleanup.ts | 84 +++++++++++++++++++------------ 1 file changed, 52 insertions(+), 32 deletions(-) diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 526aaffa2abb3..2d801ff47c7d0 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -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 @@ -733,13 +719,41 @@ const make = Effect.gen(function* () { const snapshot = yield* readThreads(); 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. + 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)); + canonicalPaths.set(resolved, realPath); + return realPath; + }); + const hasTerminal = Effect.fnUntraced(function* (worktreeKey: string) { + for (const terminal of [...liveTerminals.values()].flatMap((entries) => [ + ...entries.values(), + ])) { + if (terminal.status !== "starting" && terminal.status !== "running") continue; + const cwd = yield* canonical(terminal.cwd); + if ( + (terminal.worktreePath !== null && + (yield* canonical(terminal.worktreePath)) === worktreeKey) || + cwd === worktreeKey || + inside(worktreeKey, 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]) => @@ -755,13 +769,14 @@ 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 group = groups.get(worktreeKey) ?? []; const activityAt = Math.max(...group.map(storageCleanupActivityAt)); const deleted = "workspaceRoot" in thread; const members = deleted ? [thread, ...group] : group; @@ -793,7 +808,7 @@ 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(worktreeKey)) 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)); @@ -801,7 +816,7 @@ const make = Effect.gen(function* () { 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(worktreeKey, [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"); @@ -919,12 +934,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(worktreeKey, [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(worktreeKey)) return "open terminal"; if (deleted) { if ( !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) @@ -942,12 +959,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 || @@ -955,7 +975,7 @@ const make = Effect.gen(function* () { !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) ); }) @@ -971,9 +991,9 @@ 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); + const cwd = yield* canonical(session.cwd); if ( - (cwd === worktreePath || inside(worktreePath, cwd)) && + (cwd === worktreeKey || inside(worktreeKey, cwd)) && (yield* providerSessions.isLive(session.id)) ) return "provider session is still open"; From 433d154c4f56909e031490efa494aaa5e3a0826e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 02:54:02 +0000 Subject: [PATCH 3/6] fix(server): storage cleanup still matches missing project roots by their stored spelling Comparing only the worktree's real path dropped the alias-to-alias match for a project root that no longer exists, since its realPath falls back to the stored alias spelling. Check both of the worktree's spellings. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 22 +++++++++++++++++----- apps/server/src/storageCleanup.ts | 19 ++++++++++++------- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 16bf49bad0cea..e9c1bb0937924 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1313,18 +1313,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); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 2d801ff47c7d0..332600e98433e 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -321,18 +321,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; }); @@ -816,7 +819,9 @@ const make = Effect.gen(function* () { return keep("worktree is a symbolic link"); if (!roots.some((root) => inside(root, realPath))) return keep("outside the managed worktree folder"); - if (yield* containsProjectRoot(worktreeKey, [project, ...snapshot.projects])) + if ( + yield* containsProjectRoot([worktreePath, worktreeKey], [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"); @@ -934,7 +939,7 @@ 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(worktreeKey, [project, ...latestProjects])) + if (yield* containsProjectRoot([worktreePath, worktreeKey], [project, ...latestProjects])) return "contains a project checkout"; const bindings = yield* projections.getThreadWorktreePaths({ includeArchived: true }); const bindingKeys = yield* Effect.forEach(bindings, (binding) => From 7d989bf9952af202b21daf86c50a73ff45ef73b9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 02:58:12 +0000 Subject: [PATCH 4/6] fix(server): storage cleanup folds case when comparing worktree keys on Windows Canonical worktree keys skipped pathKey, so on Windows two spellings of one checkout that differ only in case still split into separate groups. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 24 ++++++++++++++++++++++++ apps/server/src/storageCleanup.ts | 6 ++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index e9c1bb0937924..bc28c97a590bf 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -821,6 +821,30 @@ describe("subagent worktree ownership", () => { }).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", diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 332600e98433e..8b9b0a26b7e83 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -723,14 +723,16 @@ const make = Effect.gen(function* () { const refreshedDefaultRefs = new Map>(); const defaultBranches = new Map(); // 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)); - canonicalPaths.set(resolved, realPath); - return realPath; + const key = pathKey(realPath); + canonicalPaths.set(resolved, key); + return key; }); const hasTerminal = Effect.fnUntraced(function* (worktreeKey: string) { for (const terminal of [...liveTerminals.values()].flatMap((entries) => [ From c78e85e576c87341f83828954c1e6b063cff9d98 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 03:10:41 +0000 Subject: [PATCH 5/6] fix(server): storage cleanup still protects live sessions in missing folders A terminal or provider-session cwd in a deleted folder cannot be resolved, so its fallback no longer matched the worktree's real path when the thread stored the alias spelling. Compare cwds against both of the worktree's spellings, as main did lexically. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 17 ++++++++++++++ apps/server/src/storageCleanup.ts | 32 +++++++++++++++----------- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index bc28c97a590bf..2609da473dd02 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1591,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* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 8b9b0a26b7e83..2729d82cc4ee3 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -734,17 +734,25 @@ const make = Effect.gen(function* () { canonicalPaths.set(resolved, key); return key; }); - const hasTerminal = Effect.fnUntraced(function* (worktreeKey: string) { + // 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; - const cwd = yield* canonical(terminal.cwd); if ( (terminal.worktreePath !== null && - (yield* canonical(terminal.worktreePath)) === worktreeKey) || - cwd === worktreeKey || - inside(worktreeKey, cwd) + (yield* occupies(worktreePaths, terminal.worktreePath))) || + (yield* occupies(worktreePaths, terminal.cwd)) ) return true; } @@ -781,6 +789,7 @@ const make = Effect.gen(function* () { if (!worktreeCleanupEnabled(settings)) continue; const worktreePath = path.resolve(thread.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; @@ -813,7 +822,7 @@ 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 (yield* hasTerminal(worktreeKey)) 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)); @@ -821,9 +830,7 @@ const make = Effect.gen(function* () { 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, worktreeKey], [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"); @@ -941,14 +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, worktreeKey], [project, ...latestProjects])) + if (yield* containsProjectRoot(worktreePaths, [project, ...latestProjects])) return "contains a project checkout"; 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(worktreeKey)) return "open terminal"; + if (yield* hasTerminal(worktreePaths)) return "open terminal"; if (deleted) { if ( !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) @@ -998,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 = yield* canonical(session.cwd); if ( - (cwd === worktreeKey || inside(worktreeKey, cwd)) && + (yield* occupies(worktreePaths, session.cwd)) && (yield* providerSessions.isLive(session.id)) ) return "provider session is still open"; From d1725485342160e5154ea7576970668fbda00a4e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:50:55 +0000 Subject: [PATCH 6/6] refactor(server): name storage cleanup group keys as canonical keys AI-assisted; verified with the tests and tools listed above. --- apps/server/src/storageCleanup.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index cd7ba3f58235c..3bc4b53136802 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -901,11 +901,11 @@ const make = Effect.gen(function* () { 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 &&