From 2ffa1d963aa840f0ce145af42546b8d439e4e311 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Mon, 19 Oct 2026 02:15:36 -0400 Subject: [PATCH] fix(server): storage cleanup keeps Windows worktrees with an open terminal regardless of path casing Storage cleanup compares paths through pathKey(), which lowercases on Windows, everywhere except its keep-guards. The open-terminal check and the live provider-session check compared the worktree root with plain ===, and inside() is false for equal paths, so a terminal or agent whose cwd was the worktree root in different letter case went unnoticed and the worktree could be removed under it. Compare those paths through pathKey() like the rest of the file, and cover both guards with win32/linux casing tests. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 73 +++++++++++++++++++++++++- apps/server/src/storageCleanup.ts | 6 +-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index e3870f8af0c41..e2e2876e397be 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -270,6 +270,9 @@ const cleanupFixture = ( let editBeforeRemoval = false; let openSessionCwd: string | null = null; let sessionLive = true; + let terminalListener: Parameters< + TerminalManager.TerminalManager["Service"]["subscribeMetadata"] + >[0] = () => Effect.void; let fullStatusCalls = 0; let snapshotReads = 0; let events: Stream.Stream = Stream.empty; @@ -355,7 +358,11 @@ const cleanupFixture = ( invalidateStatus: () => Effect.void, } as unknown as GitManager.GitManager["Service"]), Effect.provideService(TerminalManager.TerminalManager, { - subscribeMetadata: () => Effect.succeed(() => {}), + subscribeMetadata: (listener: typeof terminalListener) => + Effect.sync(() => { + terminalListener = listener; + return () => {}; + }), } as unknown as TerminalManager.TerminalManager["Service"]), Effect.provideService(ServerActivation, Deferred.await(activated)), ); @@ -413,6 +420,22 @@ const cleanupFixture = ( setOpenSessionCwd: (cwd: string | null) => { openSessionCwd = cwd; }, + openTerminal: (paths: { cwd: string; worktreePath: string | null }) => + terminalListener({ + type: "upsert", + terminal: { + threadId: "thread-1", + terminalId: "terminal-1", + status: "running", + pid: 1, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: false, + label: "Terminal", + updatedAt: "2026-06-10T12:00:00.000Z", + ...paths, + }, + }), fullStatusCalls: () => fullStatusCalls, snapshotReads: () => snapshotReads, activate: (domainEvents: Stream.Stream) => { @@ -1153,6 +1176,54 @@ describe("orphan worktree reports", () => { ), ); + it.live.each([ + ["win32", "cwd"], + ["win32", "worktreePath"], + ["linux", "cwd"], + ["linux", "worktreePath"], + ] as const)( + "matches a terminal at the worktree root with host casing rules (%s, %s)", + ([platform, field]) => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture().pipe( + Effect.provideService(HostProcess.Platform, platform), + ); + const upper = fixture.worktree.toUpperCase(); + yield* fixture.openTerminal( + field === "cwd" + ? { cwd: upper, worktreePath: null } + : { cwd: fixture.repo, worktreePath: upper }, + ); + const report = yield* fixture.service.runNow; + expect(report.entries[0]).toMatchObject( + platform === "win32" + ? { outcome: "kept", reason: "Open terminal" } + : { outcome: "removed" }, + ); + }), + ), + ); + + it.live.each(["win32", "linux"] as const)( + "matches a provider session at the worktree root with host casing rules (%s)", + (platform) => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture().pipe( + Effect.provideService(HostProcess.Platform, platform), + ); + fixture.setOpenSessionCwd(fixture.worktree.toUpperCase()); + const report = yield* fixture.service.runNow; + expect(report.entries[0]).toMatchObject( + platform === "win32" + ? { outcome: "kept", reason: "Provider session is still open" } + : { outcome: "removed" }, + ); + }), + ), + ); + it.live("caps orphan entries while preserving kept totals", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index d684448674282..5a90e8ce4f50c 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -241,8 +241,8 @@ const make = Effect.gen(function* () { const cwd = path.resolve(terminal.cwd); return ( (terminal.worktreePath !== null && - path.resolve(terminal.worktreePath) === worktreePath) || - cwd === worktreePath || + pathKey(terminal.worktreePath) === pathKey(worktreePath)) || + pathKey(cwd) === pathKey(worktreePath) || inside(worktreePath, cwd) ); }); @@ -797,7 +797,7 @@ const make = Effect.gen(function* () { for (const session of sessions) { const cwd = path.resolve(session.cwd); if ( - (cwd === worktreePath || inside(worktreePath, cwd)) && + (pathKey(cwd) === pathKey(worktreePath) || inside(worktreePath, cwd)) && (yield* providerSessions.isLive(session.id)) ) return "provider session is still open";