From 0c5b05829774198f74e493ce775231f80d067441 Mon Sep 17 00:00:00 2001 From: Deepusleepy <181001606+Deepusleepy@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:12:40 +0530 Subject: [PATCH 1/4] fix(server): recover deleted thread worktrees --- .../Layers/ProviderCommandReactor.test.ts | 133 ++++++++++++++++-- .../Layers/ProviderCommandReactor.ts | 86 ++++++++++- 2 files changed, 205 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605a..c46ff166c775 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -148,6 +148,7 @@ describe("ProviderCommandReactor", () => { readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; + readonly currentBranch?: string; readonly titleRegenerationCompletionDispatchFailures?: number; readonly titleRegenerationBeforeStart?: "one" | "two"; readonly startSessionEffect?: ( @@ -160,6 +161,10 @@ describe("ProviderCommandReactor", () => { createdBaseDirs.add(baseDir); const { stateDir } = deriveServerPathsSync(baseDir, undefined); createdStateDirs.add(stateDir); + const workspaceRoot = NodePath.join(baseDir, "provider-project"); + const worktreePath = NodePath.join(baseDir, "provider-project-worktree"); + NodeFS.mkdirSync(workspaceRoot, { recursive: true }); + NodeFS.mkdirSync(worktreePath, { recursive: true }); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSessionIndex = 1; const runtimeSessions: Array = []; @@ -282,6 +287,20 @@ describe("ProviderCommandReactor", () => { pr: null, }), ); + const refreshLocalStatus = vi.fn((_: string) => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: input?.currentBranch ?? "main", + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + }), + ); const generateBranchName = vi.fn((_) => Effect.fail( new TextGenerationError({ @@ -400,8 +419,7 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge( Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), - refreshLocalStatus: () => - Effect.die("refreshLocalStatus should not be called in this test"), + refreshLocalStatus, refreshStatus, streamStatus: () => Stream.die("streamStatus should not be called in this test"), }), @@ -429,7 +447,7 @@ describe("ProviderCommandReactor", () => { commandId: CommandId.make("cmd-project-create"), projectId: asProjectId("project-1"), title: "Provider Project", - workspaceRoot: "/tmp/provider-project", + workspaceRoot, defaultModelSelection: modelSelection, createdAt: now, }), @@ -500,9 +518,12 @@ describe("ProviderCommandReactor", () => { stopSession, renameBranch, refreshStatus, + refreshLocalStatus, generateBranchName, generateThreadTitle, runtimeSessions, + workspaceRoot, + worktreePath, stateDir, drain, runEffect, @@ -537,7 +558,7 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.make("thread-1")); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - cwd: "/tmp/provider-project", + cwd: harness.workspaceRoot, modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", @@ -552,6 +573,96 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("repairs a deleted worktree when the project checkout is on the same branch", async () => { + const harness = await createHarness({ currentBranch: "main" }); + const missingWorktreePath = NodePath.join(harness.workspaceRoot, "deleted-worktree"); + const now = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-deleted-worktree"), + threadId: ThreadId.make("thread-1"), + branch: "main", + worktreePath: missingWorktreePath, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-deleted-worktree"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-deleted-worktree"), + role: "user", + text: "continue from the project checkout", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.refreshLocalStatus).toHaveBeenCalledWith(harness.workspaceRoot); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + cwd: harness.workspaceRoot, + }); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.branch).toBe("main"); + expect(thread?.worktreePath).toBeNull(); + }); + + it("refuses to repair a deleted worktree onto a different branch", async () => { + const harness = await createHarness({ currentBranch: "main" }); + const missingWorktreePath = NodePath.join(harness.workspaceRoot, "deleted-worktree"); + const now = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-deleted-worktree-wrong-branch"), + threadId: ThreadId.make("thread-1"), + branch: "feature/original-worktree", + worktreePath: missingWorktreePath, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-deleted-worktree-wrong-branch"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-deleted-worktree-wrong-branch"), + role: "user", + text: "do not run on the wrong branch", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + if (harness.startSession.mock.calls.length > 0) return true; + const readModel = await harness.readModel(); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.session + ?.status === "error" + ); + }); + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.lastError).toContain(missingWorktreePath); + expect(thread?.session?.lastError).toContain("feature/original-worktree"); + expect(thread?.session?.lastError).toContain("main"); + }); + effectIt.effect("projects starting before a slow provider session finishes", () => Effect.gen(function* () { const releaseStart = yield* Deferred.make(); @@ -775,7 +886,7 @@ describe("ProviderCommandReactor", () => { expect(harness.generateThreadTitle).toHaveBeenCalledTimes(1); expect(harness.generateThreadTitle.mock.calls[0]?.[0]).toMatchObject({ - cwd: "/tmp/provider-project", + cwd: harness.workspaceRoot, previousTitle: "Investigate reconnect regressions", message: [ "USER:", @@ -1466,7 +1577,7 @@ describe("ProviderCommandReactor", () => { commandId: CommandId.make("cmd-thread-branch"), threadId: ThreadId.make("thread-1"), branch: "t3code/1234abcd", - worktreePath: "/tmp/provider-project-worktree", + worktreePath: harness.worktreePath, }), ); @@ -1507,7 +1618,7 @@ describe("ProviderCommandReactor", () => { expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({ message: "Add a safer reconnect backoff.", }); - expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); + expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe(harness.worktreePath); }); it("forwards codex model options through session start and turn send", async () => { @@ -2000,7 +2111,7 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - cwd: "/tmp/provider-project", + cwd: harness.workspaceRoot, }); await Effect.runPromise( @@ -2008,7 +2119,7 @@ describe("ProviderCommandReactor", () => { type: "thread.meta.update", commandId: CommandId.make("cmd-thread-worktree-change"), threadId: ThreadId.make("thread-1"), - worktreePath: "/tmp/provider-project-worktree", + worktreePath: harness.worktreePath, }), ); @@ -2034,7 +2145,7 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ threadId: ThreadId.make("thread-1"), - cwd: "/tmp/provider-project-worktree", + cwd: harness.worktreePath, resumeCursor: { opaque: "resume-1" }, modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), @@ -2567,7 +2678,7 @@ describe("ProviderCommandReactor", () => { status: "ready", runtimeMode: "approval-required", threadId: ThreadId.make("thread-1"), - cwd: "/tmp/provider-project", + cwd: harness.workspaceRoot, resumeCursor: { opaque: "resume-without-instance" }, createdAt: now, updatedAt: now, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ff639797179f..22463d07691a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -19,6 +19,7 @@ import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -316,6 +317,7 @@ const make = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const providerService = yield* ProviderService; const providerRegistry = yield* ProviderRegistry; + const fileSystem = yield* FileSystem.FileSystem; const gitWorkflow = yield* GitWorkflowService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; @@ -612,9 +614,87 @@ const make = Effect.gen(function* () { } } const project = yield* resolveProject(thread.projectId); - const effectiveCwd = resolveThreadWorkspaceCwd({ - thread, - projects: project ? [project] : [], + const effectiveCwd = yield* Effect.gen(function* () { + const resolvedCwd = resolveThreadWorkspaceCwd({ + thread, + projects: project ? [project] : [], + }); + if (!thread.worktreePath) { + return resolvedCwd; + } + + const worktreeExists = yield* fileSystem.exists(thread.worktreePath).pipe( + Effect.mapError( + () => + new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(desiredInstanceId)), + method: "thread.turn.start", + detail: `T3 Code could not check whether thread '${threadId}' worktree '${thread.worktreePath}' still exists.`, + }), + ), + ); + if (worktreeExists) { + return thread.worktreePath; + } + + if (!project) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(desiredInstanceId)), + method: "thread.turn.start", + detail: `Thread '${threadId}' points to deleted worktree '${thread.worktreePath}', and its project checkout could not be found.`, + }); + } + + const projectCheckoutExists = yield* fileSystem.exists(project.workspaceRoot).pipe( + Effect.mapError( + () => + new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(desiredInstanceId)), + method: "thread.turn.start", + detail: `Thread '${threadId}' points to deleted worktree '${thread.worktreePath}', and T3 Code could not check its project checkout '${project.workspaceRoot}'.`, + }), + ), + ); + if (!projectCheckoutExists) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(desiredInstanceId)), + method: "thread.turn.start", + detail: `Thread '${threadId}' points to deleted worktree '${thread.worktreePath}', and its project checkout '${project.workspaceRoot}' is also missing.`, + }); + } + + const localStatus = yield* vcsStatusBroadcaster + .refreshLocalStatus(project.workspaceRoot) + .pipe( + Effect.mapError( + () => + new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(desiredInstanceId)), + method: "thread.turn.start", + detail: `Thread '${threadId}' points to deleted worktree '${thread.worktreePath}', and T3 Code could not verify the branch in project checkout '${project.workspaceRoot}'.`, + }), + ), + ); + const expectedBranch = thread.branch; + const currentBranch = localStatus.isRepo ? localStatus.refName : null; + if (!expectedBranch || currentBranch !== expectedBranch) { + const currentBranchLabel = localStatus.isRepo + ? (currentBranch ?? "detached HEAD") + : "not a Git repository"; + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(desiredInstanceId)), + method: "thread.turn.start", + detail: `Thread '${threadId}' points to deleted worktree '${thread.worktreePath}' for branch '${expectedBranch ?? "unknown"}'. The project checkout '${project.workspaceRoot}' is on '${currentBranchLabel}', so T3 Code refused to run the turn in the wrong checkout. Restore the worktree or check out '${expectedBranch ?? "the thread branch"}' in the project checkout and retry.`, + }); + } + + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: yield* serverCommandId("deleted-worktree-repair"), + threadId, + worktreePath: null, + }); + return project.workspaceRoot; }); const startProviderSession = (input?: { From 1a687ee6c2d52583a2e8b22b980e41ec551cfc09 Mon Sep 17 00:00:00 2001 From: Deepusleepy <181001606+Deepusleepy@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:37:55 +0530 Subject: [PATCH 2/4] fix(server): keep recovered threads on their branch --- .../Layers/ProviderCommandReactor.test.ts | 98 ++++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 83 ++++++++++------ 2 files changed, 148 insertions(+), 33 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c46ff166c775..296980a7624a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -167,6 +167,7 @@ describe("ProviderCommandReactor", () => { NodeFS.mkdirSync(worktreePath, { recursive: true }); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSessionIndex = 1; + let currentBranch = input?.currentBranch ?? "main"; const runtimeSessions: Array = []; const modelSelection = input?.threadModelSelection ?? { instanceId: ProviderInstanceId.make("codex"), @@ -292,7 +293,7 @@ describe("ProviderCommandReactor", () => { isRepo: true, hasPrimaryRemote: true, isDefaultRef: false, - refName: input?.currentBranch ?? "main", + refName: currentBranch, hasWorkingTreeChanges: false, workingTree: { files: [], @@ -522,6 +523,9 @@ describe("ProviderCommandReactor", () => { generateBranchName, generateThreadTitle, runtimeSessions, + setCurrentBranch: (branch: string) => { + currentBranch = branch; + }, workspaceRoot, worktreePath, stateDir, @@ -663,6 +667,98 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.lastError).toContain("main"); }); + it("keeps validating the branch after recovering a deleted worktree", async () => { + let failStartup = true; + const harness = await createHarness({ + currentBranch: "main", + startSessionEffect: (session) => + failStartup + ? Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "thread.start", + detail: "deterministic startup failure", + }), + ) + : Effect.succeed(session), + }); + const missingWorktreePath = NodePath.join(harness.workspaceRoot, "deleted-worktree"); + const now = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-deleted-worktree-follow-up"), + threadId: ThreadId.make("thread-1"), + branch: "main", + worktreePath: missingWorktreePath, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-deleted-worktree-failed-startup"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-deleted-worktree-failed-startup"), + role: "user", + text: "recover, then fail to start", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await harness.readModel(); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.session + ?.status === "error" + ); + }); + let readModel = await harness.readModel(); + let thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.worktreePath).toBeNull(); + expect(harness.startSession).toHaveBeenCalledTimes(1); + + harness.setCurrentBranch("feature/other"); + failStartup = false; + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-deleted-worktree-branch-drift"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-deleted-worktree-branch-drift"), + role: "user", + text: "do not retry on a different branch", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:01.000Z", + }), + ); + + await waitFor(async () => { + if (harness.startSession.mock.calls.length > 1) return true; + const currentReadModel = await harness.readModel(); + return ( + currentReadModel.threads + .find((entry) => entry.id === ThreadId.make("thread-1")) + ?.session?.lastError?.includes("feature/other") ?? false + ); + }); + expect(harness.startSession).toHaveBeenCalledTimes(1); + expect(harness.sendTurn).not.toHaveBeenCalled(); + readModel = await harness.readModel(); + thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.lastError).toContain("main"); + expect(thread?.session?.lastError).toContain("feature/other"); + }); + effectIt.effect("projects starting before a slow provider session finishes", () => Effect.gen(function* () { const releaseStart = yield* Deferred.make(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 22463d07691a..0695ab07dd0b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -614,34 +614,17 @@ const make = Effect.gen(function* () { } } const project = yield* resolveProject(thread.projectId); - const effectiveCwd = yield* Effect.gen(function* () { - const resolvedCwd = resolveThreadWorkspaceCwd({ - thread, - projects: project ? [project] : [], - }); - if (!thread.worktreePath) { - return resolvedCwd; - } - - const worktreeExists = yield* fileSystem.exists(thread.worktreePath).pipe( - Effect.mapError( - () => - new ProviderAdapterRequestError({ - provider: providerErrorLabel(String(desiredInstanceId)), - method: "thread.turn.start", - detail: `T3 Code could not check whether thread '${threadId}' worktree '${thread.worktreePath}' still exists.`, - }), - ), - ); - if (worktreeExists) { - return thread.worktreePath; - } - - if (!project) { + const validateProjectCheckoutBranch = Effect.fnUntraced(function* (input: { + readonly deletedWorktreePath?: string; + }) { + const expectedBranch = thread.branch; + if (!project || !expectedBranch) { return yield* new ProviderAdapterRequestError({ provider: providerErrorLabel(String(desiredInstanceId)), method: "thread.turn.start", - detail: `Thread '${threadId}' points to deleted worktree '${thread.worktreePath}', and its project checkout could not be found.`, + detail: input.deletedWorktreePath + ? `Thread '${threadId}' points to deleted worktree '${input.deletedWorktreePath}', and its project checkout or expected branch could not be found.` + : `Thread '${threadId}' is bound to a project branch that could not be resolved.`, }); } @@ -651,7 +634,9 @@ const make = Effect.gen(function* () { new ProviderAdapterRequestError({ provider: providerErrorLabel(String(desiredInstanceId)), method: "thread.turn.start", - detail: `Thread '${threadId}' points to deleted worktree '${thread.worktreePath}', and T3 Code could not check its project checkout '${project.workspaceRoot}'.`, + detail: input.deletedWorktreePath + ? `Thread '${threadId}' points to deleted worktree '${input.deletedWorktreePath}', and T3 Code could not check its project checkout '${project.workspaceRoot}'.` + : `T3 Code could not check project checkout '${project.workspaceRoot}' for thread '${threadId}'.`, }), ), ); @@ -659,7 +644,9 @@ const make = Effect.gen(function* () { return yield* new ProviderAdapterRequestError({ provider: providerErrorLabel(String(desiredInstanceId)), method: "thread.turn.start", - detail: `Thread '${threadId}' points to deleted worktree '${thread.worktreePath}', and its project checkout '${project.workspaceRoot}' is also missing.`, + detail: input.deletedWorktreePath + ? `Thread '${threadId}' points to deleted worktree '${input.deletedWorktreePath}', and its project checkout '${project.workspaceRoot}' is also missing.` + : `Thread '${threadId}' is bound to branch '${expectedBranch}', but its project checkout '${project.workspaceRoot}' is missing.`, }); } @@ -671,30 +658,62 @@ const make = Effect.gen(function* () { new ProviderAdapterRequestError({ provider: providerErrorLabel(String(desiredInstanceId)), method: "thread.turn.start", - detail: `Thread '${threadId}' points to deleted worktree '${thread.worktreePath}', and T3 Code could not verify the branch in project checkout '${project.workspaceRoot}'.`, + detail: input.deletedWorktreePath + ? `Thread '${threadId}' points to deleted worktree '${input.deletedWorktreePath}', and T3 Code could not verify the branch in project checkout '${project.workspaceRoot}'.` + : `T3 Code could not verify project checkout '${project.workspaceRoot}' for thread '${threadId}' on branch '${expectedBranch}'.`, }), ), ); - const expectedBranch = thread.branch; const currentBranch = localStatus.isRepo ? localStatus.refName : null; - if (!expectedBranch || currentBranch !== expectedBranch) { + if (currentBranch !== expectedBranch) { const currentBranchLabel = localStatus.isRepo ? (currentBranch ?? "detached HEAD") : "not a Git repository"; return yield* new ProviderAdapterRequestError({ provider: providerErrorLabel(String(desiredInstanceId)), method: "thread.turn.start", - detail: `Thread '${threadId}' points to deleted worktree '${thread.worktreePath}' for branch '${expectedBranch ?? "unknown"}'. The project checkout '${project.workspaceRoot}' is on '${currentBranchLabel}', so T3 Code refused to run the turn in the wrong checkout. Restore the worktree or check out '${expectedBranch ?? "the thread branch"}' in the project checkout and retry.`, + detail: input.deletedWorktreePath + ? `Thread '${threadId}' points to deleted worktree '${input.deletedWorktreePath}' for branch '${expectedBranch}'. The project checkout '${project.workspaceRoot}' is on '${currentBranchLabel}', so T3 Code refused to run the turn in the wrong checkout. Restore the worktree or check out '${expectedBranch}' in the project checkout and retry.` + : `Thread '${threadId}' is bound to branch '${expectedBranch}', but project checkout '${project.workspaceRoot}' is on '${currentBranchLabel}'. T3 Code refused to run the turn in the wrong checkout. Check out '${expectedBranch}' and retry.`, }); } + return project.workspaceRoot; + }); + const effectiveCwd = yield* Effect.gen(function* () { + const resolvedCwd = resolveThreadWorkspaceCwd({ + thread, + projects: project ? [project] : [], + }); + if (!thread.worktreePath) { + return thread.branch ? yield* validateProjectCheckoutBranch({}) : resolvedCwd; + } + + const worktreeExists = yield* fileSystem.exists(thread.worktreePath).pipe( + Effect.mapError( + () => + new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(desiredInstanceId)), + method: "thread.turn.start", + detail: `T3 Code could not check whether thread '${threadId}' worktree '${thread.worktreePath}' still exists.`, + }), + ), + ); + if (worktreeExists) { + return thread.worktreePath; + } + + const projectCheckout = yield* validateProjectCheckoutBranch({ + deletedWorktreePath: thread.worktreePath, + }); + yield* orchestrationEngine.dispatch({ type: "thread.meta.update", commandId: yield* serverCommandId("deleted-worktree-repair"), threadId, worktreePath: null, }); - return project.workspaceRoot; + return projectCheckout; }); const startProviderSession = (input?: { From 4fd03cff90ce44b32192afab294af55969610e8b Mon Sep 17 00:00:00 2001 From: Deepusleepy <181001606+Deepusleepy@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:24:00 +0530 Subject: [PATCH 3/4] fix(server): preserve checkout error causes --- .../src/orchestration/Layers/ProviderCommandReactor.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 0695ab07dd0b..60ca253e0b0c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -630,10 +630,11 @@ const make = Effect.gen(function* () { const projectCheckoutExists = yield* fileSystem.exists(project.workspaceRoot).pipe( Effect.mapError( - () => + (cause) => new ProviderAdapterRequestError({ provider: providerErrorLabel(String(desiredInstanceId)), method: "thread.turn.start", + cause, detail: input.deletedWorktreePath ? `Thread '${threadId}' points to deleted worktree '${input.deletedWorktreePath}', and T3 Code could not check its project checkout '${project.workspaceRoot}'.` : `T3 Code could not check project checkout '${project.workspaceRoot}' for thread '${threadId}'.`, @@ -654,10 +655,11 @@ const make = Effect.gen(function* () { .refreshLocalStatus(project.workspaceRoot) .pipe( Effect.mapError( - () => + (cause) => new ProviderAdapterRequestError({ provider: providerErrorLabel(String(desiredInstanceId)), method: "thread.turn.start", + cause, detail: input.deletedWorktreePath ? `Thread '${threadId}' points to deleted worktree '${input.deletedWorktreePath}', and T3 Code could not verify the branch in project checkout '${project.workspaceRoot}'.` : `T3 Code could not verify project checkout '${project.workspaceRoot}' for thread '${threadId}' on branch '${expectedBranch}'.`, @@ -691,10 +693,11 @@ const make = Effect.gen(function* () { const worktreeExists = yield* fileSystem.exists(thread.worktreePath).pipe( Effect.mapError( - () => + (cause) => new ProviderAdapterRequestError({ provider: providerErrorLabel(String(desiredInstanceId)), method: "thread.turn.start", + cause, detail: `T3 Code could not check whether thread '${threadId}' worktree '${thread.worktreePath}' still exists.`, }), ), From ca37fde028a1caa0135f7c3f756278b8cec6021f Mon Sep 17 00:00:00 2001 From: Deepusleepy <181001606+Deepusleepy@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:38:03 +0530 Subject: [PATCH 4/4] fix(server): recover before first-turn helpers --- .../Layers/ProviderCommandReactor.test.ts | 35 ++++++++-- .../Layers/ProviderCommandReactor.ts | 67 ++++++++++--------- 2 files changed, 67 insertions(+), 35 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 296980a7624a..117dae76bbcb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -577,17 +577,20 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); - it("repairs a deleted worktree when the project checkout is on the same branch", async () => { - const harness = await createHarness({ currentBranch: "main" }); + it("repairs a deleted worktree before starting first-turn helpers", async () => { + const recoveredBranch = "t3code/1234abcd"; + const harness = await createHarness({ currentBranch: recoveredBranch }); const missingWorktreePath = NodePath.join(harness.workspaceRoot, "deleted-worktree"); const now = "2026-01-01T00:00:00.000Z"; + harness.generateBranchName.mockReturnValue(Effect.succeed({ branch: "recovered branch" })); + harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Recovered title" })); await Effect.runPromise( harness.engine.dispatch({ type: "thread.meta.update", commandId: CommandId.make("cmd-thread-deleted-worktree"), threadId: ThreadId.make("thread-1"), - branch: "main", + branch: recoveredBranch, worktreePath: missingWorktreePath, }), ); @@ -602,6 +605,7 @@ describe("ProviderCommandReactor", () => { text: "continue from the project checkout", attachments: [], }, + titleSeed: "Thread", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -609,14 +613,37 @@ describe("ProviderCommandReactor", () => { ); await waitFor(() => harness.sendTurn.mock.calls.length === 1); + await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); + await waitFor(() => harness.renameBranch.mock.calls.length === 1); expect(harness.refreshLocalStatus).toHaveBeenCalledWith(harness.workspaceRoot); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ cwd: harness.workspaceRoot, }); + expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({ + cwd: harness.workspaceRoot, + }); + expect(harness.generateThreadTitle.mock.calls[0]?.[0]).toMatchObject({ + cwd: harness.workspaceRoot, + }); + expect(harness.renameBranch.mock.calls[0]?.[0]).toMatchObject({ + cwd: harness.workspaceRoot, + oldBranch: recoveredBranch, + }); + await waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return ( + thread?.branch === "t3code/recovered-branch" && + thread.worktreePath === null && + thread.title === "Recovered title" + ); + }); const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.branch).toBe("main"); + expect(thread?.branch).toBe("t3code/recovered-branch"); expect(thread?.worktreePath).toBeNull(); + expect(thread?.title).toBe("Recovered title"); }); it("refuses to repair a deleted worktree onto a different branch", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 60ca253e0b0c..ca50bd760c66 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -899,11 +899,12 @@ const make = Effect.gen(function* () { )(function* (input: { readonly threadId: ThreadId; readonly branch: string | null; + readonly cwd: string | null; readonly worktreePath: string | null; readonly messageText: string; readonly attachments?: ReadonlyArray; }) { - if (!input.branch || !input.worktreePath) { + if (!input.branch || !input.cwd) { return; } if (!isTemporaryWorktreeBranch(input.branch)) { @@ -911,7 +912,7 @@ const make = Effect.gen(function* () { } const oldBranch = input.branch; - const cwd = input.worktreePath; + const cwd = input.cwd; const attachments = input.attachments ?? []; yield* Effect.gen(function* () { const settings = yield* serverSettingsService.getSettings; @@ -940,7 +941,7 @@ const make = Effect.gen(function* () { commandId: yield* serverCommandId("worktree-branch-rename"), threadId: input.threadId, branch: renamed.branch, - worktreePath: cwd, + worktreePath: input.worktreePath, }); yield* vcsStatusBroadcaster.refreshStatus(cwd).pipe(Effect.ignoreCause({ log: true })); }).pipe( @@ -1198,34 +1199,6 @@ const make = Effect.gen(function* () { const isFirstUserMessageTurn = thread.messages.filter((entry) => entry.role === "user").length === 1; - if (isFirstUserMessageTurn) { - const project = yield* resolveProject(thread.projectId); - const generationCwd = - resolveThreadWorkspaceCwd({ - thread, - projects: project ? [project] : [], - }) ?? process.cwd(); - const generationInput = { - messageText: message.text, - ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), - ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), - }; - - yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ - threadId: event.payload.threadId, - branch: thread.branch, - worktreePath: thread.worktreePath, - ...generationInput, - }).pipe(Effect.forkScoped); - - if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { - yield* maybeGenerateThreadTitleForFirstTurn({ - threadId: event.payload.threadId, - cwd: generationCwd, - ...generationInput, - }).pipe(Effect.forkScoped); - } - } const handleTurnStartFailure = (cause: Cause.Cause) => { if (Cause.hasInterruptsOnly(cause)) { @@ -1281,6 +1254,38 @@ const make = Effect.gen(function* () { return; } + if (isFirstUserMessageTurn) { + const firstTurnThread = yield* resolveThread(event.payload.threadId); + if (firstTurnThread) { + const project = yield* resolveProject(firstTurnThread.projectId); + const workspaceCwd = resolveThreadWorkspaceCwd({ + thread: firstTurnThread, + projects: project ? [project] : [], + }); + const generationInput = { + messageText: message.text, + ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), + }; + + yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ + threadId: event.payload.threadId, + branch: firstTurnThread.branch, + cwd: workspaceCwd ?? null, + worktreePath: firstTurnThread.worktreePath, + ...generationInput, + }).pipe(Effect.forkScoped); + + if (canReplaceThreadTitle(firstTurnThread.title, event.payload.titleSeed)) { + yield* maybeGenerateThreadTitleForFirstTurn({ + threadId: event.payload.threadId, + cwd: workspaceCwd ?? process.cwd(), + ...generationInput, + }).pipe(Effect.forkScoped); + } + } + } + yield* providerService .sendTurn(sendTurnRequest.value) .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped);