From 1f19bb0b363936bc64135539bf66f14338e65a2b Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:53:36 +0200 Subject: [PATCH 1/7] perf(server): speed up worktree task preparation --- apps/server/src/git/GitWorkflowService.ts | 1 + apps/server/src/server.test.ts | 1 + apps/server/src/vcs/GitVcsDriver.ts | 1 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 67 +++++++++++++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 35 +++++++--- apps/server/src/ws.ts | 1 + 6 files changed, 95 insertions(+), 11 deletions(-) diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 5e3e5b0420f2..530bd372af04 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -73,6 +73,7 @@ export class GitWorkflowService extends Context.Service< readonly fetchRemote: (input: { readonly cwd: string; readonly remoteName: string; + readonly refName?: string; }) => Effect.Effect; readonly remoteExists: (input: { readonly cwd: string; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index af934c59d480..d1cef2651683 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -10655,6 +10655,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(fetchRemote.mock.calls[0]?.[0], { cwd: "/tmp/project", remoteName: "origin", + refName: "main", }); assert.deepEqual(remoteBranchExists.mock.calls[0]?.[0], { cwd: "/tmp/project", diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 9b25e915973c..104424999223 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -201,6 +201,7 @@ export interface GitFetchRemoteTrackingBranchInput { export interface GitFetchRemoteInput { cwd: string; remoteName: string; + refName?: string; } export interface GitRemoteExistsInput { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 4f1f0204a4ed..9fedb3a02de2 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1553,6 +1553,59 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { + it.effect("uses parallel checkout without skipping filters or hooks", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["config", "filter.test.smudge", "sed s/original/filtered/g"]); + yield* writeTextFile(cwd, ".gitattributes", "asset.txt filter=test\n"); + yield* writeTextFile(cwd, "asset.txt", "original\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "filtered asset"]); + yield* writeTextFile( + cwd, + ".git/hooks/post-checkout", + "#!/bin/sh\ngit config checkout.workers > checkout-workers\nexit 0\n", + ); + yield* fs.chmod(path.join(cwd, ".git/hooks/post-checkout"), 0o755); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "parallel"); + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/parallel", + baseRefName: initialBranch, + }); + + assert.equal(yield* fs.readFileString(path.join(worktreePath, "checkout-workers")), "0\n"); + assert.equal(yield* fs.readFileString(path.join(worktreePath, "asset.txt")), "filtered\n"); + assert.equal( + yield* git(worktreePath, ["rev-parse", "HEAD"]), + yield* git(cwd, ["rev-parse", "HEAD"]), + ); + assert.equal( + yield* git(cwd, ["config", "branch.feature/parallel.gh-merge-base"]), + initialBranch, + ); + yield* git(cwd, ["config", "checkout.workers", "1"]); + const configuredPath = path.join(yield* makeTmpDir("git-worktrees-"), "configured"); + yield* driver.createWorktree({ + cwd, + path: configuredPath, + refName: initialBranch, + newRefName: "feature/configured", + }); + assert.equal( + yield* fs.readFileString(path.join(configuredPath, "checkout-workers")), + "1\n", + ); + }), + ); + // NTFS rejects a newline in a file name, so there is nothing to preserve there. it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( "preserves newline characters in worktree paths when listing refs", @@ -1914,8 +1967,20 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const remoteHead = yield* git(peer, ["rev-parse", "HEAD"]); assert.notEqual(beforeFetch, remoteHead); + yield* git(peer, ["push", "origin", "HEAD:refs/heads/unrelated"]); const driver = yield* GitVcsDriver.GitVcsDriver; - yield* driver.fetchRemote({ cwd, remoteName: "origin" }); + yield* driver.fetchRemote({ + cwd, + remoteName: "origin", + refName: `origin/${initialBranch}`, + }); + assert.isFalse( + yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "unrelated" }), + ); + yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: "local-only" }); + assert.isTrue( + yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "unrelated" }), + ); assert.equal( yield* driver.remoteBranchExists({ diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index d371e63617f6..4ff055ec30fc 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3019,10 +3019,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] : ["worktree", "add", worktreePath, input.refName]; - yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { - fallbackErrorDetail: "git worktree add failed", - timeoutMs: WORKTREE_ADD_TIMEOUT_MS, - }); + const checkoutWorkers = (yield* readConfigValue(input.cwd, "checkout.workers")) ?? "0"; + yield* executeGit( + "GitVcsDriver.createWorktree", + input.cwd, + ["-c", `checkout.workers=${checkoutWorkers}`, ...args], + { + fallbackErrorDetail: "git worktree add failed", + timeoutMs: WORKTREE_ADD_TIMEOUT_MS, + }, + ); // `git worktree add` leaves submodules empty, so a repo that keeps agent // skills, tooling or source in one gets a worktree that is quietly missing @@ -3180,15 +3186,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const fetchRemote: GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"] = Effect.fn("fetchRemote")( function* (input) { + const args = ["fetch", "--quiet", input.remoteName]; + const options = { + env: STATUS_UPSTREAM_REFRESH_ENV, + fallbackErrorDetail: `git fetch ${input.remoteName} failed`, + }; + const fetchAll = executeGit("GitVcsDriver.fetchRemote", input.cwd, args, options); + if (input.refName === undefined) { + return yield* fetchAll.pipe(Effect.asVoid); + } + const branch = + parseRemoteRefWithRemoteNames(input.refName, [input.remoteName])?.branchName ?? + input.refName; yield* executeGit( "GitVcsDriver.fetchRemote", input.cwd, - ["fetch", "--quiet", input.remoteName], - { - env: STATUS_UPSTREAM_REFRESH_ENV, - fallbackErrorDetail: `git fetch ${input.remoteName} failed`, - }, - ); + [...args, `+refs/heads/${branch}:refs/remotes/${input.remoteName}/${branch}`], + options, + ).pipe(Effect.catch(() => fetchAll)); }, ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1f960c488d8a..34cc810d3c33 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1141,6 +1141,7 @@ const makeWsRpcLayer = ( yield* gitWorkflow.fetchRemote({ cwd: prepareWorktree.projectCwd, remoteName: "origin", + refName: prepareWorktree.baseBranch, }); const remoteBaseExists = yield* gitWorkflow.remoteBranchExists({ cwd: prepareWorktree.projectCwd, From 5fea716c588ab93d7a07d5de2b284cf84325fd3e Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:55:34 +0200 Subject: [PATCH 2/7] fix(web): release the composer while background tasks start --- apps/web/src/components/ChatView.tsx | 144 ++++++++++--------- apps/web/src/composerDraftStore.test.ts | 19 ++- apps/web/src/composerDraftStore.ts | 10 +- apps/web/src/routes/_chat.draft.$draftId.tsx | 1 + 4 files changed, 105 insertions(+), 69 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7994b3196f58..b97b85c4e2ee 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -94,7 +94,7 @@ import { useState, } from "react"; import { flushSync } from "react-dom"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate, useRouter } from "@tanstack/react-router"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import { isMacPlatform } from "../lib/utils"; @@ -285,6 +285,7 @@ import { type DraftThreadEnvMode, finalizePromotedDraftThreadByRef, markPromotedDraftThreadByRef, + useBackgroundDraftSubmissionPending, useComposerDraftStore, DraftId, } from "../composerDraftStore"; @@ -774,6 +775,11 @@ function useLocalDispatchState(input: { localDispatch, ], ); + const backgroundPending = useBackgroundDraftSubmissionPending( + input.activeThread + ? scopeThreadRef(input.activeThread.environmentId, input.activeThread.id) + : null, + ); const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; const beginLocalDispatch = useCallback( (options?: { preparingWorktree?: boolean; submissionIntent?: ComposerSubmissionIntent }) => { @@ -799,8 +805,9 @@ function useLocalDispatchState(input: { localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null, latestUserMessageAt: latestUserMessage?.createdAt ?? null, isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, - isSendBusy: activeLocalDispatch !== null, - backgroundSubmissionPending: localDispatch?.submissionIntent === "background", + isSendBusy: activeLocalDispatch !== null || backgroundPending, + backgroundSubmissionPending: + backgroundPending || localDispatch?.submissionIntent === "background", }; } @@ -1546,6 +1553,7 @@ export default function ChatView(props: ChatViewProps) { ); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); + const router = useRouter(); const citationLocation = useLocation({ select: (location) => ({ href: location.href, @@ -7441,8 +7449,9 @@ export default function ChatView(props: ChatViewProps) { : null; if (backgroundThreadRef) { beginBackgroundDraftSubmissionByRef(backgroundThreadRef); + markPromotedDraftThreadByRef(backgroundThreadRef); } - const startResult = await startThreadTurn({ + const startPromise = startThreadTurn({ environmentId, input: { threadId: threadIdForSend, @@ -7486,9 +7495,49 @@ export default function ChatView(props: ChatViewProps) { createdAt: messageCreatedAt, }, }); + let openedNextDraft = false; + if (backgroundThreadRef) { + try { + openedNextDraft = Boolean( + await handleNewThread( + scopeProjectRef(activeProject.environmentId, activeProject.id), + resolveBackgroundDraftWorkspaceOptions({ + envMode: sendEnvMode, + branch: activeThreadBranch, + startFromOrigin, + }), + ), + ); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Could not open a fresh composer", + description: error instanceof Error ? error.message : undefined, + }), + ); + } + } + const startResult = await startPromise; if (startResult._tag === "Failure") { if (backgroundThreadRef) { + const error = squashAtomCommandFailure(startResult); + if (draftId) setDraftThreadContext(draftId, { promotedTo: null }); clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Background task could not start", + description: + error instanceof Error ? error.message : "Your draft is saved. Open it to retry.", + actionProps: { + children: "Open draft", + onClick: () => { + if (draftId) void navigate({ to: "/draft/$draftId", params: { draftId } }); + }, + }, + }), + ); } failure = startResult; } else { @@ -7502,65 +7551,36 @@ export default function ChatView(props: ChatViewProps) { } acknowledgeActiveThreadWoke(); if (backgroundThreadRef) { - markPromotedDraftThreadByRef(backgroundThreadRef); - try { - const nextDraft = await handleNewThread( - scopeProjectRef(activeProject.environmentId, activeProject.id), - resolveBackgroundDraftWorkspaceOptions({ - envMode: sendEnvMode, - branch: activeThreadBranch, - startFromOrigin, - }), - ); - if (nextDraft) { - finalizePromotedDraftThreadByRef(backgroundThreadRef); - toastManager.add( - stackedThreadToast({ - type: "success", - title: "Started in background", - timeout: 5_000, - actionProps: { - children: "Open", - onClick: () => { - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(backgroundThreadRef), - }); - }, - }, - }), - ); - } else { - clearBackgroundDraftSubmissionByRef(backgroundThreadRef); - } - } catch (error) { + if (openedNextDraft && router.state.location.pathname !== `/draft/${draftId}`) { + finalizePromotedDraftThreadByRef(backgroundThreadRef); + } else { clearBackgroundDraftSubmissionByRef(backgroundThreadRef); - resetLocalDispatch(); - toastManager.add( - stackedThreadToast({ - type: "warning", - title: "Task started in the background", - description: - error instanceof Error - ? `Could not open a fresh composer: ${error.message}` - : "Could not open a fresh composer.", - }), - ); } + toastManager.add( + stackedThreadToast({ + type: "success", + title: "Started in background", + timeout: 5_000, + actionProps: { + children: "Open", + onClick: () => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(backgroundThreadRef), + }); + }, + }, + }), + ); } } } if (failure !== null) { if ( - promptRef.current.length === 0 && - composerImagesRef.current.length === 0 && - composerFilesRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations - .length ?? 0) === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments - .length ?? 0) === 0 + !composerDraftHasUserContent( + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), + ) ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -7592,15 +7612,11 @@ export default function ChatView(props: ChatViewProps) { if (isLocalDraftThread && draftId && wasBootstrapThreadDeleted(error)) { const failedDraftSession = getDraftSession(draftId); if (failedDraftSession?.threadId === threadIdForSend) { - setLogicalProjectDraftThreadId( - failedDraftSession.logicalProjectKey, - scopeProjectRef(failedDraftSession.environmentId, failedDraftSession.projectId), - draftId, - { - threadId: newThreadId(), - createdAt: new Date().toISOString(), - }, - ); + setDraftThreadContext(draftId, { + threadId: newThreadId(), + createdAt: new Date().toISOString(), + promotedTo: null, + }); } } setThreadError( diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 7b44b1b71128..caf9e2d2af28 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -66,6 +66,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { COMPOSER_DRAFT_STORAGE_KEY, + beginBackgroundDraftSubmissionByRef, + clearBackgroundDraftSubmissionByRef, clearComposerDraftsEnvironment, composerDraftHasUserContent, finalizePromotedDraftThreadByRef, @@ -1300,7 +1302,7 @@ describe("composerDraftStore project draft thread mapping", () => { expect(store.getComposerDraft(draftId)?.prompt).toBe("keep this prompt"); }); - it("rotates a failed bootstrap thread id without losing its draft", () => { + it("rotates a failed bootstrap thread id without changing the next draft", () => { const store = useComposerDraftStore.getState(); const retryThreadId = ThreadId.make("thread-retry"); store.setProjectDraftThreadId(projectRef, draftId, { @@ -1314,13 +1316,24 @@ describe("composerDraftStore project draft thread mapping", () => { interactionMode: "plan", }); store.setPrompt(draftId, "keep this prompt"); - markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); + const pendingRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + beginBackgroundDraftSubmissionByRef(pendingRef); + markPromotedDraftThreadByRef(pendingRef); + store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId }); + store.setPrompt(otherDraftId, "second task"); - store.setLogicalProjectDraftThreadId(scopedProjectKey(projectRef), projectRef, draftId, { + store.setDraftThreadContext(draftId, { threadId: retryThreadId, + promotedTo: null, createdAt: "2026-01-01T00:01:00.000Z", }); + clearBackgroundDraftSubmissionByRef(pendingRef); + expect(store.getDraftThreadByProjectRef(projectRef)?.draftId).toBe(otherDraftId); + expect(store.getComposerDraft(otherDraftId)?.prompt).toBe("second task"); + expect(useComposerDraftStore.getState().backgroundSubmissionThreadKeys).not.toHaveProperty( + scopedThreadKey(pendingRef), + ); expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ threadId: retryThreadId, branch: "feature/test", diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index e8c60911caa5..4c93a833d80f 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -545,6 +545,8 @@ interface ComposerDraftStoreState { setDraftThreadContext: ( threadRef: ComposerThreadTarget, options: { + threadId?: ThreadId; + promotedTo?: ScopedThreadRef | null; branch?: string | null; worktreePath?: string | null; projectRef?: ScopedProjectRef; @@ -2755,7 +2757,7 @@ const composerDraftStore = create()( ? "manual" : existing.environmentSelection); const nextDraftThread: DraftThreadState = { - threadId: existing.threadId, + threadId: options.threadId ?? existing.threadId, environmentId: nextProjectRef.environmentId, projectId: nextProjectRef.projectId, logicalProjectKey: existing.logicalProjectKey, @@ -2777,9 +2779,13 @@ const composerDraftStore = create()( envMode: options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, - promotedTo: existing.promotedTo ?? null, + promotedTo: + options.promotedTo === undefined + ? (existing.promotedTo ?? null) + : options.promotedTo, }; const isUnchanged = + nextDraftThread.threadId === existing.threadId && nextDraftThread.environmentId === existing.environmentId && nextDraftThread.projectId === existing.projectId && nextDraftThread.logicalProjectKey === existing.logicalProjectKey && diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index 04cdf3ce8c9b..9d393f27e0bd 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -78,6 +78,7 @@ function DraftChatThreadRouteView() { return ( Date: Sun, 13 Sep 2026 22:08:40 +0200 Subject: [PATCH 3/7] ci: retry Rust toolchain download From 49ec42ead30794b276554a61434cadc599883e85 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:25:24 +0200 Subject: [PATCH 4/7] fix(server): clean up interrupted worktree checkout --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 59 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 29 ++++++++-- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index dd143d291043..878304c07b96 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1557,6 +1557,65 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { + for (const state of ["new", "new-missing", "existing", "existing-missing"] as const) { + it.effect(`cleans up only newly registered worktrees on interruption: ${state}`, () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "interrupted"); + const existing = state.startsWith("existing"); + if (existing) { + yield* git(cwd, ["worktree", "add", "-b", "feature/existing", worktreePath]); + if (state === "existing-missing") { + yield* fs.remove(worktreePath, { recursive: true }); + } + } + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const commandFinished = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const handle = yield* delegate.spawn(command); + return ChildProcess.isStandardCommand(command) && + command.args.includes("worktree") && + command.args.includes("add") + ? ChildProcessSpawner.makeHandle({ + ...handle, + exitCode: handle.exitCode.pipe( + Effect.andThen(Deferred.succeed(commandFinished, undefined)), + Effect.andThen(Effect.never), + ), + }) + : handle; + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(ServerConfigLayer), + ); + const creating = yield* driver + .createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/interrupted", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(commandFinished); + if (state === "new-missing") { + yield* fs.remove(worktreePath, { recursive: true }); + } + yield* Fiber.interrupt(creating); + + assert.equal(yield* fs.exists(worktreePath), state === "existing"); + const registered = yield* git(cwd, ["worktree", "list", "--porcelain", "-z"]); + assert.equal(registered.includes("feature/existing"), existing); + assert.notInclude(registered, "feature/interrupted"); + }), + ); + } + it.effect("uses parallel checkout without skipping filters or hooks", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 5ac2860cc9aa..05358a8f31f1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3043,6 +3043,22 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const onCheckoutProgress = progress?.onCheckoutProgress; const checkoutWorkers = (yield* readConfigValue(input.cwd, "checkout.workers")) ?? "0"; + const registeredWorktrees = yield* runGitStdout( + "GitVcsDriver.createWorktree.registeredPaths", + input.cwd, + ["worktree", "list", "--porcelain", "-z"], + ); + const worktreeAlreadyRegistered = + registeredWorktrees + .split("\0") + .some( + (field) => + field.startsWith("worktree ") && + path.resolve(field.slice("worktree ".length)) === path.resolve(worktreePath), + ) || + (yield* fileSystem + .exists(path.join(worktreePath, ".git")) + .pipe(Effect.orElseSucceed(() => true))); yield* executeGit( "GitVcsDriver.createWorktree", input.cwd, @@ -3064,12 +3080,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } : {}), }, + ).pipe( + Effect.tap(() => progress?.onWorktreeClaimed?.(worktreePath) ?? Effect.void), + Effect.onInterrupt(() => + worktreeAlreadyRegistered + ? Effect.void + : removeWorktree({ cwd: input.cwd, path: worktreePath, force: true }).pipe( + Effect.ignoreCause({ log: true }), + ), + ), ); - if (progress?.onWorktreeClaimed) { - yield* progress.onWorktreeClaimed(worktreePath); - } - // `git worktree add` leaves submodules empty, so a repo that keeps agent // skills, tooling or source in one gets a worktree that is quietly missing // them. Best-effort: the objects are usually already in the parent's From 42a9796261d936a895f09d1064bd69a3c4a09a20 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:41:32 +0200 Subject: [PATCH 5/7] fix(server): identify owned worktrees before cancellation cleanup --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 33 +++++++-- apps/server/src/vcs/GitVcsDriverCore.ts | 71 +++++++++++++------- 2 files changed, 72 insertions(+), 32 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 878304c07b96..689fb9e041e5 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1557,7 +1557,13 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { - for (const state of ["new", "new-missing", "existing", "existing-missing"] as const) { + for (const state of [ + "new", + "new-missing", + "existing", + "existing-missing", + "concurrent", + ] as const) { it.effect(`cleans up only newly registered worktrees on interruption: ${state}`, () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1565,8 +1571,8 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "interrupted"); - const existing = state.startsWith("existing"); - if (existing) { + const existing = state.startsWith("existing") || state === "concurrent"; + if (state.startsWith("existing")) { yield* git(cwd, ["worktree", "add", "-b", "feature/existing", worktreePath]); if (state === "existing-missing") { yield* fs.remove(worktreePath, { recursive: true }); @@ -1576,10 +1582,16 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const commandFinished = yield* Deferred.make(); const spawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { - const handle = yield* delegate.spawn(command); - return ChildProcess.isStandardCommand(command) && + const isWorktreeAdd = + ChildProcess.isStandardCommand(command) && command.args.includes("worktree") && - command.args.includes("add") + command.args.includes("add"); + if (isWorktreeAdd && state === "concurrent") { + yield* git(cwd, ["worktree", "add", "-b", "feature/existing", worktreePath]); + yield* writeTextFile(worktreePath, "uncommitted.txt", "keep these edits"); + } + const handle = yield* delegate.spawn(command); + return isWorktreeAdd ? ChildProcessSpawner.makeHandle({ ...handle, exitCode: handle.exitCode.pipe( @@ -1608,7 +1620,13 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { } yield* Fiber.interrupt(creating); - assert.equal(yield* fs.exists(worktreePath), state === "existing"); + assert.equal(yield* fs.exists(worktreePath), existing && !state.endsWith("missing")); + if (state === "concurrent") { + assert.equal( + yield* fs.readFileString(path.join(worktreePath, "uncommitted.txt")), + "keep these edits", + ); + } const registered = yield* git(cwd, ["worktree", "list", "--porcelain", "-z"]); assert.equal(registered.includes("feature/existing"), existing); assert.notInclude(registered, "feature/interrupted"); @@ -1644,6 +1662,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { baseRefName: initialBranch, }); + assert.notInclude(yield* git(cwd, ["worktree", "list", "--porcelain"]), "locked"); assert.equal(yield* fs.readFileString(path.join(worktreePath, "checkout-workers")), "0\n"); assert.equal(yield* fs.readFileString(path.join(worktreePath, "asset.txt")), "filtered\n"); assert.equal( diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 05358a8f31f1..e24a331d1785 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3036,29 +3036,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const sanitizedBranch = targetBranch.replace(/\//g, "-"); const repoName = path.basename(input.cwd); const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); - const args = input.newRefName - ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] - : ["worktree", "add", worktreePath, input.refName]; + const lockReason = `t3code-create-${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`; + const args = [ + "worktree", + "add", + "--lock", + "--reason", + lockReason, + ...(input.newRefName ? ["-b", input.newRefName] : []), + worktreePath, + input.refName, + ]; const progress = options?.progress; const onCheckoutProgress = progress?.onCheckoutProgress; const checkoutWorkers = (yield* readConfigValue(input.cwd, "checkout.workers")) ?? "0"; - const registeredWorktrees = yield* runGitStdout( - "GitVcsDriver.createWorktree.registeredPaths", - input.cwd, - ["worktree", "list", "--porcelain", "-z"], - ); - const worktreeAlreadyRegistered = - registeredWorktrees - .split("\0") - .some( - (field) => - field.startsWith("worktree ") && - path.resolve(field.slice("worktree ".length)) === path.resolve(worktreePath), - ) || - (yield* fileSystem - .exists(path.join(worktreePath, ".git")) - .pipe(Effect.orElseSucceed(() => true))); yield* executeGit( "GitVcsDriver.createWorktree", input.cwd, @@ -3082,12 +3074,41 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ).pipe( Effect.tap(() => progress?.onWorktreeClaimed?.(worktreePath) ?? Effect.void), - Effect.onInterrupt(() => - worktreeAlreadyRegistered - ? Effect.void - : removeWorktree({ cwd: input.cwd, path: worktreePath, force: true }).pipe( - Effect.ignoreCause({ log: true }), - ), + Effect.onExit((exit) => + Effect.gen(function* () { + if (!Exit.isSuccess(exit)) { + const registeredWorktrees = yield* runGitStdout( + "GitVcsDriver.createWorktree.registeredPaths", + input.cwd, + ["worktree", "list", "--porcelain", "-z"], + ); + const matchingPaths = registeredWorktrees + .split("\0\0") + .filter((record) => + record + .split("\0") + .some( + (field) => + field.startsWith("worktree ") && + path.resolve(field.slice("worktree ".length)) === path.resolve(worktreePath), + ), + ); + if ( + matchingPaths.length !== 1 || + !matchingPaths[0]?.split("\0").includes(`locked ${lockReason}`) + ) { + return; + } + } + yield* runGit("GitVcsDriver.createWorktree.unlock", input.cwd, [ + "worktree", + "unlock", + worktreePath, + ]); + if (Exit.hasInterrupts(exit)) { + yield* removeWorktree({ cwd: input.cwd, path: worktreePath, force: true }); + } + }).pipe(Effect.ignoreCause({ log: true })), ), ); From c986ab93a11164d8ab756f7883a4087fe2b9cff5 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:49:55 +0200 Subject: [PATCH 6/7] test(server): provide captured services in worktree race test --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 689fb9e041e5..275aef722378 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1578,6 +1578,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* fs.remove(worktreePath, { recursive: true }); } } + const baseDriver = yield* GitVcsDriver.GitVcsDriver; const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const commandFinished = yield* Deferred.make(); const spawner = ChildProcessSpawner.make((command) => @@ -1587,8 +1588,14 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { command.args.includes("worktree") && command.args.includes("add"); if (isWorktreeAdd && state === "concurrent") { - yield* git(cwd, ["worktree", "add", "-b", "feature/existing", worktreePath]); - yield* writeTextFile(worktreePath, "uncommitted.txt", "keep these edits"); + yield* git(cwd, ["worktree", "add", "-b", "feature/existing", worktreePath]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, baseDriver), + Effect.orDie, + ); + yield* fs.writeFileString( + path.join(worktreePath, "uncommitted.txt"), + "keep these edits", + ); } const handle = yield* delegate.spawn(command); return isWorktreeAdd From 964a68e205527ffc9b4006bb3fcc07d93307798a Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:10:02 +0200 Subject: [PATCH 7/7] fix(server): narrow worktree optimization and fetch fallback --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 177 ++++++++----------- apps/server/src/vcs/GitVcsDriverCore.ts | 87 ++++----- apps/web/src/components/ChatView.tsx | 174 ++++++++---------- apps/web/src/composerDraftStore.test.ts | 19 +- apps/web/src/composerDraftStore.ts | 10 +- apps/web/src/routes/_chat.draft.$draftId.tsx | 1 - 6 files changed, 188 insertions(+), 280 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 275aef722378..feb83f936a86 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1557,90 +1557,6 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { - for (const state of [ - "new", - "new-missing", - "existing", - "existing-missing", - "concurrent", - ] as const) { - it.effect(`cleans up only newly registered worktrees on interruption: ${state}`, () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "interrupted"); - const existing = state.startsWith("existing") || state === "concurrent"; - if (state.startsWith("existing")) { - yield* git(cwd, ["worktree", "add", "-b", "feature/existing", worktreePath]); - if (state === "existing-missing") { - yield* fs.remove(worktreePath, { recursive: true }); - } - } - const baseDriver = yield* GitVcsDriver.GitVcsDriver; - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const commandFinished = yield* Deferred.make(); - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - const isWorktreeAdd = - ChildProcess.isStandardCommand(command) && - command.args.includes("worktree") && - command.args.includes("add"); - if (isWorktreeAdd && state === "concurrent") { - yield* git(cwd, ["worktree", "add", "-b", "feature/existing", worktreePath]).pipe( - Effect.provideService(GitVcsDriver.GitVcsDriver, baseDriver), - Effect.orDie, - ); - yield* fs.writeFileString( - path.join(worktreePath, "uncommitted.txt"), - "keep these edits", - ); - } - const handle = yield* delegate.spawn(command); - return isWorktreeAdd - ? ChildProcessSpawner.makeHandle({ - ...handle, - exitCode: handle.exitCode.pipe( - Effect.andThen(Deferred.succeed(commandFinished, undefined)), - Effect.andThen(Effect.never), - ), - }) - : handle; - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provide(ServerConfigLayer), - ); - const creating = yield* driver - .createWorktree({ - cwd, - path: worktreePath, - refName: initialBranch, - newRefName: "feature/interrupted", - }) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(commandFinished); - if (state === "new-missing") { - yield* fs.remove(worktreePath, { recursive: true }); - } - yield* Fiber.interrupt(creating); - - assert.equal(yield* fs.exists(worktreePath), existing && !state.endsWith("missing")); - if (state === "concurrent") { - assert.equal( - yield* fs.readFileString(path.join(worktreePath, "uncommitted.txt")), - "keep these edits", - ); - } - const registered = yield* git(cwd, ["worktree", "list", "--porcelain", "-z"]); - assert.equal(registered.includes("feature/existing"), existing); - assert.notInclude(registered, "feature/interrupted"); - }), - ); - } - it.effect("uses parallel checkout without skipping filters or hooks", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1680,18 +1596,23 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["config", "branch.feature/parallel.gh-merge-base"]), initialBranch, ); - yield* git(cwd, ["config", "checkout.workers", "1"]); - const configuredPath = path.join(yield* makeTmpDir("git-worktrees-"), "configured"); - yield* driver.createWorktree({ - cwd, - path: configuredPath, - refName: initialBranch, - newRefName: "feature/configured", - }); - assert.equal( - yield* fs.readFileString(path.join(configuredPath, "checkout-workers")), - "1\n", - ); + for (const [configured, expected] of [ + ["1", "1"], + ["", "0"], + ] as const) { + yield* git(cwd, ["config", "checkout.workers", configured]); + const configuredPath = path.join(yield* makeTmpDir("git-worktrees-"), "configured"); + yield* driver.createWorktree({ + cwd, + path: configuredPath, + refName: initialBranch, + newRefName: `feature/configured-${expected}`, + }); + assert.equal( + yield* fs.readFileString(path.join(configuredPath, "checkout-workers")), + `${expected}\n`, + ); + } }), ); it("parses checkout progress lines from git's stderr", () => { @@ -2094,6 +2015,61 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("remote operations", () => { + for (const failure of ["offline", "auth", "timeout"] as const) { + it.effect(`does not retry a scoped fetch after ${failure}`, () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const started = yield* Deferred.make(); + const attempts: Array> = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) + return yield* Effect.die("unexpected command"); + if (command.args[0] !== "fetch") return yield* delegate.spawn(command); + attempts.push(command.args); + yield* Deferred.succeed(started, undefined); + return ChildProcessSpawner.makeHandle({ + ...makeNonRepositoryHandle(), + exitCode: + failure === "timeout" + ? Effect.never + : Effect.succeed(ChildProcessSpawner.ExitCode(128)), + stderr: Stream.encodeText( + Stream.make( + failure === "auth" + ? "fatal: Authentication failed" + : "fatal: Could not resolve host", + ), + ), + }); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(ServerConfigLayer), + ); + const fetching = yield* driver + .fetchRemote({ cwd, remoteName: "origin", refName: "main" }) + .pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(started); + if (failure === "timeout") { + yield* TestClock.adjust("31 seconds"); + yield* TestClock.adjust("31 seconds"); + } + const result = yield* Fiber.join(fetching); + assert.isTrue(Result.isFailure(result)); + assert.equal(attempts.length, 1); + if (Result.isFailure(result)) { + assert.equal( + result.failure.detail, + failure === "timeout" ? "Git command timed out." : "git fetch origin failed", + ); + } + }), + ); + } + it.effect("creates a worktree from the latest fetched remote commit", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2126,10 +2102,6 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.isFalse( yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "unrelated" }), ); - yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: "local-only" }); - assert.isTrue( - yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "unrelated" }), - ); assert.equal( yield* driver.remoteBranchExists({ @@ -2191,6 +2163,11 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const status = yield* driver.statusDetails(worktreePath); assert.equal(status.aheadCount, 0); assert.equal(status.aheadOfDefaultCount, 0); + + yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: "local-only" }); + assert.isTrue( + yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "unrelated" }), + ); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index e24a331d1785..6974d5baac21 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3036,17 +3036,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const sanitizedBranch = targetBranch.replace(/\//g, "-"); const repoName = path.basename(input.cwd); const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); - const lockReason = `t3code-create-${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`; - const args = [ - "worktree", - "add", - "--lock", - "--reason", - lockReason, - ...(input.newRefName ? ["-b", input.newRefName] : []), - worktreePath, - input.refName, - ]; + const args = input.newRefName + ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] + : ["worktree", "add", worktreePath, input.refName]; const progress = options?.progress; const onCheckoutProgress = progress?.onCheckoutProgress; @@ -3072,46 +3064,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } : {}), }, - ).pipe( - Effect.tap(() => progress?.onWorktreeClaimed?.(worktreePath) ?? Effect.void), - Effect.onExit((exit) => - Effect.gen(function* () { - if (!Exit.isSuccess(exit)) { - const registeredWorktrees = yield* runGitStdout( - "GitVcsDriver.createWorktree.registeredPaths", - input.cwd, - ["worktree", "list", "--porcelain", "-z"], - ); - const matchingPaths = registeredWorktrees - .split("\0\0") - .filter((record) => - record - .split("\0") - .some( - (field) => - field.startsWith("worktree ") && - path.resolve(field.slice("worktree ".length)) === path.resolve(worktreePath), - ), - ); - if ( - matchingPaths.length !== 1 || - !matchingPaths[0]?.split("\0").includes(`locked ${lockReason}`) - ) { - return; - } - } - yield* runGit("GitVcsDriver.createWorktree.unlock", input.cwd, [ - "worktree", - "unlock", - worktreePath, - ]); - if (Exit.hasInterrupts(exit)) { - yield* removeWorktree({ cwd: input.cwd, path: worktreePath, force: true }); - } - }).pipe(Effect.ignoreCause({ log: true })), - ), ); + if (progress?.onWorktreeClaimed) { + yield* progress.onWorktreeClaimed(worktreePath); + } + // `git worktree add` leaves submodules empty, so a repo that keeps agent // skills, tooling or source in one gets a worktree that is quietly missing // them. Best-effort: the objects are usually already in the parent's @@ -3300,12 +3258,35 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const branch = parseRemoteRefWithRemoteNames(input.refName, [input.remoteName])?.branchName ?? input.refName; - yield* executeGit( + const scopedArgs = [ + ...args, + `+refs/heads/${branch}:refs/remotes/${input.remoteName}/${branch}`, + ]; + const result = yield* executeGitWithStableDiagnostics( "GitVcsDriver.fetchRemote", input.cwd, - [...args, `+refs/heads/${branch}:refs/remotes/${input.remoteName}/${branch}`], - options, - ).pipe(Effect.catch(() => fetchAll)); + scopedArgs, + { ...options, allowNonZeroExit: true }, + ); + if (result.exitCode === 0) return; + if ( + result.stderr + .split(/\r?\n/) + .includes(`fatal: couldn't find remote ref refs/heads/${branch}`) + ) { + return yield* fetchAll.pipe(Effect.asVoid); + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.fetchRemote", + cwd: input.cwd, + args: scopedArgs, + }), + detail: options.fallbackErrorDetail, + exitCode: result.exitCode, + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }); }, ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 40fc0a6c8437..cc8e43f46738 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -95,7 +95,7 @@ import { useState, } from "react"; import { flushSync } from "react-dom"; -import { useLocation, useNavigate, useRouter } from "@tanstack/react-router"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import { isMacPlatform } from "../lib/utils"; @@ -286,7 +286,6 @@ import { type DraftThreadEnvMode, finalizePromotedDraftThreadByRef, markPromotedDraftThreadByRef, - useBackgroundDraftSubmissionPending, useComposerDraftStore, DraftId, } from "../composerDraftStore"; @@ -778,11 +777,6 @@ function useLocalDispatchState(input: { localDispatch, ], ); - const backgroundPending = useBackgroundDraftSubmissionPending( - input.activeThread - ? scopeThreadRef(input.activeThread.environmentId, input.activeThread.id) - : null, - ); const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; const beginLocalDispatch = useCallback( (options?: { preparingWorktree?: boolean; submissionIntent?: ComposerSubmissionIntent }) => { @@ -808,9 +802,8 @@ function useLocalDispatchState(input: { localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null, latestUserMessageAt: latestUserMessage?.createdAt ?? null, isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, - isSendBusy: activeLocalDispatch !== null || backgroundPending, - backgroundSubmissionPending: - backgroundPending || localDispatch?.submissionIntent === "background", + isSendBusy: activeLocalDispatch !== null, + backgroundSubmissionPending: localDispatch?.submissionIntent === "background", }; } @@ -1559,7 +1552,6 @@ export default function ChatView(props: ChatViewProps) { ); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); - const router = useRouter(); const citationLocation = useLocation({ select: (location) => ({ href: location.href, @@ -1661,20 +1653,20 @@ export default function ChatView(props: ChatViewProps) { environmentId: EnvironmentId; threadId: ThreadId; ownerKey: string; - } | null>(() => { - const pendingThreadRef = draftThread?.promotedTo ?? routeThreadRef; - return useComposerDraftStore.getState().backgroundSubmissionThreadKeys[ - scopedThreadKey(pendingThreadRef) - ] - ? { ...pendingThreadRef, ownerKey: draftId ?? routeThreadKey } - : null; - }); + } | null>(null); const [heldWorktreeSetup, setHeldWorktreeSetup] = useState(null); // Set by "Work locally": the draft whose restored message should be resent // once the cancelled dispatch has settled and the draft is in local mode. // Keyed by draft id so a bootstrap rotating the thread id keeps it, while // moving to another draft drops it without an effect. const [workLocallyResendDraftId, setWorkLocallyResendDraftId] = useState(null); + // The draft route reuses this component across drafts, so a resend recorded + // for one draft must not fire when the user comes back to it later. + useEffect(() => { + if (workLocallyResendDraftId !== null && workLocallyResendDraftId !== draftId) { + setWorkLocallyResendDraftId(null); + } + }, [draftId, workLocallyResendDraftId]); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< Record> >({}); @@ -7584,9 +7576,8 @@ export default function ChatView(props: ChatViewProps) { : null; if (backgroundThreadRef) { beginBackgroundDraftSubmissionByRef(backgroundThreadRef); - markPromotedDraftThreadByRef(backgroundThreadRef); } - const startPromise = startThreadTurn({ + const startResult = await startThreadTurn({ environmentId, input: { threadId: threadIdForSend, @@ -7630,63 +7621,9 @@ export default function ChatView(props: ChatViewProps) { createdAt: messageCreatedAt, }, }); - let openedNextDraft = false; - if (backgroundThreadRef) { - try { - openedNextDraft = Boolean( - await handleNewThread( - scopeProjectRef(activeProject.environmentId, activeProject.id), - resolveBackgroundDraftWorkspaceOptions({ - envMode: sendEnvMode, - branch: activeThreadBranch, - startFromOrigin, - }), - ), - ); - } catch (error) { - toastManager.add( - stackedThreadToast({ - type: "warning", - title: "Could not open a fresh composer", - description: error instanceof Error ? error.message : undefined, - }), - ); - } - } - if (openedNextDraft && draftId) { - toastManager.add( - stackedThreadToast({ - type: "info", - title: "Starting in background", - actionProps: { - children: "Open draft", - onClick: () => { - void navigate({ to: "/draft/$draftId", params: { draftId } }); - }, - }, - }), - ); - } - const startResult = await startPromise; if (startResult._tag === "Failure") { if (backgroundThreadRef) { - const error = squashAtomCommandFailure(startResult); - if (draftId) setDraftThreadContext(draftId, { promotedTo: null }); clearBackgroundDraftSubmissionByRef(backgroundThreadRef); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Background task could not start", - description: - error instanceof Error ? error.message : "Your draft is saved. Open it to retry.", - actionProps: { - children: "Open draft", - onClick: () => { - if (draftId) void navigate({ to: "/draft/$draftId", params: { draftId } }); - }, - }, - }), - ); } failure = startResult; } else { @@ -7700,36 +7637,65 @@ export default function ChatView(props: ChatViewProps) { } acknowledgeActiveThreadWoke(); if (backgroundThreadRef) { - if (openedNextDraft && router.state.location.pathname !== `/draft/${draftId}`) { - finalizePromotedDraftThreadByRef(backgroundThreadRef); - } else { + markPromotedDraftThreadByRef(backgroundThreadRef); + try { + const nextDraft = await handleNewThread( + scopeProjectRef(activeProject.environmentId, activeProject.id), + resolveBackgroundDraftWorkspaceOptions({ + envMode: sendEnvMode, + branch: activeThreadBranch, + startFromOrigin, + }), + ); + if (nextDraft) { + finalizePromotedDraftThreadByRef(backgroundThreadRef); + toastManager.add( + stackedThreadToast({ + type: "success", + title: "Started in background", + timeout: 5_000, + actionProps: { + children: "Open", + onClick: () => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(backgroundThreadRef), + }); + }, + }, + }), + ); + } else { + clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + } + } catch (error) { clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + resetLocalDispatch(); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Task started in the background", + description: + error instanceof Error + ? `Could not open a fresh composer: ${error.message}` + : "Could not open a fresh composer.", + }), + ); } - toastManager.add( - stackedThreadToast({ - type: "success", - title: "Started in background", - timeout: 5_000, - actionProps: { - children: "Open", - onClick: () => { - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(backgroundThreadRef), - }); - }, - }, - }), - ); } } } if (failure !== null) { if ( - !composerDraftHasUserContent( - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), - ) + promptRef.current.length === 0 && + composerImagesRef.current.length === 0 && + composerFilesRef.current.length === 0 && + composerTerminalContextsRef.current.length === 0 && + (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations + .length ?? 0) === 0 && + (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments + .length ?? 0) === 0 ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -7761,11 +7727,15 @@ export default function ChatView(props: ChatViewProps) { if (isLocalDraftThread && draftId && wasBootstrapThreadDeleted(error)) { const failedDraftSession = getDraftSession(draftId); if (failedDraftSession?.threadId === threadIdForSend) { - setDraftThreadContext(draftId, { - threadId: newThreadId(), - createdAt: new Date().toISOString(), - promotedTo: null, - }); + setLogicalProjectDraftThreadId( + failedDraftSession.logicalProjectKey, + scopeProjectRef(failedDraftSession.environmentId, failedDraftSession.projectId), + draftId, + { + threadId: newThreadId(), + createdAt: new Date().toISOString(), + }, + ); } } setThreadError( diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index caf9e2d2af28..7b44b1b71128 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -66,8 +66,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { COMPOSER_DRAFT_STORAGE_KEY, - beginBackgroundDraftSubmissionByRef, - clearBackgroundDraftSubmissionByRef, clearComposerDraftsEnvironment, composerDraftHasUserContent, finalizePromotedDraftThreadByRef, @@ -1302,7 +1300,7 @@ describe("composerDraftStore project draft thread mapping", () => { expect(store.getComposerDraft(draftId)?.prompt).toBe("keep this prompt"); }); - it("rotates a failed bootstrap thread id without changing the next draft", () => { + it("rotates a failed bootstrap thread id without losing its draft", () => { const store = useComposerDraftStore.getState(); const retryThreadId = ThreadId.make("thread-retry"); store.setProjectDraftThreadId(projectRef, draftId, { @@ -1316,24 +1314,13 @@ describe("composerDraftStore project draft thread mapping", () => { interactionMode: "plan", }); store.setPrompt(draftId, "keep this prompt"); - const pendingRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); - beginBackgroundDraftSubmissionByRef(pendingRef); - markPromotedDraftThreadByRef(pendingRef); - store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId }); - store.setPrompt(otherDraftId, "second task"); + markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); - store.setDraftThreadContext(draftId, { + store.setLogicalProjectDraftThreadId(scopedProjectKey(projectRef), projectRef, draftId, { threadId: retryThreadId, - promotedTo: null, createdAt: "2026-01-01T00:01:00.000Z", }); - clearBackgroundDraftSubmissionByRef(pendingRef); - expect(store.getDraftThreadByProjectRef(projectRef)?.draftId).toBe(otherDraftId); - expect(store.getComposerDraft(otherDraftId)?.prompt).toBe("second task"); - expect(useComposerDraftStore.getState().backgroundSubmissionThreadKeys).not.toHaveProperty( - scopedThreadKey(pendingRef), - ); expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ threadId: retryThreadId, branch: "feature/test", diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 4c93a833d80f..e8c60911caa5 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -545,8 +545,6 @@ interface ComposerDraftStoreState { setDraftThreadContext: ( threadRef: ComposerThreadTarget, options: { - threadId?: ThreadId; - promotedTo?: ScopedThreadRef | null; branch?: string | null; worktreePath?: string | null; projectRef?: ScopedProjectRef; @@ -2757,7 +2755,7 @@ const composerDraftStore = create()( ? "manual" : existing.environmentSelection); const nextDraftThread: DraftThreadState = { - threadId: options.threadId ?? existing.threadId, + threadId: existing.threadId, environmentId: nextProjectRef.environmentId, projectId: nextProjectRef.projectId, logicalProjectKey: existing.logicalProjectKey, @@ -2779,13 +2777,9 @@ const composerDraftStore = create()( envMode: options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, - promotedTo: - options.promotedTo === undefined - ? (existing.promotedTo ?? null) - : options.promotedTo, + promotedTo: existing.promotedTo ?? null, }; const isUnchanged = - nextDraftThread.threadId === existing.threadId && nextDraftThread.environmentId === existing.environmentId && nextDraftThread.projectId === existing.projectId && nextDraftThread.logicalProjectKey === existing.logicalProjectKey && diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index 9d393f27e0bd..04cdf3ce8c9b 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -78,7 +78,6 @@ function DraftChatThreadRouteView() { return (