From 7190bf2dd6f7a84faf5cda8ba86787f51a928d48 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 4 Aug 2026 13:19:09 +0200 Subject: [PATCH] fix(server): restore worktree branch naming in the v2 orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2 launch path only generated a branch name when no branch was passed, but the web client always passed a temporary t3code/ branch — so the guard never fired and threads kept the hash as their branch, with no later rename pass (unlike v1). The server now owns worktree naming: without an explicit branch, the worktree is provisioned immediately under a server-invented temporary t3code/ name, then renamed to a generated name in a background fork so name generation never delays provisioning or the provider turn. Temporary names from clients that still send them (mobile outbox) are renamed the same way, and stick if generation fails. The web client stops inventing temporary branch names. Co-Authored-By: Claude Fable 5 --- .../ThreadLaunchService.test.ts | 178 +++++++++++++++++- .../orchestration-v2/ThreadLaunchService.ts | 102 ++++++---- apps/web/src/components/ChatView.tsx | 4 +- 3 files changed, 245 insertions(+), 39 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 869f440b1d5..57280e6c44e 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -67,6 +67,7 @@ const adapter = { interface HarnessOptions { readonly createWorktree?: GitWorkflow.GitWorkflowService["Service"]["createWorktree"]; + readonly renameBranch?: GitWorkflow.GitWorkflowService["Service"]["renameBranch"]; readonly runSetup?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"]; readonly generateTitle?: TextGeneration.TextGeneration["Service"]["generateThreadTitle"]; readonly generateBranchName?: TextGeneration.TextGeneration["Service"]["generateBranchName"]; @@ -87,11 +88,14 @@ function makeHarness(options: HarnessOptions = {}) { const outbox = EffectOutbox.layer.pipe(Layer.provide(database)); const createWorktree = vi.fn( options.createWorktree ?? - (() => + ((input) => Effect.succeed({ - worktree: { path: "/repo-worktrees/feature", refName: "feature", headSha: "abc" }, + worktree: { path: "/repo-worktrees/feature", refName: input.newRefName, headSha: "abc" }, } as never)), ); + const renameBranch = vi.fn( + options.renameBranch ?? ((input) => Effect.succeed({ branch: input.newBranch })), + ); const runSetup = vi.fn( options.runSetup ?? (() => Effect.succeed({ status: "no-script" as const })), ); @@ -113,6 +117,7 @@ function makeHarness(options: HarnessOptions = {}) { }), Layer.mock(GitWorkflow.GitWorkflowService)({ createWorktree, + renameBranch, fetchRemote: () => Effect.void, removeWorktree: () => Effect.void, resolveRemoteTrackingCommit: () => @@ -154,6 +159,7 @@ function makeHarness(options: HarnessOptions = {}) { return { layer: Layer.mergeAll(launch, threadManagement, titleRegeneration, outbox, database), createWorktree, + renameBranch, generateBranchName, generateThreadTitle, runSetup, @@ -721,6 +727,174 @@ it.effect("falls back when the source control writer is unavailable", () => }), ); +it.effect("names the worktree itself when the client provides no branch", () => + Effect.gen(function* () { + const harness = makeHarness(); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:server-named-branch", + thread: "thread:launch:server-named-branch", + message: "Build the feature", + workspace: { type: "worktree", baseRef: "main" }, + }), + ); + yield* waitUntil(() => Effect.sync(() => harness.createWorktree.mock.calls.length === 1)); + assert.match( + harness.createWorktree.mock.calls[0]?.[0].newRefName ?? "", + /^t3code\/[0-9a-f]{8}$/u, + ); + yield* waitUntil(() => + threads + .getThreadProjection(launched.threadId) + .pipe(Effect.map((projection) => projection.thread.branch === "generated-branch")), + ); + }).pipe(Effect.provide(harness.layer)); + }), +); + +it.effect("renames a temporary t3code/ branch off the provisioning critical path", () => + Effect.gen(function* () { + const branchNameStarted = yield* Deferred.make(); + const allowBranchName = yield* Deferred.make(); + const harness = makeHarness({ + createWorktree: (input) => + Effect.succeed({ + worktree: { path: "/repo-worktrees/temp", refName: input.newRefName, headSha: "abc" }, + } as never), + generateBranchName: () => + Deferred.succeed(branchNameStarted, undefined).pipe( + Effect.andThen(Deferred.await(allowBranchName)), + Effect.as({ branch: "generated-branch" }), + ), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:temp-branch", + thread: "thread:launch:temp-branch", + message: "Build the feature", + workspace: { type: "worktree", baseRef: "main", branch: "t3code/abcd1234" }, + }), + ); + yield* Deferred.await(branchNameStarted); + assert.equal(harness.createWorktree.mock.calls[0]?.[0].newRefName, "t3code/abcd1234"); + yield* waitUntil(() => + threads + .getThreadProjection(launched.threadId) + .pipe(Effect.map((projection) => projection.runs[0]?.status === "starting")), + ); + assert.equal( + (yield* threads.getThreadProjection(launched.threadId)).thread.branch, + "t3code/abcd1234", + ); + yield* Deferred.succeed(allowBranchName, undefined); + yield* waitUntil(() => + threads + .getThreadProjection(launched.threadId) + .pipe(Effect.map((projection) => projection.thread.branch === "generated-branch")), + ); + assert.deepEqual(harness.renameBranch.mock.calls[0]?.[0], { + cwd: "/repo-worktrees/temp", + oldBranch: "t3code/abcd1234", + newBranch: "generated-branch", + }); + }).pipe(Effect.provide(harness.layer)); + }), +); + +it.effect("keeps an explicit branch name instead of generating one", () => + Effect.gen(function* () { + const harness = makeHarness(); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + yield* launches.launch( + launchInput({ + command: "command:launch:explicit-branch", + thread: "thread:launch:explicit-branch", + message: "Build the feature", + workspace: { type: "worktree", baseRef: "main", branch: "my-feature" }, + }), + ); + yield* waitUntil(() => Effect.sync(() => harness.createWorktree.mock.calls.length === 1)); + assert.equal(harness.generateBranchName.mock.calls.length, 0); + assert.equal(harness.createWorktree.mock.calls[0]?.[0].newRefName, "my-feature"); + }).pipe(Effect.provide(harness.layer)); + }), +); + +it.effect("keeps the temporary branch when branch generation fails", () => + Effect.gen(function* () { + const harness = makeHarness({ + createWorktree: (input) => + Effect.succeed({ + worktree: { path: "/repo-worktrees/temp", refName: input.newRefName, headSha: "abc" }, + } as never), + generateBranchName: () => Effect.die("branch generation is down"), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:branch-fallback", + thread: "thread:launch:branch-fallback", + message: "Build the feature", + workspace: { type: "worktree", baseRef: "main", branch: "t3code/abcd1234" }, + }), + ); + yield* waitUntil(() => Effect.sync(() => harness.generateBranchName.mock.calls.length === 1)); + assert.equal(harness.createWorktree.mock.calls[0]?.[0].newRefName, "t3code/abcd1234"); + yield* waitUntil(() => + threads + .getThreadProjection(launched.threadId) + .pipe(Effect.map((projection) => projection.runs[0]?.status === "starting")), + ); + assert.equal(harness.renameBranch.mock.calls.length, 0); + assert.equal( + (yield* threads.getThreadProjection(launched.threadId)).thread.branch, + "t3code/abcd1234", + ); + }).pipe(Effect.provide(harness.layer)); + }), +); + +it.effect("renames a temporary branch on an existing worktree to a generated name", () => + Effect.gen(function* () { + const harness = makeHarness(); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:existing-worktree-rename", + thread: "thread:launch:existing-worktree-rename", + message: "Build the feature", + workspace: { + type: "existing_worktree", + worktreePath: "/repo-worktrees/t3code-abcd1234", + branch: "t3code/abcd1234", + }, + }), + ); + yield* waitUntil(() => + threads + .getThreadProjection(launched.threadId) + .pipe(Effect.map((projection) => projection.thread.branch === "generated-branch")), + ); + assert.deepEqual(harness.renameBranch.mock.calls[0]?.[0], { + cwd: "/repo-worktrees/t3code-abcd1234", + oldBranch: "t3code/abcd1234", + newBranch: "generated-branch", + }); + }).pipe(Effect.provide(harness.layer)); + }), +); + for (const failurePoint of ["worktree", "setup"] as const) { it.effect( `${failurePoint} failure keeps the thread and message visible and emits failure items`, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 1e095d6bac4..97f9daba388 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -21,6 +21,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import { buildTemporaryWorktreeBranchName, isTemporaryWorktreeBranch } from "@t3tools/shared/git"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; import * as ProjectService from "../project/ProjectService.ts"; @@ -31,6 +32,7 @@ import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as IdAllocator from "./IdAllocator.ts"; import { makeProviderFailure } from "./ProviderFailure.ts"; +import { randomUuidV4 } from "./RandomUuid.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; export type ThreadLaunchWorkspaceStrategy = @@ -112,16 +114,6 @@ export class ThreadLaunchService extends Context.Service< const isThreadLaunchError = Schema.is(ThreadLaunchError); -function fallbackBranchName(threadId: ThreadId): string { - const suffix = String(threadId) - .split(":") - .at(-1) - ?.replace(/[^a-zA-Z0-9-]+/gu, "-") - .replace(/^-+|-+$/gu, "") - .slice(0, 16); - return `thread-${suffix || "new"}`; -} - function failureDetail(error: unknown): string { if (isThreadLaunchError(error)) { const cause = error.cause; @@ -200,30 +192,37 @@ export const make = Effect.gen(function* () { ); const initialMessage = input.initialMessage; - let branch = - input.workspaceStrategy.type === "worktree" && - input.workspaceStrategy.branch === undefined && - initialMessage !== undefined - ? yield* Effect.gen(function* () { - const settings = yield* serverSettings.getSettings; - const modelSelection = - settings.sourceControlWriterModelSelection === null - ? settings.textGenerationModelSelection - : ServerSettings.resolveSourceControlWriterModelSelection( - settings, - yield* providerRegistry.getProviders, - ); - return yield* textGeneration - .generateBranchName({ - cwd: project.workspaceRoot, - message: initialMessage.text, - attachments: initialMessage.attachments, - modelSelection, - }) - .pipe(Effect.map((result) => result.branch)); - }).pipe(Effect.mapError(mapError(input, "generate-metadata", threadId))) - : (input.workspaceStrategy.branch ?? - (input.workspaceStrategy.type === "worktree" ? fallbackBranchName(threadId) : null)); + const generateBranchNameFor = (cwd: string, message: ThreadLaunchInitialMessage) => + Effect.gen(function* () { + const settings = yield* serverSettings.getSettings; + const modelSelection = + settings.sourceControlWriterModelSelection === null + ? settings.textGenerationModelSelection + : ServerSettings.resolveSourceControlWriterModelSelection( + settings, + yield* providerRegistry.getProviders, + ); + return yield* textGeneration + .generateBranchName({ + cwd, + message: message.text, + attachments: message.attachments, + modelSelection, + }) + .pipe(Effect.map((result) => result.branch)); + }); + + // The server owns worktree naming: without an explicit branch, provision + // under a temporary `t3code/` name so the worktree never waits on + // name generation, then rename in the background below. + const requestedBranch = input.workspaceStrategy.branch; + let branch: string | null; + if (input.workspaceStrategy.type === "worktree" && requestedBranch === undefined) { + const uuid = yield* randomUuidV4; + branch = buildTemporaryWorktreeBranchName(() => uuid.replaceAll("-", "")); + } else { + branch = requestedBranch ?? null; + } let worktreePath = input.workspaceStrategy.type === "existing_worktree" ? input.workspaceStrategy.worktreePath @@ -279,6 +278,41 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(mapError(input, "update-thread", threadId))); + // Rename temporary branches (server-invented above, or sent by clients + // that name worktrees themselves) in the background so generation latency + // never delays provisioning or the provider turn. The temporary name + // simply sticks if generation or the rename fails. + if ( + worktreePath !== null && + branch !== null && + initialMessage !== undefined && + isTemporaryWorktreeBranch(branch) + ) { + const oldBranch = branch; + const worktreeCwd = worktreePath; + yield* generateBranchNameFor(worktreeCwd, initialMessage).pipe( + Effect.flatMap((newBranch) => git.renameBranch({ cwd: worktreeCwd, oldBranch, newBranch })), + Effect.flatMap((renamed) => + threads.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`${input.commandId}:branch-rename`), + threadId, + branch: renamed.branch, + worktreePath: worktreeCwd, + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("Thread worktree branch rename failed", { + commandId: input.commandId, + threadId, + oldBranch, + cause, + }), + ), + Effect.forkIn(preparationScope), + ); + } + const cwd = worktreePath ?? project.workspaceRoot; if (runId !== null) { yield* threads diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 57b55328583..7f492b72460 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -123,7 +123,6 @@ import { import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; -import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { useElementWidth } from "../hooks/useElementWidth"; import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize"; @@ -167,7 +166,7 @@ import { TriangleAlertIcon, WifiOffIcon, } from "lucide-react"; -import { cn, randomHex } from "~/lib/utils"; +import { cn } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -5223,7 +5222,6 @@ function ChatViewContent(props: ChatViewProps) { prepareWorktree: { projectCwd: activeProject.workspaceRoot, baseBranch: baseBranchForWorktree, - branch: buildTemporaryWorktreeBranchName(randomHex), ...(startFromOrigin ? { startFromOrigin: true } : {}), }, runSetupScript: true,