diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605a..117dae76bbcb 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,8 +161,13 @@ 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; + let currentBranch = input?.currentBranch ?? "main"; const runtimeSessions: Array = []; const modelSelection = input?.threadModelSelection ?? { instanceId: ProviderInstanceId.make("codex"), @@ -282,6 +288,20 @@ describe("ProviderCommandReactor", () => { pr: null, }), ); + const refreshLocalStatus = vi.fn((_: string) => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: currentBranch, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + }), + ); const generateBranchName = vi.fn((_) => Effect.fail( new TextGenerationError({ @@ -400,8 +420,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 +448,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 +519,15 @@ describe("ProviderCommandReactor", () => { stopSession, renameBranch, refreshStatus, + refreshLocalStatus, generateBranchName, generateThreadTitle, runtimeSessions, + setCurrentBranch: (branch: string) => { + currentBranch = branch; + }, + workspaceRoot, + worktreePath, stateDir, drain, runEffect, @@ -537,7 +562,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 +577,215 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + 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: recoveredBranch, + 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: [], + }, + titleSeed: "Thread", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + 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("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 () => { + 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"); + }); + + 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(); @@ -775,7 +1009,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 +1700,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 +1741,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 +2234,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 +2242,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 +2268,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 +2801,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..ca50bd760c66 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,109 @@ const make = Effect.gen(function* () { } } const project = yield* resolveProject(thread.projectId); - const effectiveCwd = resolveThreadWorkspaceCwd({ - thread, - projects: project ? [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: 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.`, + }); + } + + 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}'.`, + }), + ), + ); + if (!projectCheckoutExists) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(desiredInstanceId)), + method: "thread.turn.start", + 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.`, + }); + } + + const localStatus = yield* vcsStatusBroadcaster + .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}'.`, + }), + ), + ); + const currentBranch = localStatus.isRepo ? localStatus.refName : null; + 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: 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( + (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.`, + }), + ), + ); + 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 projectCheckout; }); const startProviderSession = (input?: { @@ -797,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)) { @@ -809,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; @@ -838,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( @@ -1096,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)) { @@ -1179,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);