From 60430b7e9faf06444255b66dadf55758ffe8fb65 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:19:58 +0000 Subject: [PATCH 1/2] test(server): leftover worktree folders without .git are recovered and kept Repro for #24390: turn start skips recreation when the managed worktree folder exists without a checkout, and the storage cleanup sweep fails on the missing .git instead of keeping the folder. AI-assisted; verified with the tests and tools listed above. --- .../ProviderTurnStartService.test.ts | 9 ++++++++- apps/server/src/storageCleanup.test.ts | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 23e7689504876..30767c04096c5 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1113,6 +1113,8 @@ effectIt.effect( const expectedTips = { retained: { tip: threadTip, file: "thread.txt", upstream: null, base: "" }, + // A failed removal can leave the folder behind with only dependencies inside. + leftover: { tip: threadTip, file: "thread.txt", upstream: null, base: "" }, deleted: { tip: projectTip, file: "project.txt", upstream: null, base: "project-current" }, pushed: { tip: threadTip, @@ -1147,7 +1149,12 @@ effectIt.effect( yield* git(workspaceRoot, ["remote", "remove", "origin"]); } yield* fileSystem.remove(worktreePath, { recursive: true }); - if (scenario !== "retained") { + if (scenario === "leftover") { + yield* fileSystem.makeDirectory(path.join(worktreePath, "node_modules"), { + recursive: true, + }); + } + if (scenario !== "retained" && scenario !== "leftover") { yield* git(workspaceRoot, ["worktree", "prune"]); yield* git(workspaceRoot, ["branch", "-D", "feature/restore"]); } diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index b933895c4de1a..adf687e275d6b 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -2107,6 +2107,24 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.effect("keeps a thread's worktree folder that lost its .git link", () => + runCleanupTest( + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture(); + fixture.setThreads([ + shell({ branch: "feature", worktreePath: fixture.worktree, archivedAt: at(-DAY_MS) }), + ]); + yield* fixture.fs.remove(`${fixture.worktree}/.git`); + yield* fixture.fs.makeDirectory(`${fixture.worktree}/node_modules`); + const report = yield* fixture.service.runNow; + expect(report.entries).toEqual([ + expect.objectContaining({ path: fixture.worktree, outcome: "kept" }), + ]); + expect(yield* fixture.fs.exists(`${fixture.worktree}/node_modules`)).toBe(true); + }), + ), + ); it.live("skips a stored worktree path whose parent is a regular file", () => runCleanupTest( Effect.gen(function* () { From 2ae1676f7d6195792bd1da0c4f2c52579e6be5db Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:31:01 +0000 Subject: [PATCH 2/2] fix(server): turns recreate a worktree folder that lost its checkout A failed removal can leave a thread's managed worktree folder behind with no .git link. Turn start only checked that the folder existed, so the agent ran outside any checkout and git commands reached the repository around it. Turn start now checks for the folder's .git, clears the leftover folder, and recreates the checkout. Storage cleanup keeps such a folder as "not a linked worktree" instead of failing the sweep on the missing .git. Fixes pingdotgg/t3code#24390 AI-assisted; verified with the tests and tools listed above. --- .../src/orchestration-v2/ProviderTurnStartService.test.ts | 1 + .../server/src/orchestration-v2/ProviderTurnStartService.ts | 4 +++- apps/server/src/storageCleanup.ts | 6 ++++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 30767c04096c5..6bfbf63451731 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -141,6 +141,7 @@ function startWithMissingWorktree( Layer.mock(TurnItemPositionStore.TurnItemPositionStoreV2)({}), Layer.succeed(FileSystem.FileSystem, { exists: () => Effect.succeed(false), + remove: () => Effect.void, realPath: (target: string) => normalize(target) in realPaths ? Effect.succeed(realPaths[normalize(target)]) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index a506cc6e122c6..3966e07de95be 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -219,6 +219,7 @@ export const layer: Layer.Layer< readonly branch: string; }) { const { cwd, worktreePath, branch } = input; + yield* fileSystem.remove(worktreePath, { recursive: true, force: true }); yield* gitWorkflow.pruneWorktrees({ cwd }); const localBranchNames = yield* gitWorkflow.listLocalBranchNames(cwd); if (localBranchNames.includes(branch)) { @@ -651,8 +652,9 @@ export const layer: Layer.Layer< } const { worktreePath, branch } = projection.thread; if (worktreePath !== null && branch !== null) { + // A failed removal can leave the folder behind without its checkout. const exists = yield* fileSystem - .exists(worktreePath) + .exists(path.join(worktreePath, ".git")) .pipe(Effect.orElseSucceed(() => true)); if (!exists && (yield* isManagedWorktreePath(worktreePath))) { const project = yield* projects.getById(projection.thread.projectId).pipe( diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index d044c2f7bdc40..851a928867c37 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -944,8 +944,10 @@ const make = Effect.gen(function* () { return "worktree is a symbolic link"; if (!roots.some((root) => inside(root, realPath))) return "outside the managed worktree folder"; - if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") - return "not a linked worktree"; + const gitLink = yield* fs + .stat(path.join(worktreePath, ".git")) + .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)); + if (gitLink?.type !== "File") return "not a linked worktree"; return null; }); const deletedRows = yield* sql<{ payload_json: string; workspaceRoot: string }>`