From 07bf30a033487e4a243638bab70149aed02930d5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 10:59:40 +0000 Subject: [PATCH 1/4] test(server): rollback recreates a managed worktree folder that lost its checkout Fails on main: a leftover worktree folder without .git skips the recreation block in CheckpointRollbackService, so file restore runs against a folder with no checkout (#35156). AI-assisted; verified with the tests and tools listed above. --- .../CheckpointRollbackService.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts index c61f9bc175edc..5f8600af79ac3 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts @@ -174,7 +174,8 @@ it.effect.each([ { order: "cleanup-first", restoreFiles: true }, { order: "rollback-first", restoreFiles: true }, { order: "cleanup-first", restoreFiles: false, unmanaged: true }, -])("locks rollback with %s", ({ order, restoreFiles, unmanaged = false }) => + { order: "cleanup-first", restoreFiles: true, leavesFolder: true }, +])("locks rollback with %s", ({ order, restoreFiles, unmanaged = false, leavesFolder = false }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -185,7 +186,7 @@ it.effect.each([ const worktreePath = unmanaged ? path.join(workspaceRoot, "elsewhere", "feature") : path.join(worktreesDir, "repo", "feature"); - yield* fileSystem.makeDirectory(worktreePath, { recursive: true }); + yield* fileSystem.makeDirectory(path.join(worktreePath, ".git"), { recursive: true }); const threadId = ThreadId.make("rollback-worktree"); const projectId = ProjectId.make("rollback-worktree-project"); const providerThreadId = ProviderThreadId.make("rollback-worktree-provider-thread"); @@ -246,14 +247,18 @@ it.effect.each([ path: worktreePath, }); calls.push("create"); - yield* fileSystem.makeDirectory(worktreePath).pipe(Effect.orDie); + yield* fileSystem + .makeDirectory(path.join(worktreePath, ".git"), { recursive: true }) + .pipe(Effect.orDie); return {} as never; }), }), Layer.mock(CheckpointService.CheckpointServiceV2)({ restore: () => Effect.gen(function* () { - assert.isTrue(yield* fileSystem.exists(worktreePath).pipe(Effect.orDie)); + assert.isTrue( + yield* fileSystem.exists(path.join(worktreePath, ".git")).pipe(Effect.orDie), + ); calls.push("files"); }), }), @@ -306,6 +311,8 @@ it.effect.each([ cleanupEntered = true; if (order === "cleanup-first") yield* Deferred.await(release); yield* fileSystem.remove(worktreePath, { recursive: true, force: true }); + // A failed `git worktree remove` can leave the folder without its checkout. + if (leavesFolder) yield* fileSystem.makeDirectory(worktreePath); calls.push("cleanup"); }), ); From 9716a1b1de18193884086729f96b83e56a30a7b8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 11:02:35 +0000 Subject: [PATCH 2/4] fix(server): Edit from here recreates a worktree folder that lost its checkout A failed worktree removal can leave the managed folder behind without .git, which skipped recreation and made file restore fail. Rollback now treats a folder without .git as missing, as turn start does: it clears the leftover folder, prunes, and recreates the checkout from the branch. Fixes pingdotgg/t3code#35156 AI-assisted; verified with the tests and tools listed above. --- apps/server/src/orchestration-v2/CheckpointRollbackService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts index 4d40641fa54db..bafdb9347ac92 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts @@ -219,14 +219,16 @@ export const layer: Layer.Layer< const { worktreePath, branch } = context.thread; // Recreated only where the server would have made it, as turn start does. + // A failed removal can leave the folder behind without its checkout. if ( worktreePath != null && branch != null && - !(yield* fileSystem.exists(worktreePath)) && + !(yield* fileSystem.exists(path.join(worktreePath, ".git"))) && (yield* isManagedWorktreePath(worktreePath)) ) { const project = yield* projects.get(context.thread.projectId); if (Option.isSome(project)) { + yield* fileSystem.remove(worktreePath, { recursive: true, force: true }); yield* gitWorkflow.pruneWorktrees({ cwd: project.value.workspaceRoot }); yield* gitWorkflow.createWorktree({ cwd: project.value.workspaceRoot, From 262e832dd5e6471a60cfbfcb8b1769faeee52349 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 11:10:34 +0000 Subject: [PATCH 3/4] fix(server): conversation-only rollback keeps a leftover worktree folder Only a rollback that restores files replaces a managed worktree folder that lost its .git; a conversation-only rewind keeps the folder's files and recreates the checkout only when the folder is gone, as before. AI-assisted; verified with the tests and tools listed above. --- .../CheckpointRollbackService.test.ts | 12 ++++++++++-- .../orchestration-v2/CheckpointRollbackService.ts | 6 ++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts index 5f8600af79ac3..d22d55bdf95e5 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts @@ -175,6 +175,7 @@ it.effect.each([ { order: "rollback-first", restoreFiles: true }, { order: "cleanup-first", restoreFiles: false, unmanaged: true }, { order: "cleanup-first", restoreFiles: true, leavesFolder: true }, + { order: "cleanup-first", restoreFiles: false, leavesFolder: true }, ])("locks rollback with %s", ({ order, restoreFiles, unmanaged = false, leavesFolder = false }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -312,7 +313,10 @@ it.effect.each([ if (order === "cleanup-first") yield* Deferred.await(release); yield* fileSystem.remove(worktreePath, { recursive: true, force: true }); // A failed `git worktree remove` can leave the folder without its checkout. - if (leavesFolder) yield* fileSystem.makeDirectory(worktreePath); + if (leavesFolder) { + yield* fileSystem.makeDirectory(worktreePath); + yield* fileSystem.writeFileString(path.join(worktreePath, "unsaved.txt"), "keep"); + } calls.push("cleanup"); }), ); @@ -343,12 +347,16 @@ it.effect.each([ const rollbackCalls = restoreFiles ? ["session", "files", "projection"] : ["session", "projection"]; + const recreates = !leavesFolder || restoreFiles; assert.deepEqual( calls, order === "cleanup-first" - ? ["cleanup", "prune", "create", ...rollbackCalls] + ? ["cleanup", ...(recreates ? ["prune", "create"] : []), ...rollbackCalls] : [...rollbackCalls, "cleanup"], ); + if (!recreates) { + assert.isTrue(yield* fileSystem.exists(path.join(worktreePath, "unsaved.txt"))); + } }).pipe(Effect.provide(testLayer)); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts index bafdb9347ac92..0c78ea21f78c7 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts @@ -219,11 +219,13 @@ export const layer: Layer.Layer< const { worktreePath, branch } = context.thread; // Recreated only where the server would have made it, as turn start does. - // A failed removal can leave the folder behind without its checkout. + // A failed removal can leave the folder without its checkout; only a file restore replaces it. if ( worktreePath != null && branch != null && - !(yield* fileSystem.exists(path.join(worktreePath, ".git"))) && + !(yield* fileSystem.exists( + input.restoreFiles === false ? worktreePath : path.join(worktreePath, ".git"), + )) && (yield* isManagedWorktreePath(worktreePath)) ) { const project = yield* projects.get(context.thread.projectId); From f18d43354a76add1f7c716a0899fb77dbf95032f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 11:17:11 +0000 Subject: [PATCH 4/4] fix(server): rollback checks workspace isolation before clearing a leftover worktree folder A file-restoring rollback into a shared workspace is rejected; it now rejects before removing a leftover folder that lost its .git, so the folder's files stay, as they did before recovery was added. AI-assisted; verified with the tests and tools listed above. --- .../CheckpointRollbackService.test.ts | 30 ++++++-- .../CheckpointRollbackService.ts | 71 ++++++++++--------- 2 files changed, 63 insertions(+), 38 deletions(-) diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts index d22d55bdf95e5..dea2c0151d38f 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts @@ -176,7 +176,8 @@ it.effect.each([ { order: "cleanup-first", restoreFiles: false, unmanaged: true }, { order: "cleanup-first", restoreFiles: true, leavesFolder: true }, { order: "cleanup-first", restoreFiles: false, leavesFolder: true }, -])("locks rollback with %s", ({ order, restoreFiles, unmanaged = false, leavesFolder = false }) => + { order: "cleanup-first", restoreFiles: true, leavesFolder: true, shared: true }, +])("locks rollback with %s", ({ order, restoreFiles, unmanaged, leavesFolder, shared }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -278,16 +279,31 @@ it.effect.each([ Effect.succeed({ schemaVersion: 1, snapshotSequence: 0, - threads: [], + threads: shared + ? [ + { + id: ThreadId.make("rollback-worktree-sharer"), + projectId, + worktreePath, + deletedAt: null, + lineage: {}, + }, + ] + : [], archivedThreads: [], - }), + } as never), + getCheckpointContext: () => Effect.succeed({ checkpointScopes: [] } as never), + getThreadProviderContext: () => Effect.succeed({ providerSessions: [] } as never), }), Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ open: (input) => Effect.gen(function* () { assert.equal(input.runtimePolicy.cwd, worktreePath); if (!(yield* fileSystem.exists(worktreePath).pipe(Effect.orDie))) { - return yield* new ProviderWorkspaceMissingError({ threadId, cwd: worktreePath }); + return yield* new ProviderWorkspaceMissingError({ + threadId, + cwd: worktreePath, + }); } calls.push("session"); yield* Deferred.succeed(openEntered, undefined); @@ -336,10 +352,14 @@ it.effect.each([ if (order === "rollback-first") assert.isFalse(cleanupEntered); yield* Deferred.succeed(release, undefined); if (cleanup !== undefined) yield* Fiber.join(cleanup); - if (unmanaged) { + if (unmanaged || shared) { const error = yield* Fiber.join(rollback).pipe(Effect.flip); assert.equal(error._tag, "CheckpointRollbackExecutionError"); assert.deepEqual(calls, ["cleanup"]); + if (shared) { + assert.equal(error.reason, "shared-workspace"); + assert.isTrue(yield* fileSystem.exists(path.join(worktreePath, "unsaved.txt"))); + } return; } yield* Fiber.join(rollback); diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts index 0c78ea21f78c7..a3604c278e3dd 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts @@ -217,46 +217,51 @@ export const layer: Layer.Layer< }; }); - const { worktreePath, branch } = context.thread; - // Recreated only where the server would have made it, as turn start does. - // A failed removal can leave the folder without its checkout; only a file restore replaces it. - if ( - worktreePath != null && - branch != null && - !(yield* fileSystem.exists( - input.restoreFiles === false ? worktreePath : path.join(worktreePath, ".git"), - )) && - (yield* isManagedWorktreePath(worktreePath)) - ) { - const project = yield* projects.get(context.thread.projectId); - if (Option.isSome(project)) { - yield* fileSystem.remove(worktreePath, { recursive: true, force: true }); - yield* gitWorkflow.pruneWorktrees({ cwd: project.value.workspaceRoot }); - yield* gitWorkflow.createWorktree({ - cwd: project.value.workspaceRoot, - refName: branch, - path: worktreePath, - }); - } - } - - if ( - input.restoreFiles !== false && - !(yield* isCheckpointRestoreIsolated(context.thread, scope, { - fileSystem, - projections, - projects, - path, - })) - ) { - return yield* new CheckpointRollbackExecutionError({ + const restoreFiles = input.restoreFiles !== false; + const isolated = isCheckpointRestoreIsolated(context.thread, scope, { + fileSystem, + projections, + projects, + path, + }); + const sharedWorkspace = () => + new CheckpointRollbackExecutionError({ reason: "shared-workspace", threadId: input.threadId, providerThreadId: input.providerThreadId, checkpointId: input.checkpointId, }); + + const { worktreePath, branch } = context.thread; + // Recreated only where the server would have made it, as turn start does. + if (worktreePath != null && branch != null) { + // A failed removal can leave the folder without its checkout; only a file restore replaces it. + const leftover = + restoreFiles && + (yield* fileSystem.exists(worktreePath)) && + !(yield* fileSystem.exists(path.join(worktreePath, ".git"))); + if ( + (leftover || !(yield* fileSystem.exists(worktreePath))) && + (yield* isManagedWorktreePath(worktreePath)) + ) { + const project = yield* projects.get(context.thread.projectId); + if (Option.isSome(project)) { + if (leftover) { + if (!(yield* isolated)) return yield* sharedWorkspace(); + yield* fileSystem.remove(worktreePath, { recursive: true, force: true }); + } + yield* gitWorkflow.pruneWorktrees({ cwd: project.value.workspaceRoot }); + yield* gitWorkflow.createWorktree({ + cwd: project.value.workspaceRoot, + refName: branch, + path: worktreePath, + }); + } + } } + if (restoreFiles && !(yield* isolated)) return yield* sharedWorkspace(); + const modelSelection = context.thread.modelSelection; const resolvedRuntimePolicy = yield* runtimePolicy.resolve({ thread: context.thread,