From 7f1b666f21a882bad9b95b99e4297b2193ab0165 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Fri, 14 Aug 2026 19:46:50 -0700 Subject: [PATCH 1/2] fix(thread-launch): launch queued threads without mounted panes - Mark bridge launch failures on the thread runtime - Cover queued launches and failure states with tests --- .../actions/threadLaunchActions.test.ts | 142 +++++++++++++++++- src/renderer/actions/threadLaunchActions.ts | 81 +++++++--- 2 files changed, 199 insertions(+), 24 deletions(-) diff --git a/src/renderer/actions/threadLaunchActions.test.ts b/src/renderer/actions/threadLaunchActions.test.ts index aebe2cae9..46168e31a 100644 --- a/src/renderer/actions/threadLaunchActions.test.ts +++ b/src/renderer/actions/threadLaunchActions.test.ts @@ -13,7 +13,7 @@ function deferred() { const mocks = vi.hoisted(() => { const appState = { updateProjectDraftConfig: vi.fn<(projectId: string, config: unknown) => void>(), - view: { kind: "home" as const }, + view: { kind: "home" } as { kind: string; panes?: string[]; activeGroupId?: string }, projects: [] as Project[], threads: [] as Thread[], provisioningWorktreeThreadIds: {} as Record, @@ -189,11 +189,14 @@ describe("startThreadFromDraft host transport", () => { id: values.threadId ?? "local-thread", projectId: values.projectId ?? localProject.id, archived: false, + config: values.config ?? {}, ...(values.presentationMode ? { presentationMode: values.presentationMode } : {}), ...(values.remoteServerId ? { remoteServerId: values.remoteServerId } : {}), ...(values.remoteId ? { remoteId: values.remoteId } : {}), } as Thread; mocks.appState.threads = [thread]; + // The real createThread focuses the new thread's pane. + mocks.appState.view = { kind: "thread", panes: [thread.id] }; if (values.worktreeProvisioning) { mocks.appState.provisioningWorktreeThreadIds[thread.id] = true; } @@ -282,11 +285,15 @@ describe("startThreadFromDraft host transport", () => { "C:\\shared-worktrees\\feature", "feature", ); - expect(mocks.appState.queueThreadLaunch).toHaveBeenCalledWith( - "local-thread", - "build it", - undefined, - optimisticItemId, + expect(mocks.appState.queueThreadLaunch).not.toHaveBeenCalled(); + expect(mocks.bridge.startThread).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: "local-thread", + prompt: "build it", + projectLocation: { kind: "windows", path: "C:\\shared-worktrees\\feature" }, + userMessageItemId: optimisticItemId, + initialSize: expect.objectContaining({ cols: expect.any(Number) }), + }), ); expect(mocks.primeWorktreeGitState).toHaveBeenCalledWith( localProject, @@ -299,6 +306,129 @@ describe("startThreadFromDraft host transport", () => { ); }); + it("launches inline when the thread's pane was closed during worktree provisioning", async () => { + let resolveWorktree!: (result: { path: string; changesTransferred?: boolean }) => void; + mocks.createWorktree.mockReturnValue( + new Promise((resolve) => { + resolveWorktree = resolve; + }), + ); + + const launch = startThreadFromDraft(localProject, { + agentKind: "codex", + config: { model: "gpt-5.6" }, + prompt: "build it", + presentationMode: "gui", + worktreeBranch: "feature", + worktreeIsNewBranch: true, + }); + const optimisticStartCall = mocks.appState.applyRuntimeEvent.mock.calls[0]; + if (!optimisticStartCall) throw new Error("Expected an optimistic user message event"); + const optimisticItemId = (optimisticStartCall[1] as { itemId?: string }).itemId; + + // The user switched to another thread while the worktree was provisioning, + // so no mounted ThreadView will ever consume a queued launch. + mocks.appState.view = { kind: "thread", panes: ["another-thread"] }; + resolveWorktree({ path: "C:\\shared-worktrees\\feature" }); + await launch; + + expect(mocks.appState.queueThreadLaunch).not.toHaveBeenCalled(); + expect(mocks.bridge.startThread).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: "local-thread", + prompt: "build it", + projectLocation: { kind: "windows", path: "C:\\shared-worktrees\\feature" }, + userMessageItemId: optimisticItemId, + initialSize: expect.objectContaining({ cols: expect.any(Number) }), + }), + ); + expect(mocks.runWorktreeSetupScript).toHaveBeenCalledWith( + localProject, + "C:\\shared-worktrees\\feature", + "pnpm install", + ); + }); + + it("marks the thread failed when the inline launch cannot start", async () => { + mocks.bridge.startThread.mockRejectedValue(new Error("spawn failed")); + let resolveWorktree!: (result: { path: string; changesTransferred?: boolean }) => void; + mocks.createWorktree.mockReturnValue( + new Promise((resolve) => { + resolveWorktree = resolve; + }), + ); + + const launch = startThreadFromDraft(localProject, { + agentKind: "codex", + config: { model: "gpt-5.6" }, + prompt: "build it", + presentationMode: "gui", + worktreeBranch: "feature", + worktreeIsNewBranch: true, + }); + mocks.appState.view = { kind: "thread", panes: ["another-thread"] }; + resolveWorktree({ path: "C:\\shared-worktrees\\feature" }); + await expect(launch).rejects.toThrow("spawn failed"); + + expect(mocks.appState.queueThreadLaunch).not.toHaveBeenCalled(); + expect(mocks.appState.applyRuntimeEvent).toHaveBeenCalledWith("local-thread", { + type: "error", + threadId: "local-thread", + message: "spawn failed", + }); + expect(mocks.appState.updateThreadRuntime).toHaveBeenCalledWith("local-thread", { + status: "error", + attention: "error", + errorMessage: "spawn failed", + canResumeWithConfig: false, + }); + expect(mocks.performWorktreeRemoval).not.toHaveBeenCalled(); + }); + + it("launches a local non-worktree thread inline over the bridge", async () => { + await startThreadFromDraft(localProject, { + agentKind: "codex", + config: { model: "gpt-5.6" }, + prompt: "build it", + presentationMode: "gui", + }); + + expect(mocks.appState.queueThreadLaunch).not.toHaveBeenCalled(); + expect(mocks.bridge.startThread).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: "local-thread", + prompt: "build it", + projectLocation: localProject.location, + initialSize: expect.objectContaining({ cols: expect.any(Number) }), + }), + ); + }); + + it("marks a local non-worktree thread failed when the bridge launch fails", async () => { + mocks.bridge.startThread.mockRejectedValue(new Error("spawn failed")); + + await expect( + startThreadFromDraft(localProject, { + agentKind: "codex", + config: { model: "gpt-5.6" }, + prompt: "build it", + presentationMode: "gui", + }), + ).rejects.toThrow("spawn failed"); + + expect(mocks.appState.applyRuntimeEvent).toHaveBeenCalledWith("local-thread", { + type: "error", + threadId: "local-thread", + message: "spawn failed", + }); + expect(mocks.appState.updateThreadRuntime).toHaveBeenCalledWith("local-thread", { + status: "error", + attention: "error", + errorMessage: "spawn failed", + canResumeWithConfig: false, + }); + }); + it("shows a provisioning failure on the thread opened for a new local worktree", async () => { mocks.createWorktree.mockRejectedValue(new Error("Branch already exists")); diff --git a/src/renderer/actions/threadLaunchActions.ts b/src/renderer/actions/threadLaunchActions.ts index 40b2e685f..fd23878bc 100644 --- a/src/renderer/actions/threadLaunchActions.ts +++ b/src/renderer/actions/threadLaunchActions.ts @@ -10,8 +10,9 @@ import type { ThreadConfig, ThreadPresentationMode, } from "@/shared/contracts"; -import { resolveMcpLaunchSnapshot } from "@/shared/contracts"; +import { DEFAULT_TERMINAL_SIZE, resolveMcpLaunchSnapshot } from "@/shared/contracts"; import { isHomeProject, isHomeProjectId } from "@/shared/homeScope"; +import { resolveProjectLocation } from "@/shared/worktree"; import { friendlyError } from "@/shared/messages"; import { buildPromptContentBlocks } from "@/shared/promptContent"; import { titlePromptFromSegments } from "@/shared/threadTitle"; @@ -311,18 +312,7 @@ export async function startThreadFromDraft( await performWorktreeRemoval(project, worktreePath, worktreeBranch); return; } - const message = friendlyError(error); - store.applyRuntimeEvent(pendingThread.id, { - type: "error", - threadId: pendingThread.id, - message, - }); - store.updateThreadRuntime(pendingThread.id, { - status: "error", - attention: "error", - errorMessage: message, - canResumeWithConfig: false, - }); + markThreadLaunchFailed(pendingThread.id, error); throw error; } if (useAppStore.getState().threads.some((thread) => thread.id === pendingThread.id)) { @@ -330,7 +320,30 @@ export async function startThreadFromDraft( } } else { store.setThreadWorktree(pendingThread.id, worktreePath, worktreeBranch); - store.queueThreadLaunch(pendingThread.id, prompt, segments, pendingUserMessageItemId); + // Launch inline, never via the view-consumed launch queue: a queued + // launch fires only when a mounted ThreadView consumes it, so switching + // or closing the pane while the worktree provisions would leave the + // agent silently never started. The launch must not depend on the view. + const launchThread = + useAppStore.getState().threads.find((thread) => thread.id === pendingThread.id) ?? + pendingThread; + try { + await performInitialThreadLaunch({ + thread: launchThread, + projectLocation: resolveProjectLocation(project.location, worktreePath), + prompt, + ...(segments ? { segments } : {}), + ...(pendingUserMessageItemId ? { userMessageItemId: pendingUserMessageItemId } : {}), + initialSize: DEFAULT_TERMINAL_SIZE, + }); + } catch (error) { + if (!useAppStore.getState().threads.some((thread) => thread.id === pendingThread.id)) { + await performWorktreeRemoval(project, worktreePath, worktreeBranch); + return; + } + markThreadLaunchFailed(pendingThread.id, error); + throw error; + } } } else { await host.startThread({ @@ -402,11 +415,26 @@ function threadLaunchHost(project: Project): ThreadLaunchHostTransport { return { setupRunsOnHost: false, - startThread: (launch) => { + startThread: async (launch) => { const thread = createThreadRow(launch); - const store = useAppStore.getState(); - store.queueThreadLaunch(thread.id, launch.prompt, launch.segments); - return Promise.resolve("started"); + // Launch inline, never via the view-consumed launch queue — the launch + // must not depend on which pane is mounted (see the worktree path above). + try { + await performInitialThreadLaunch({ + thread, + projectLocation: resolveProjectLocation(launch.project.location, launch.worktreePath), + prompt: launch.prompt, + ...(launch.segments ? { segments: launch.segments } : {}), + ...(launch.userMessageItemId ? { userMessageItemId: launch.userMessageItemId } : {}), + initialSize: DEFAULT_TERMINAL_SIZE, + }); + } catch (error) { + if (useAppStore.getState().threads.some((row) => row.id === thread.id)) { + markThreadLaunchFailed(thread.id, error); + } + throw error; + } + return "started"; }, }; } @@ -455,6 +483,23 @@ function createThreadRow(launch: ThreadLaunchRequest): Thread { return thread; } +/** Surface a failed launch on the thread row (error item + error status). */ +function markThreadLaunchFailed(threadId: string, error: unknown): void { + const store = useAppStore.getState(); + const message = friendlyError(error); + store.applyRuntimeEvent(threadId, { + type: "error", + threadId, + message, + }); + store.updateThreadRuntime(threadId, { + status: "error", + attention: "error", + errorMessage: message, + canResumeWithConfig: false, + }); +} + function appendOptimisticInitialUserMessage( thread: Thread, prompt: string, From 7e896ba254ee5e4ecc0103d1180616c297b52abb Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Fri, 14 Aug 2026 19:56:10 -0700 Subject: [PATCH 2/2] Fix direct launch hydration race --- src/renderer/app.test.tsx | 29 ++++++++++++++++------ src/renderer/hooks/useAppHydration.ts | 5 ++++ src/renderer/state/appStore.test.ts | 31 ++++++++++++++++++++++++ src/renderer/state/slices/threadSlice.ts | 8 ++++-- 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/src/renderer/app.test.tsx b/src/renderer/app.test.tsx index 509efef8e..fa06124a9 100644 --- a/src/renderer/app.test.tsx +++ b/src/renderer/app.test.tsx @@ -46,6 +46,9 @@ const { worktreeBasePath: "", wslWorktreeBasePath: "", workspaces: [] as Workspace[], + mcpServers: [], + disabledBuiltInMcpServers: {}, + disabledBuiltInMcpTools: {}, }, }, quickComposerSubmitListeners: quickListeners, @@ -391,6 +394,7 @@ vi.mock("./state/sharedSettingsStore", () => ({ { getState: () => ({ ...sharedSettingsState.current, + pushRecentModel: () => undefined, setThemeMode: () => undefined, }), }, @@ -893,7 +897,7 @@ describe("App", () => { expect(useExperimentStore.getState().experiments).toEqual({}); }); - it("creates and queues the thread submitted by the quick composer", async () => { + it("creates and launches the thread submitted by the quick composer", async () => { useAppStore.persist.hasHydrated = vi.fn<() => boolean>().mockReturnValue(true); useAppStore.persist.onHydrate = vi.fn<() => () => void>(() => () => undefined); useAppStore.persist.onFinishHydration = vi.fn<() => () => void>(() => () => undefined); @@ -927,18 +931,27 @@ describe("App", () => { }); }); - await waitFor(() => { - expect(screen.getByText("sent from overlay")).toHaveAttribute( - "data-pending-launch", - "sent from overlay", - ); - }); + await waitFor(() => expect(bridge.startThread).toHaveBeenCalledTimes(1)); expect(useAppStore.getState().view.kind).toBe("thread"); - expect(useAppStore.getState().threads[0]).toMatchObject({ + const thread = useAppStore.getState().threads[0]; + expect(thread).toMatchObject({ projectId: "project-1", agentKind: "codex", presentationMode: "gui", }); + expect(screen.getByText("sent from overlay")).toHaveAttribute( + "data-pending-launch", + "__none__", + ); + expect(bridge.startThread).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: thread?.id, + projectLocation: { kind: "windows", path: "C:\\repo" }, + prompt: "sent from overlay", + segments: [{ kind: "text", content: "sent from overlay" }], + presentationMode: "gui", + }), + ); }); it("mirrors a remotely started thread without queueing a duplicate launch", async () => { diff --git a/src/renderer/hooks/useAppHydration.ts b/src/renderer/hooks/useAppHydration.ts index 7be17ac6d..5539f2537 100644 --- a/src/renderer/hooks/useAppHydration.ts +++ b/src/renderer/hooks/useAppHydration.ts @@ -127,6 +127,10 @@ export function useAppHydration(options: { runtimeOwner?: boolean } = {}) { purgeStaleArchivedThreads(30); }); + // A user can create a thread while this request is in flight. Scope the + // response to the threads that existed when it began so an older empty + // snapshot cannot mark a fresh direct launch inactive and relaunch it. + const requestedThreadIds = new Set(useAppStore.getState().threads.map((thread) => thread.id)); const snapshotsPromise = readBridge().getThreadSnapshots(); const visibleGuiThreadIds = collectVisibleGuiThreadIds(); @@ -171,6 +175,7 @@ export function useAppHydration(options: { runtimeOwner?: boolean } = {}) { selectedIds.size > 0 ? snapshots.filter((snapshot) => selectedIds.has(snapshot.threadId)) : [], + requestedThreadIds, ); }); } catch (error) { diff --git a/src/renderer/state/appStore.test.ts b/src/renderer/state/appStore.test.ts index f43595cbb..c6ebed53b 100644 --- a/src/renderer/state/appStore.test.ts +++ b/src/renderer/state/appStore.test.ts @@ -883,6 +883,37 @@ describe("appStore runtime config sync", () => { expect(useAppStore.getState().threads[0]?.activeTurnStartedAt).toBe("2026-05-02T08:57:00.000Z"); }); + it("does not reconcile a thread created after the snapshot request began", () => { + const project = useAppStore.getState().addProject({ + kind: "windows", + path: "C:\\repo", + }); + const existingThread = useAppStore.getState().createThread({ + projectId: project.id, + agentKind: "codex", + config: { model: "m" }, + prompt: "existing", + }); + const requestedThreadIds = new Set([existingThread.id]); + const newThread = useAppStore.getState().createThread({ + projectId: project.id, + agentKind: "codex", + config: { model: "m" }, + prompt: "new", + }); + useAppStore.getState().updateThreadRuntime(newThread.id, { + status: "working", + attention: "working", + canResumeWithConfig: false, + }); + + useAppStore.getState().reconcileRuntimeSnapshots([], requestedThreadIds); + + expect( + useAppStore.getState().threads.find((thread) => thread.id === newThread.id)?.status, + ).toBe("working"); + }); + it("markThreadExited finalizes an active turn", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-01T12:00:00.000Z")); diff --git a/src/renderer/state/slices/threadSlice.ts b/src/renderer/state/slices/threadSlice.ts index f71d1bc99..8c9f4c1b1 100644 --- a/src/renderer/state/slices/threadSlice.ts +++ b/src/renderer/state/slices/threadSlice.ts @@ -121,7 +121,10 @@ export interface ThreadSlice { touchThread: (threadId: string) => void; markThreadViewed: (threadId: string) => void; markThreadsViewed: (threadIds: readonly string[]) => void; - reconcileRuntimeSnapshots: (snapshots: ThreadRuntimeSnapshot[]) => void; + reconcileRuntimeSnapshots: ( + snapshots: ThreadRuntimeSnapshot[], + requestedThreadIds?: ReadonlySet, + ) => void; reorderThreads: (sourceId: string, targetId: string, placement: ReorderPlacement) => void; reorderThreadBlock: (blockIds: string[], targetId: string, placement: ReorderPlacement) => void; } @@ -710,7 +713,7 @@ export const createThreadSlice: SliceCreator = (set) => ({ } return changed ? { lastViewedAtByThreadId: next } : {}; }), - reconcileRuntimeSnapshots: (snapshots) => + reconcileRuntimeSnapshots: (snapshots, requestedThreadIds) => set((state) => { const snapshotsById = new Map(snapshots.map((snapshot) => [snapshot.threadId, snapshot])); const runtimeLaunchConfigByThreadId = Object.fromEntries( @@ -810,6 +813,7 @@ export const createThreadSlice: SliceCreator = (set) => ({ } if ( + (requestedThreadIds !== undefined && !requestedThreadIds.has(thread.id)) || thread.status === "inactive" || thread.status === "error" || thread.status === "launching"