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,