From cd3a46b9ddf44d2732cafe4b8fff12c5459418e4 Mon Sep 17 00:00:00 2001 From: ashx-j Date: Fri, 30 Oct 2026 03:07:17 +0100 Subject: [PATCH 1/6] fix(server): deleting a thread and its worktree no longer fails on Windows Fixes #27018 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 48 ++++++++++++++++++++ apps/server/src/vcs/worktreeRemoval.ts | 15 +++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index cbc3efcc3b4e7..be6954a70fb93 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -3838,6 +3838,54 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("retries a Windows delete while a stopped provider still holds the worktree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "held"); + // The first deletes fail the way Windows fails them while an exiting + // provider process still has files in the worktree open. + let heldAttempts = 2; + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + remove: (target, options) => + Effect.suspend(() => + target === worktreePath && heldAttempts-- > 0 + ? Effect.fail( + PlatformError.systemError({ + _tag: "Busy", + module: "FileSystem", + method: "remove", + pathOrDescriptor: target, + }), + ) + : fileSystem.remove(target, options), + ), + }), + Effect.provide(layerServerConfig), + ); + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/held", + }); + + // The retry waits on the real clock. + yield* TestClock.withLive( + driver + .removeWorktree({ cwd, path: worktreePath, force: true }) + .pipe(Effect.provideService(HostProcess.Platform, "win32")), + ); + + assert.equal(yield* fileSystem.exists(worktreePath), false); + assert.notInclude(yield* git(cwd, ["worktree", "list", "--porcelain"]), "held"); + }), + ); + it.effect("keeps the junctions of a worktree that git refuses to remove", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/worktreeRemoval.ts b/apps/server/src/vcs/worktreeRemoval.ts index f1140cdcc3a48..753d91aaee2e4 100644 --- a/apps/server/src/vcs/worktreeRemoval.ts +++ b/apps/server/src/vcs/worktreeRemoval.ts @@ -4,10 +4,14 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Schedule from "effect/Schedule"; import type * as GitVcsDriver from "./GitVcsDriver.ts"; const INDEX_LIST_MAX_BYTES = 64 * 1024 * 1024; +// About 10 seconds of retries while a stopped provider's processes exit. +const OPEN_FILE_RETRIES = 40; +const OPEN_FILE_RETRY_DELAY = "250 millis"; /** * Git for Windows before 2.54 follows NTFS junctions; newer Git leaves them behind. @@ -142,12 +146,19 @@ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorkt ); } - // A file held open can stop the delete partway. Putting `.git` back, when - // that works, leaves a worktree that a retry can remove. + // A file held open can stop the delete partway. A deleted thread's provider + // processes keep files open for a few seconds while they exit, so the delete + // retries. Putting `.git` back after the last attempt leaves a worktree that a + // later retry can remove. const gitFileContents = yield* fileSystem .readFileString(gitFile) .pipe(Effect.mapError(() => notAWorktree)); yield* fileSystem.remove(worktreePath, { recursive: true }).pipe( + Effect.retry({ + while: (error) => error.reason._tag !== "NotFound", + times: OPEN_FILE_RETRIES, + schedule: Schedule.spaced(OPEN_FILE_RETRY_DELAY), + }), Effect.tapError(() => fileSystem.writeFileString(gitFile, gitFileContents).pipe(Effect.ignore)), Effect.mapError((cause) => refuse(`failed to delete '${worktreePath}'`, cause)), ); From ee21052eeab76382c68b706fab44a6cd87903fd7 Mon Sep 17 00:00:00 2001 From: ashx-j Date: Fri, 30 Oct 2026 11:52:17 +0100 Subject: [PATCH 2/6] fix(server): finish a worktree delete when the folder is already gone Only forced deletes retry, and an interrupted delete puts .git back. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 32 ++++++++++++-------- apps/server/src/vcs/worktreeRemoval.ts | 15 +++++---- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index be6954a70fb93..1769b379d4408 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -3846,24 +3846,30 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const fileSystem = yield* FileSystem.FileSystem; const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "held"); // The first deletes fail the way Windows fails them while an exiting - // provider process still has files in the worktree open. + // provider process still has files in the worktree open. The last one + // deletes the folder anyway, so the next attempt finds nothing left. let heldAttempts = 2; const driver = yield* makeGitVcsDriverCore().pipe( Effect.provideService(FileSystem.FileSystem, { ...fileSystem, remove: (target, options) => - Effect.suspend(() => - target === worktreePath && heldAttempts-- > 0 - ? Effect.fail( - PlatformError.systemError({ - _tag: "Busy", - module: "FileSystem", - method: "remove", - pathOrDescriptor: target, - }), - ) - : fileSystem.remove(target, options), - ), + Effect.suspend(() => { + if (target !== worktreePath || heldAttempts === 0) { + return fileSystem.remove(target, options); + } + heldAttempts--; + const held = Effect.fail( + PlatformError.systemError({ + _tag: "Busy", + module: "FileSystem", + method: "remove", + pathOrDescriptor: target, + }), + ); + return heldAttempts === 0 + ? fileSystem.remove(target, options).pipe(Effect.andThen(held)) + : held; + }), }), Effect.provide(layerServerConfig), ); diff --git a/apps/server/src/vcs/worktreeRemoval.ts b/apps/server/src/vcs/worktreeRemoval.ts index 753d91aaee2e4..4cb2a060a480d 100644 --- a/apps/server/src/vcs/worktreeRemoval.ts +++ b/apps/server/src/vcs/worktreeRemoval.ts @@ -146,20 +146,19 @@ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorkt ); } - // A file held open can stop the delete partway. A deleted thread's provider - // processes keep files open for a few seconds while they exit, so the delete - // retries. Putting `.git` back after the last attempt leaves a worktree that a - // later retry can remove. + // A file held open can stop the delete partway, e.g. while a deleted thread's + // provider exits. Only a forced delete retries, since it already discards + // edits. Putting `.git` back after a failure or interruption leaves a + // worktree that a later retry can remove. const gitFileContents = yield* fileSystem .readFileString(gitFile) .pipe(Effect.mapError(() => notAWorktree)); - yield* fileSystem.remove(worktreePath, { recursive: true }).pipe( + yield* fileSystem.remove(worktreePath, { recursive: true, force: true }).pipe( Effect.retry({ - while: (error) => error.reason._tag !== "NotFound", - times: OPEN_FILE_RETRIES, + times: input.force ? OPEN_FILE_RETRIES : 0, schedule: Schedule.spaced(OPEN_FILE_RETRY_DELAY), }), - Effect.tapError(() => fileSystem.writeFileString(gitFile, gitFileContents).pipe(Effect.ignore)), + Effect.onError(() => fileSystem.writeFileString(gitFile, gitFileContents).pipe(Effect.ignore)), Effect.mapError((cause) => refuse(`failed to delete '${worktreePath}'`, cause)), ); // The folder is gone, so Git only drops the worktree's metadata. From 3915c64b5f677881bc6f54cefa9df4dab0074716 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 01:08:59 +0000 Subject: [PATCH 3/6] test(server): cover worktree retries with the test clock --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 114 +++++++++++-------- apps/server/src/vcs/worktreeRemoval.ts | 6 +- 2 files changed, 66 insertions(+), 54 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index af58d31ddf940..0cfb3c57fb717 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -4467,58 +4467,72 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("retries a Windows delete while a stopped provider still holds the worktree", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - const pathService = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "held"); - // The first deletes fail the way Windows fails them while an exiting - // provider process still has files in the worktree open. The last one - // deletes the folder anyway, so the next attempt finds nothing left. - let heldAttempts = 2; - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fileSystem, - remove: (target, options) => - Effect.suspend(() => { - if (target !== worktreePath || heldAttempts === 0) { - return fileSystem.remove(target, options); - } - heldAttempts--; - const held = Effect.fail( - PlatformError.systemError({ - _tag: "Busy", - module: "FileSystem", - method: "remove", - pathOrDescriptor: target, - }), - ); - return heldAttempts === 0 - ? fileSystem.remove(target, options).pipe(Effect.andThen(held)) - : held; - }), - }), - Effect.provide(layerServerConfig), - ); - yield* driver.createWorktree({ - cwd, - path: worktreePath, - refName: initialBranch, - newRefName: "feature/held", - }); + it.effect.each([false, true])( + "retries a Windows delete (already removed: %s)", + (alreadyRemoved) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "held"); + // Windows can report Busy after partially removing a held worktree. + let heldAttempts = 2; + const firstAttempt = yield* Deferred.make(); + const secondAttempt = yield* Deferred.make(); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + remove: (target, options) => + Effect.suspend(() => { + if (target !== worktreePath || heldAttempts === 0) { + return fileSystem.remove(target, options); + } + heldAttempts--; + const held = Effect.fail( + PlatformError.systemError({ + _tag: "Busy", + module: "FileSystem", + method: "remove", + pathOrDescriptor: target, + }), + ); + return ( + alreadyRemoved && heldAttempts === 0 + ? fileSystem.remove(target, options) + : Effect.void + ).pipe( + Effect.andThen( + Deferred.succeed( + heldAttempts === 0 ? secondAttempt : firstAttempt, + undefined, + ), + ), + Effect.andThen(held), + ); + }), + }), + Effect.provide(layerServerConfig), + ); + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/held", + }); - // The retry waits on the real clock. - yield* TestClock.withLive( - driver + const removal = yield* driver .removeWorktree({ cwd, path: worktreePath, force: true }) - .pipe(Effect.provideService(HostProcess.Platform, "win32")), - ); - - assert.equal(yield* fileSystem.exists(worktreePath), false); - assert.notInclude(yield* git(cwd, ["worktree", "list", "--porcelain"]), "held"); - }), + .pipe(Effect.provideService(HostProcess.Platform, "win32"), Effect.forkChild); + yield* Deferred.await(firstAttempt); + yield* TestClock.adjust("250 millis"); + yield* Deferred.await(secondAttempt); + yield* TestClock.adjust("250 millis"); + yield* Fiber.join(removal); + + assert.equal(yield* fileSystem.exists(worktreePath), false); + assert.notInclude(yield* git(cwd, ["worktree", "list", "--porcelain"]), "held"); + }), ); it.effect("keeps the junctions of a worktree that git refuses to remove", () => diff --git a/apps/server/src/vcs/worktreeRemoval.ts b/apps/server/src/vcs/worktreeRemoval.ts index 4cb2a060a480d..ca468c1fd7b7c 100644 --- a/apps/server/src/vcs/worktreeRemoval.ts +++ b/apps/server/src/vcs/worktreeRemoval.ts @@ -146,10 +146,8 @@ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorkt ); } - // A file held open can stop the delete partway, e.g. while a deleted thread's - // provider exits. Only a forced delete retries, since it already discards - // edits. Putting `.git` back after a failure or interruption leaves a - // worktree that a later retry can remove. + // Only forced deletes retry: a later attempt must not discard newly written edits. + // Restore `.git` on failure or interruption so Git can retry a partial deletion. const gitFileContents = yield* fileSystem .readFileString(gitFile) .pipe(Effect.mapError(() => notAWorktree)); From 953a94c1108d0edf2058876cc9eba223047500a5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 01:16:22 +0000 Subject: [PATCH 4/6] fix(server): settle worktree deletion before interruption recovery --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 55 ++++++++++++++++++++ apps/server/src/vcs/worktreeRemoval.ts | 2 + 2 files changed, 57 insertions(+) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 0cfb3c57fb717..bf744f1462632 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -4535,6 +4535,61 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("restores Git metadata after an interrupted Windows deletion settles", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "interrupted"); + const gitFile = path.join(worktreePath, ".git"); + const started = yield* Deferred.make(); + const finished = yield* Deferred.make(); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + remove: (target, options) => + target !== worktreePath + ? fileSystem.remove(target, options) + : Effect.gen(function* () { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(finished); + return yield* PlatformError.systemError({ + _tag: "Busy", + module: "FileSystem", + method: "remove", + pathOrDescriptor: target, + }); + }), + writeFileString: (target, contents) => + Effect.sync(() => NodeFS.writeFileSync(target, contents)), + }), + Effect.provide(layerServerConfig), + ); + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/interrupted-delete", + }); + const gitContents = yield* fileSystem.readFileString(gitFile); + const removal = yield* driver + .removeWorktree({ cwd, path: worktreePath, force: true }) + .pipe(Effect.provideService(HostProcess.Platform, "win32"), Effect.forkChild); + yield* Deferred.await(started); + const cancellation = yield* Fiber.interrupt(removal).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("0 millis"); + // The native deletion can finish after its effect has been interrupted. + yield* fileSystem.remove(gitFile); + yield* Deferred.succeed(finished, undefined); + yield* Fiber.join(cancellation); + assert.equal(yield* fileSystem.readFileString(gitFile), gitContents); + assert.include(yield* git(cwd, ["worktree", "list", "--porcelain"]), worktreePath); + }), + ); + it.effect("keeps the junctions of a worktree that git refuses to remove", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/worktreeRemoval.ts b/apps/server/src/vcs/worktreeRemoval.ts index ca468c1fd7b7c..f811b3e219720 100644 --- a/apps/server/src/vcs/worktreeRemoval.ts +++ b/apps/server/src/vcs/worktreeRemoval.ts @@ -152,6 +152,8 @@ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorkt .readFileString(gitFile) .pipe(Effect.mapError(() => notAWorktree)); yield* fileSystem.remove(worktreePath, { recursive: true, force: true }).pipe( + // Node cannot cancel fs.rm; wait for its callback before restoring `.git`. + Effect.uninterruptible, Effect.retry({ times: input.force ? OPEN_FILE_RETRIES : 0, schedule: Schedule.spaced(OPEN_FILE_RETRY_DELAY), From 9c7b151f15544e6cf4ea54928c4afa1d1de905f2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:27:43 +0000 Subject: [PATCH 5/6] test(server): drop an unneeded write override from the interrupted delete test AI-assisted; verified with the tests and tools listed above. --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 1ccf30959b909..62165502a3801 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5530,8 +5530,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { pathOrDescriptor: target, }); }), - writeFileString: (target, contents) => - Effect.sync(() => NodeFS.writeFileSync(target, contents)), }), Effect.provide(layerServerConfig), ); From db0485b9cf0442d4ad3a7b28f04f5b216cefe547 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 21:29:16 +0000 Subject: [PATCH 6/6] fix(server): finish Git cleanup once a Windows worktree folder is deleted An interrupt between the folder's deletion and `git worktree remove` left stale metadata that no later cleanup could reach, because retries need the `.git` file the deletion removed. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/worktreeRemoval.ts | 30 ++++++++++++++++---------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/server/src/vcs/worktreeRemoval.ts b/apps/server/src/vcs/worktreeRemoval.ts index f811b3e219720..b6573c40b2fa2 100644 --- a/apps/server/src/vcs/worktreeRemoval.ts +++ b/apps/server/src/vcs/worktreeRemoval.ts @@ -151,16 +151,24 @@ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorkt const gitFileContents = yield* fileSystem .readFileString(gitFile) .pipe(Effect.mapError(() => notAWorktree)); - yield* fileSystem.remove(worktreePath, { recursive: true, force: true }).pipe( - // Node cannot cancel fs.rm; wait for its callback before restoring `.git`. - Effect.uninterruptible, - Effect.retry({ - times: input.force ? OPEN_FILE_RETRIES : 0, - schedule: Schedule.spaced(OPEN_FILE_RETRY_DELAY), - }), - Effect.onError(() => fileSystem.writeFileString(gitFile, gitFileContents).pipe(Effect.ignore)), - Effect.mapError((cause) => refuse(`failed to delete '${worktreePath}'`, cause)), + // Once the folder is gone, Git cleanup must run: nothing else can drop the metadata. + return yield* Effect.uninterruptibleMask((restore) => + restore( + fileSystem.remove(worktreePath, { recursive: true, force: true }).pipe( + // Node cannot cancel fs.rm; wait for its callback before restoring `.git`. + Effect.uninterruptible, + Effect.retry({ + times: input.force ? OPEN_FILE_RETRIES : 0, + schedule: Schedule.spaced(OPEN_FILE_RETRY_DELAY), + }), + Effect.onError(() => + fileSystem.writeFileString(gitFile, gitFileContents).pipe(Effect.ignore), + ), + Effect.mapError((cause) => refuse(`failed to delete '${worktreePath}'`, cause)), + ), + ).pipe( + // The folder is gone, so Git only drops the worktree's metadata. + Effect.andThen(input.run(["worktree", "remove", "--force", "--force", "--", worktreePath])), + ), ); - // The folder is gone, so Git only drops the worktree's metadata. - return yield* input.run(["worktree", "remove", "--force", "--force", "--", worktreePath]); });