From 2540f800441fe0e0e0fecec9166b4c706a4d8ae6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 00:07:46 +0000 Subject: [PATCH 01/11] fix(web): messages sent during worktree setup queue instead of being refused --- apps/web/src/components/ChatView.tsx | 68 ++++++++++++++++++---------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1fe19f6bc2f2..5ceca0f5898a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3667,6 +3667,11 @@ export default function ChatView(props: ChatViewProps) { // Server-side workspace preparation: unlike the local-dispatch flag this // survives reloads and shows on remote viewers of the same thread. const activeRunPreparing = activeActivityRun?.status === "preparing"; + // The server queues a message behind a run that is still preparing its + // worktree or starting its agent, so a send during setup waits there and + // starts on its own instead of being refused. + const sendQueuesBehindSetup = + isServerThread && (activeRunPreparing || activeActivityRun?.status === "starting"); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); @@ -4020,11 +4025,13 @@ export default function ChatView(props: ChatViewProps) { // Sends wait for the agent handoff, not for the setup script: an async // script keeps the snapshot running while the agent already works, and a // follow-up must not be held behind a slow install. Before the first - // snapshot arrives the starting session stands in for it. + // snapshot arrives the starting session stands in for it. Once the server + // has the thread, a send queues behind the setup run instead. const worktreeSetupBlocksSend = - worktreeSetup !== null + !sendQueuesBehindSetup && + (worktreeSetup !== null ? worktreeSetup.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup) - : isPreparingWorktree; + : isPreparingWorktree); const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { reportFailure: false, }); @@ -8770,7 +8777,7 @@ export default function ChatView(props: ChatViewProps) { if ( !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || !activeThread || - isSendBusy || + (isSendBusy && !sendQueuesBehindSetup) || isConnecting || isRevertingCheckpoint || !clientSettingsHydrated || @@ -9214,7 +9221,7 @@ export default function ChatView(props: ChatViewProps) { return; } const threadIdForSend = activeThread.id; - const isFirstMessage = !isServerThread || activeMessageCount === 0; + const isFirstMessage = !sendQueuesBehindSetup && (!isServerThread || activeMessageCount === 0); const baseBranchForWorktree = isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath ? activeThreadBranch @@ -9271,9 +9278,11 @@ export default function ChatView(props: ChatViewProps) { resumeCompactionTokens !== null && !keepFullHistory && messageTextForSend.toLowerCase() !== "/compact"; - const turnDispatchMode = compactBeforeSend ? "queue" : dispatchMode; + const turnDispatchMode = compactBeforeSend || sendQueuesBehindSetup ? "queue" : dispatchMode; const shouldQueueBehindActiveRun = - compactBeforeSend || (phase === "running" && dispatchMode === "queue"); + compactBeforeSend || + sendQueuesBehindSetup || + (phase === "running" && dispatchMode === "queue"); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, @@ -9422,21 +9431,26 @@ export default function ChatView(props: ChatViewProps) { ); return; } - beginLocalDispatch({ - preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree), - // Only a draft has a background submission to hide behind its hero. - submissionIntent: - submissionIntent === "background" && !isLocalDraftThread ? "foreground" : submissionIntent, - }); - setWorktreeSetupRef( - multipleModelSelections === null && baseBranchForWorktree - ? { - environmentId: activeThread.environmentId, - threadId: threadIdForSend, - ownerKey: worktreeSetupOwnerKey, - } - : null, - ); + // A send queued behind setup leaves the first send's dispatch and setup card alone. + if (!sendQueuesBehindSetup) { + beginLocalDispatch({ + preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree), + // Only a draft has a background submission to hide behind its hero. + submissionIntent: + submissionIntent === "background" && !isLocalDraftThread + ? "foreground" + : submissionIntent, + }); + setWorktreeSetupRef( + multipleModelSelections === null && baseBranchForWorktree + ? { + environmentId: activeThread.environmentId, + threadId: threadIdForSend, + ownerKey: worktreeSetupOwnerKey, + } + : null, + ); + } const turnAttachmentsPromise = Promise.all( composerAttachmentsSnapshot.map(async (attachment) => { @@ -10107,7 +10121,7 @@ export default function ChatView(props: ChatViewProps) { setDockedDraftHeroThreadKey((currentThreadKey) => currentThreadKey === activeThreadKey ? null : currentThreadKey, ); - resetLocalDispatch(); + if (!sendQueuesBehindSetup) resetLocalDispatch(); } }; @@ -11614,7 +11628,11 @@ export default function ChatView(props: ChatViewProps) { phase={phase} canInterrupt={canInterruptRunningThread} isConnecting={isConnecting} - isSendBusy={isSendBusy || isSavingQueuedEdit || isResuming} + isSendBusy={ + (isSendBusy && !sendQueuesBehindSetup) || + isSavingQueuedEdit || + isResuming + } canResume={resumableRunId !== null || hasHeldQueuedRuns} isRevertingCheckpoint={isRevertingCheckpoint} sendDisabledReason={ @@ -11632,7 +11650,7 @@ export default function ChatView(props: ChatViewProps) { ? "Preparing worktree" : projectCloneSendBlockReason } - isPreparingWorktree={isPreparingWorktree} + isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup} queuedRunsControl={ isServerThread && activeThread ? ( Date: Sat, 10 Oct 2026 00:35:56 +0000 Subject: [PATCH 02/11] fix: hold follow-ups when worktree setup fails and let mobile queue during setup --- .../features/threads/ThreadDetailScreen.tsx | 19 ++++-- .../features/threads/ThreadRouteScreen.tsx | 5 +- .../src/orchestration-v2/Orchestrator.ts | 13 +++- .../ThreadLaunchService.test.ts | 62 +++++++++++++++++++ apps/web/src/components/ChatView.tsx | 8 ++- 5 files changed, 97 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index b6930deb5e86..e533ad2f15e1 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -169,9 +169,15 @@ export interface ThreadDetailScreenProps { * The server has not created this thread yet. "preparing" runs while the * queued creation is delivered (a worktree may be checking out); "failed" * is a rejected creation whose content went back to the project draft. + * `serverOwned` marks a thread the server already created that is still + * setting up its worktree; follow-ups queue on the server behind that setup. */ readonly creationState: - | { readonly kind: "preparing"; readonly preparingWorktree: boolean } + | { + readonly kind: "preparing"; + readonly preparingWorktree: boolean; + readonly serverOwned: boolean; + } | { readonly kind: "failed"; readonly reason: string; readonly onEditTask: () => void } | null; readonly activePendingApproval: PendingApproval | null; @@ -1392,11 +1398,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread canStopThread={props.canStopThread} environmentId={props.environmentId} projectCwd={props.threadCwd ?? props.projectWorkspaceRoot} - // Follow-ups typed during setup wait in the draft: queueing - // them against a thread id the server may still reject - // would strand them in the outbox. + // Follow-ups typed before the server creates the thread wait in + // the draft: queueing them against a thread id the server may + // still reject would strand them in the outbox. sendBlockedReason={ - props.creationState?.kind === "preparing" ? "Starting the task…" : null + props.creationState?.kind === "preparing" && + !props.creationState.serverOwned + ? "Starting the task…" + : null } draftKey={props.composerDraftKey ?? undefined} followUpBehavior={props.followUpBehavior} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 8a579fc9a2f6..9f27cab56e28 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -889,7 +889,9 @@ function ThreadRouteContent( ]); const creationState = ((): ThreadDetailScreenProps["creationState"] => { if (selectedThreadCreation === null) { - return awaitingBootstrapTurn ? { kind: "preparing", preparingWorktree: true } : null; + return awaitingBootstrapTurn + ? { kind: "preparing", preparingWorktree: true, serverOwned: true } + : null; } if (selectedThreadCreation.outcome?.kind === "failed") { return { @@ -901,6 +903,7 @@ function ThreadRouteContent( return { kind: "preparing", preparingWorktree: selectedThreadCreation.message.creation?.workspaceMode === "worktree", + serverOwned: false, }; })(); if (!environmentId || !threadId) { diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 977b225f5256..82790938dea6 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1298,15 +1298,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // Hold the queue so the user decides when to resume it. Validation // failures (setup, unsupported handoff) belong to that message alone, // and a message queued for another provider is how users recover. + // A message queued behind a worktree that was cancelled or never created + // would run in the project checkout instead, so it waits for the user. const failedRun = latestExecutedRun(projection.runs); const failureClass = failedRun?.id === options?.failedRunId ? latestRootProviderFailure(failedRun, projection.turnItems)?.class : undefined; + const worktreeMissing = + failedRun?.id === options?.failedRunId && + failedRun?.workspacePreparation?.type === "worktree" && + projection.thread.worktreePath === null; if ( - failureClass !== undefined && - failureClass !== "validation_error" && - failedRun?.providerInstanceId === queuedRun.providerInstanceId + worktreeMissing || + (failureClass !== undefined && + failureClass !== "validation_error" && + failedRun?.providerInstanceId === queuedRun.providerInstanceId) ) { const now = yield* DateTime.now; yield* writeSystemEvents( diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index dfa02f4f514b..25103fe45d54 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1422,6 +1422,68 @@ it.effect("retries a failed workspace preparation on the same run", () => { }).pipe(Effect.provide(harness.layer)); }); +it.effect("holds a message queued during setup when the workspace preparation fails", () => + Effect.gen(function* () { + const allowFetch = yield* Deferred.make(); + const harness = makeHarness({ + fetchRemote: () => + Deferred.await(allowFetch).pipe( + Effect.andThen( + Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.fetchRemote", + command: "git", + cwd: project.workspaceRoot, + detail: "Git could not reach the remote.", + exitCode: 128, + }), + ), + ), + ), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:queued-failed-setup", + thread: "thread:launch:queued-failed-setup", + message: "First message", + workspace: { type: "worktree", baseRef: "main", startFromOrigin: true }, + }), + ); + const queued = yield* threads.sendToThread({ + projectId, + commandId: CommandId.make("command:launch:queued-failed-setup:follow-up"), + threadId: launched.threadId, + messageId: MessageId.make("message:launch:queued-failed-setup:follow-up"), + text: "Sent during setup", + attachments: [], + mode: "queue", + createdBy: "user", + creationSource: "web", + }); + assert.equal(queued.delivery, "queued"); + yield* Deferred.succeed(allowFetch, undefined); + + // Without its worktree the follow-up would start in the project checkout. + yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && stored.event.payload.queueHeld === true, + ), + Stream.runHead, + ); + const projection = yield* threads.getThreadProjection(launched.threadId); + assert.deepEqual( + projection.runs.map((run) => run.status), + ["failed", "queued"], + ); + assert.equal(projection.thread.worktreePath, null); + }).pipe(Effect.provide(harness.layer)); + }), +); + it.effect("a retry reuses a recorded worktree without undoing its branch rename", () => { let setupFailures = 1; const harness = makeHarness({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5ceca0f5898a..65474e293b18 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3672,6 +3672,8 @@ export default function ChatView(props: ChatViewProps) { // starts on its own instead of being refused. const sendQueuesBehindSetup = isServerThread && (activeRunPreparing || activeActivityRun?.status === "starting"); + // Such a send skips the local dispatch, so this keeps the composer busy while it is in flight. + const [setupQueueSendInFlight, setSetupQueueSendInFlight] = useState(false); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); @@ -9432,7 +9434,9 @@ export default function ChatView(props: ChatViewProps) { return; } // A send queued behind setup leaves the first send's dispatch and setup card alone. - if (!sendQueuesBehindSetup) { + if (sendQueuesBehindSetup) { + setSetupQueueSendInFlight(true); + } else { beginLocalDispatch({ preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree), // Only a draft has a background submission to hide behind its hero. @@ -10117,6 +10121,7 @@ export default function ChatView(props: ChatViewProps) { } } sendInFlightRef.current = false; + setSetupQueueSendInFlight(false); if (!turnStartSucceeded) { setDockedDraftHeroThreadKey((currentThreadKey) => currentThreadKey === activeThreadKey ? null : currentThreadKey, @@ -11630,6 +11635,7 @@ export default function ChatView(props: ChatViewProps) { isConnecting={isConnecting} isSendBusy={ (isSendBusy && !sendQueuesBehindSetup) || + setupQueueSendInFlight || isSavingQueuedEdit || isResuming } From 3dbeab4906611b6f2be94421f25d718b863d910a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 01:03:06 +0000 Subject: [PATCH 03/11] fix: hold follow-ups when setup is interrupted and release them on retry --- .../src/orchestration-v2/Orchestrator.ts | 28 +++- .../ThreadLaunchService.test.ts | 139 ++++++++++-------- apps/web/src/components/ChatView.tsx | 12 +- 3 files changed, 112 insertions(+), 67 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 82790938dea6..a83f27f9e834 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1263,7 +1263,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); }); - const startNextQueuedRun = (threadId: ThreadId, options?: { readonly failedRunId?: RunId }) => + const startNextQueuedRun = ( + threadId: ThreadId, + options?: { readonly failedRunId?: RunId; readonly endedRunId?: RunId }, + ) => Effect.gen(function* () { // Every terminal run checks the queue. Only a deliverable queued run // needs the transcript for provider handoff and legacy import context. @@ -1306,7 +1309,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? latestRootProviderFailure(failedRun, projection.turnItems)?.class : undefined; const worktreeMissing = - failedRun?.id === options?.failedRunId && + failedRun?.id === options?.endedRunId && failedRun?.workspacePreparation?.type === "worktree" && projection.thread.worktreePath === null; if ( @@ -8111,6 +8114,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio type: "run.updated", payload: { ...state.run, status: "preparing", completedAt: null }, }); + // Messages held when this preparation failed follow the run again. + for (const run of projection.runs) { + if (run.status !== "queued" || run.queueHeld !== true) continue; + yield* emitEvent({ + type: "run.updated", + threadId: command.threadId, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, queueHeld: false }, + }); + } }); /** @@ -10656,8 +10671,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio threadId, startNextQueuedRun( threadId, - stored.event.type === "run.updated" && stored.event.payload.status === "failed" - ? { failedRunId: stored.event.payload.id } + stored.event.type === "run.updated" + ? { + endedRunId: stored.event.payload.id, + ...(stored.event.payload.status === "failed" + ? { failedRunId: stored.event.payload.id } + : {}), + } : undefined, ), ) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 25103fe45d54..9b7b2ac2f2ae 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1422,66 +1422,89 @@ it.effect("retries a failed workspace preparation on the same run", () => { }).pipe(Effect.provide(harness.layer)); }); -it.effect("holds a message queued during setup when the workspace preparation fails", () => - Effect.gen(function* () { - const allowFetch = yield* Deferred.make(); - const harness = makeHarness({ - fetchRemote: () => - Deferred.await(allowFetch).pipe( - Effect.andThen( - Effect.fail( - new GitCommandError({ - operation: "GitVcsDriver.fetchRemote", - command: "git", - cwd: project.workspaceRoot, - detail: "Git could not reach the remote.", - exitCode: 128, - }), - ), - ), - ), - }); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const launched = yield* launches.launch( - launchInput({ - command: "command:launch:queued-failed-setup", - thread: "thread:launch:queued-failed-setup", - message: "First message", - workspace: { type: "worktree", baseRef: "main", startFromOrigin: true }, - }), - ); - const queued = yield* threads.sendToThread({ - projectId, - commandId: CommandId.make("command:launch:queued-failed-setup:follow-up"), - threadId: launched.threadId, - messageId: MessageId.make("message:launch:queued-failed-setup:follow-up"), - text: "Sent during setup", - attachments: [], - mode: "queue", - createdBy: "user", - creationSource: "web", +it.effect.each(["fails", "is interrupted"] as const)( + "holds a message queued during setup when the workspace preparation %s", + (ending) => + Effect.gen(function* () { + const allowFetch = yield* Deferred.make(); + let fetchFailures = 1; + const harness = makeHarness({ + fetchRemote: () => + fetchFailures-- <= 0 + ? Effect.void + : Deferred.await(allowFetch).pipe( + Effect.andThen( + Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.fetchRemote", + command: "git", + cwd: project.workspaceRoot, + detail: "Git could not reach the remote.", + exitCode: 128, + }), + ), + ), + ), }); - assert.equal(queued.delivery, "queued"); - yield* Deferred.succeed(allowFetch, undefined); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: `command:launch:queued-setup-${ending}`, + thread: `thread:launch:queued-setup-${ending}`, + message: "First message", + workspace: { type: "worktree", baseRef: "main", startFromOrigin: true }, + }), + ); + const queued = yield* threads.sendToThread({ + projectId, + commandId: CommandId.make(`command:launch:queued-setup-${ending}:follow-up`), + threadId: launched.threadId, + messageId: MessageId.make(`message:launch:queued-setup-${ending}:follow-up`), + text: "Sent during setup", + attachments: [], + mode: "queue", + createdBy: "user", + creationSource: "web", + }); + assert.equal(queued.delivery, "queued"); + if (ending === "fails") { + yield* Deferred.succeed(allowFetch, undefined); + } else { + // An agent stop does not ask to hold the queue. + yield* threads.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("command:launch:queued-setup-interrupt"), + threadId: launched.threadId, + runId: launched.projection.runs[0]!.id, + }); + } - // Without its worktree the follow-up would start in the project checkout. - yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe( - Stream.filter( - (stored) => - stored.event.type === "run.updated" && stored.event.payload.queueHeld === true, - ), - Stream.runHead, - ); - const projection = yield* threads.getThreadProjection(launched.threadId); - assert.deepEqual( - projection.runs.map((run) => run.status), - ["failed", "queued"], - ); - assert.equal(projection.thread.worktreePath, null); - }).pipe(Effect.provide(harness.layer)); - }), + // Without its worktree the follow-up would start in the project checkout. + yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && stored.event.payload.queueHeld === true, + ), + Stream.runHead, + ); + const projection = yield* threads.getThreadProjection(launched.threadId); + assert.equal(projection.runs.at(-1)?.status, "queued"); + assert.equal(projection.thread.worktreePath, null); + + if (ending === "fails") { + // A successful retry lets the held message follow the first turn again. + yield* launches.retryPreparation({ + commandId: CommandId.make("command:launch:queued-setup-retry"), + threadId: launched.threadId, + runId: launched.projection.runs[0]!.id, + }); + const retried = yield* threads.getThreadProjection(launched.threadId); + assert.equal(retried.runs.at(-1)?.queueHeld, false); + } + }).pipe(Effect.provide(harness.layer)); + }), ); it.effect("a retry reuses a recorded worktree without undoing its branch rename", () => { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 65474e293b18..3d83318dcc97 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3672,8 +3672,9 @@ export default function ChatView(props: ChatViewProps) { // starts on its own instead of being refused. const sendQueuesBehindSetup = isServerThread && (activeRunPreparing || activeActivityRun?.status === "starting"); - // Such a send skips the local dispatch, so this keeps the composer busy while it is in flight. - const [setupQueueSendInFlight, setSetupQueueSendInFlight] = useState(false); + // Such a send skips the local dispatch, so this keeps its thread's composer busy while it is + // in flight. + const [setupQueueSendThreadKey, setSetupQueueSendThreadKey] = useState(null); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); @@ -9435,7 +9436,7 @@ export default function ChatView(props: ChatViewProps) { } // A send queued behind setup leaves the first send's dispatch and setup card alone. if (sendQueuesBehindSetup) { - setSetupQueueSendInFlight(true); + setSetupQueueSendThreadKey(activeThreadKey); } else { beginLocalDispatch({ preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree), @@ -10121,7 +10122,7 @@ export default function ChatView(props: ChatViewProps) { } } sendInFlightRef.current = false; - setSetupQueueSendInFlight(false); + setSetupQueueSendThreadKey((current) => (current === activeThreadKey ? null : current)); if (!turnStartSucceeded) { setDockedDraftHeroThreadKey((currentThreadKey) => currentThreadKey === activeThreadKey ? null : currentThreadKey, @@ -11635,7 +11636,8 @@ export default function ChatView(props: ChatViewProps) { isConnecting={isConnecting} isSendBusy={ (isSendBusy && !sendQueuesBehindSetup) || - setupQueueSendInFlight || + (setupQueueSendThreadKey !== null && + setupQueueSendThreadKey === activeThreadKey) || isSavingQueuedEdit || isResuming } From ade9163fd7ab0e30f01b52d7bfef7f0350e33d68 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 01:13:25 +0000 Subject: [PATCH 04/11] fix(server): a setup retry only releases holds from its own failure --- apps/server/src/orchestration-v2/Orchestrator.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index a83f27f9e834..3eb249575889 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -8114,9 +8114,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio type: "run.updated", payload: { ...state.run, status: "preparing", completedAt: null }, }); - // Messages held when this preparation failed follow the run again. + // Messages held when this preparation failed follow the run again. A hold + // from a later run (a stop, say) is not this failure's to release. + const failureOwnsQueue = latestExecutedRun(projection.runs)?.id === state.run.id; for (const run of projection.runs) { - if (run.status !== "queued" || run.queueHeld !== true) continue; + if (!failureOwnsQueue || run.status !== "queued" || run.queueHeld !== true) continue; yield* emitEvent({ type: "run.updated", threadId: command.threadId, From e0d5f88027bc82c8b021728e739e8e41f040ed34 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 02:33:29 +0000 Subject: [PATCH 05/11] fix(web): starting a worktree thread no longer resets it to the base branch --- apps/web/src/components/BranchToolbarBranchSelector.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 1d4185b9092f..378b6735e5a9 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -538,8 +538,13 @@ export function BranchToolbarBranchSelector({ ? null : (defaultBranchName ?? currentGitBranch); + // A started thread's server is creating its worktree and records the branch + // itself; defaulting it here would reset the thread to the base checkout and + // detach the agent that just started. + const threadStarted = serverSession !== null; useEffect(() => { if ( + threadStarted || effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || @@ -553,6 +558,7 @@ export function BranchToolbarBranchSelector({ activeWorktreePath, effectiveEnvMode, setThreadBranch, + threadStarted, worktreeBaseBranchCandidate, ]); From b468f3eee70da5fc028a90c5b1c7bd3ba8a671b6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 02:45:18 +0000 Subject: [PATCH 06/11] fix(web): an automatic base-branch default only applies to an empty thread --- apps/web/src/components/BranchToolbarBranchSelector.tsx | 9 ++++++++- packages/client-runtime/src/operations/commands.ts | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 378b6735e5a9..968f11b4d8b0 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -122,6 +122,10 @@ export function BranchToolbarBranchSelector({ threadEnvironment.updateMetadata, "thread metadata update", ); + // An automatic default loses to any launch that lands first; that rejection is expected. + const updateEmptyThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false, }); @@ -188,12 +192,14 @@ export function BranchToolbarBranchSelector({ }); } if (hasServerThread) { - void updateThreadMetadata({ + void (automatic ? updateEmptyThreadMetadata : updateThreadMetadata)({ environmentId, input: { threadId: activeThreadId, branch, worktreePath, + // A launched thread records its own worktree; a late default must not reset it. + ...(automatic ? { expectedEmpty: true } : {}), }, }); } @@ -228,6 +234,7 @@ export function BranchToolbarBranchSelector({ effectiveEnvMode, draftThread?.environmentSelection, stopThreadSession, + updateEmptyThreadMetadata, updateThreadMetadata, ], ); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 84f4a2ad3cff..f598e3d73e9a 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -131,6 +131,8 @@ export interface UpdateThreadMetadataInput extends ThreadCommandInput { readonly regenerateTitle?: boolean; /** Link (object) or unlink (null) a pull request (#8160). */ readonly linkedPullRequest?: ThreadLinkedPullRequest | null; + /** Apply only while no message or run has landed on the thread. */ + readonly expectedEmpty?: boolean; } export interface SetThreadRuntimeModeInput extends ThreadCommandInput { @@ -577,6 +579,7 @@ export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadM ...(input.title === undefined ? {} : { title: input.title }), ...(input.branch === undefined ? {} : { branch: input.branch }), ...(input.worktreePath === undefined ? {} : { worktreePath: input.worktreePath }), + ...(input.expectedEmpty === true ? { expectedEmpty: true } : {}), ...(input.regenerateTitle === undefined ? {} : { regenerateTitle: input.regenerateTitle }), ...(input.linkedPullRequest === undefined ? {} From 4adf22e403401524066e246d6fee09bf591db8be Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 05:24:17 +0000 Subject: [PATCH 07/11] fix(server): a follow-up queued during setup that arrives after it failed is held --- .../src/orchestration-v2/Orchestrator.ts | 24 +++++++-- .../ThreadLaunchService.test.ts | 51 +++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 3eb249575889..7b3a84048429 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4855,9 +4855,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio (candidate) => candidate.id === projection.thread.activeProviderThreadId, ); const activeRun = projection.runs.find(isBlockingRun); + // A message queued during worktree setup can arrive after that setup failed + // without a worktree. It waits, held, like the messages queued before the + // failure, instead of starting in the project checkout. + const latestRun = latestExecutedRun(projection.runs); + const worktreeMissingRun = + activeRun === undefined && + dispatchMode.type === "queue_after_active" && + latestRun?.status === "failed" && + latestRun.workspacePreparation?.type === "worktree" && + projection.thread.worktreePath === null + ? latestRun + : undefined; + const queueBehindRun = activeRun ?? worktreeMissingRun; const pendingMergeBackTransfers = pendingMergeBackTransfersForThread(projection); const shouldQueue = - activeRun !== undefined && + queueBehindRun !== undefined && (dispatchMode.type === "defer_start" || dispatchMode.type === "start_immediately" || dispatchMode.type === "queue_after_active"); @@ -4872,13 +4885,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const queueProviderThread = activeProviderThread ?? projection.providerThreads.find( - (candidate) => candidate.id === activeRun.providerThreadId, + (candidate) => candidate.id === queueBehindRun.providerThreadId, ); if (queueProviderThread === undefined) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: `Active run ${activeRun.id} has no provider thread for queued dispatch.`, + cause: `Active run ${queueBehindRun.id} has no provider thread for queued dispatch.`, }); } const now = yield* DateTime.now; @@ -4932,7 +4945,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const attemptId = idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }); const rootNodeId = idAllocator.derive.rootNode({ runId }); const checkpointScope = - activeRun.status === "preparing" + queueBehindRun.status === "preparing" || worktreeMissingRun !== undefined ? null : yield* runtimePolicy.resolve({ thread: projection.thread, modelSelection }).pipe( Effect.flatMap((resolvedRuntimePolicy) => @@ -4969,7 +4982,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio rootNodeId, activeAttemptId: attemptId, status: "queued", - ...(projection.runs.some( + ...(worktreeMissingRun !== undefined || + projection.runs.some( (candidate) => candidate.status === "queued" && candidate.queueHeld === true, ) ? { queueHeld: true } diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 9b7b2ac2f2ae..c2a66c0afead 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1507,6 +1507,57 @@ it.effect.each(["fails", "is interrupted"] as const)( }), ); +it.effect("holds a message queued during setup that arrives after the setup failed", () => { + const harness = makeHarness({ + fetchRemote: () => + Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.fetchRemote", + command: "git", + cwd: project.workspaceRoot, + detail: "Git could not reach the remote.", + exitCode: 128, + }), + ), + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:late-queued-follow-up", + thread: "thread:launch:late-queued-follow-up", + message: "First message", + workspace: { type: "worktree", baseRef: "main", startFromOrigin: true }, + }), + ); + yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe( + Stream.filter( + (stored) => stored.event.type === "run.updated" && stored.event.payload.status === "failed", + ), + Stream.runHead, + ); + + // Queued while setup ran, delivered only after it failed: it must not start + // in the project checkout. + const queued = yield* threads.sendToThread({ + projectId, + commandId: CommandId.make("command:launch:late-queued-follow-up:send"), + threadId: launched.threadId, + messageId: MessageId.make("message:launch:late-queued-follow-up:send"), + text: "Sent during setup", + attachments: [], + mode: "queue", + createdBy: "user", + creationSource: "web", + }); + assert.equal(queued.delivery, "queued"); + const projection = yield* threads.getThreadProjection(launched.threadId); + assert.equal(projection.runs.at(-1)?.status, "queued"); + assert.equal(projection.runs.at(-1)?.queueHeld, true); + }).pipe(Effect.provide(harness.layer)); +}); + it.effect("a retry reuses a recorded worktree without undoing its branch rename", () => { let setupFailures = 1; const harness = makeHarness({ From 92adc8b1cd06d65b89c960fd9a0593ac21abfae6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 05:35:12 +0000 Subject: [PATCH 08/11] fix(server): hold a late queued follow-up after an interrupted setup too --- .../src/orchestration-v2/Orchestrator.ts | 2 +- .../ThreadLaunchService.test.ts | 110 ++++++++++-------- 2 files changed, 63 insertions(+), 49 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 008dfd5325f8..e3d68a530b62 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4869,7 +4869,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const worktreeMissingRun = activeRun === undefined && dispatchMode.type === "queue_after_active" && - latestRun?.status === "failed" && + (latestRun?.status === "failed" || latestRun?.status === "interrupted") && latestRun.workspacePreparation?.type === "worktree" && projection.thread.worktreePath === null ? latestRun diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index c2a66c0afead..b03cbf0dfa3c 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1507,56 +1507,70 @@ it.effect.each(["fails", "is interrupted"] as const)( }), ); -it.effect("holds a message queued during setup that arrives after the setup failed", () => { - const harness = makeHarness({ - fetchRemote: () => - Effect.fail( - new GitCommandError({ - operation: "GitVcsDriver.fetchRemote", - command: "git", - cwd: project.workspaceRoot, - detail: "Git could not reach the remote.", - exitCode: 128, +it.effect.each(["failed", "interrupted"] as const)( + "holds a message queued during setup that arrives after the setup %s", + (ending) => { + const harness = makeHarness({ + fetchRemote: () => + ending === "interrupted" + ? Effect.never + : Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.fetchRemote", + command: "git", + cwd: project.workspaceRoot, + detail: "Git could not reach the remote.", + exitCode: 128, + }), + ), + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: `command:launch:late-queued-follow-up-${ending}`, + thread: `thread:launch:late-queued-follow-up-${ending}`, + message: "First message", + workspace: { type: "worktree", baseRef: "main", startFromOrigin: true }, }), - ), - }); - return Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const launched = yield* launches.launch( - launchInput({ - command: "command:launch:late-queued-follow-up", - thread: "thread:launch:late-queued-follow-up", - message: "First message", - workspace: { type: "worktree", baseRef: "main", startFromOrigin: true }, - }), - ); - yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe( - Stream.filter( - (stored) => stored.event.type === "run.updated" && stored.event.payload.status === "failed", - ), - Stream.runHead, - ); + ); + if (ending === "interrupted") { + // An agent stop does not ask to hold the queue. + yield* threads.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("command:launch:late-queued-follow-up:interrupt"), + threadId: launched.threadId, + runId: launched.projection.runs[0]!.id, + }); + } + yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe( + Stream.filter( + (stored) => stored.event.type === "run.updated" && stored.event.payload.status === ending, + ), + Stream.runHead, + ); - // Queued while setup ran, delivered only after it failed: it must not start - // in the project checkout. - const queued = yield* threads.sendToThread({ - projectId, - commandId: CommandId.make("command:launch:late-queued-follow-up:send"), - threadId: launched.threadId, - messageId: MessageId.make("message:launch:late-queued-follow-up:send"), - text: "Sent during setup", - attachments: [], - mode: "queue", - createdBy: "user", - creationSource: "web", - }); - assert.equal(queued.delivery, "queued"); - const projection = yield* threads.getThreadProjection(launched.threadId); - assert.equal(projection.runs.at(-1)?.status, "queued"); - assert.equal(projection.runs.at(-1)?.queueHeld, true); - }).pipe(Effect.provide(harness.layer)); -}); + // Queued while setup ran, delivered only after it failed: it must not start + // in the project checkout. + const queued = yield* threads.sendToThread({ + projectId, + commandId: CommandId.make(`command:launch:late-queued-follow-up-${ending}:send`), + threadId: launched.threadId, + messageId: MessageId.make(`message:launch:late-queued-follow-up-${ending}:send`), + text: "Sent during setup", + attachments: [], + mode: "queue", + createdBy: "user", + creationSource: "web", + }); + assert.equal(queued.delivery, "queued"); + const projection = yield* threads.getThreadProjection(launched.threadId); + assert.equal(projection.runs.at(-1)?.status, "queued"); + assert.equal(projection.runs.at(-1)?.queueHeld, true); + }).pipe(Effect.provide(harness.layer)); + }, +); it.effect("a retry reuses a recorded worktree without undoing its branch rename", () => { let setupFailures = 1; From 788e2d9b4b73b81fb679350b36ab459a98022d93 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 05:38:28 +0000 Subject: [PATCH 09/11] test(server): interrupt cases wait until setup is blocked in fetch --- .../src/orchestration-v2/ThreadLaunchService.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index b03cbf0dfa3c..d683b4dd08ca 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1426,13 +1426,15 @@ it.effect.each(["fails", "is interrupted"] as const)( "holds a message queued during setup when the workspace preparation %s", (ending) => Effect.gen(function* () { + const fetchEntered = yield* Deferred.make(); const allowFetch = yield* Deferred.make(); let fetchFailures = 1; const harness = makeHarness({ fetchRemote: () => fetchFailures-- <= 0 ? Effect.void - : Deferred.await(allowFetch).pipe( + : Deferred.succeed(fetchEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowFetch)), Effect.andThen( Effect.fail( new GitCommandError({ @@ -1457,6 +1459,7 @@ it.effect.each(["fails", "is interrupted"] as const)( workspace: { type: "worktree", baseRef: "main", startFromOrigin: true }, }), ); + yield* Deferred.await(fetchEntered); const queued = yield* threads.sendToThread({ projectId, commandId: CommandId.make(`command:launch:queued-setup-${ending}:follow-up`), @@ -1510,10 +1513,11 @@ it.effect.each(["fails", "is interrupted"] as const)( it.effect.each(["failed", "interrupted"] as const)( "holds a message queued during setup that arrives after the setup %s", (ending) => { + let fetchEntered: Deferred.Deferred | null = null; const harness = makeHarness({ fetchRemote: () => ending === "interrupted" - ? Effect.never + ? Deferred.succeed(fetchEntered!, undefined).pipe(Effect.andThen(Effect.never)) : Effect.fail( new GitCommandError({ operation: "GitVcsDriver.fetchRemote", @@ -1525,6 +1529,7 @@ it.effect.each(["failed", "interrupted"] as const)( ), }); return Effect.gen(function* () { + fetchEntered = yield* Deferred.make(); const launches = yield* ThreadLaunch.ThreadLaunchService; const threads = yield* ThreadManagement.ThreadManagementService; const launched = yield* launches.launch( @@ -1536,6 +1541,7 @@ it.effect.each(["failed", "interrupted"] as const)( }), ); if (ending === "interrupted") { + yield* Deferred.await(fetchEntered); // An agent stop does not ask to hold the queue. yield* threads.dispatch({ type: "run.interrupt", From badb634a7878df4f50a6da0be903c6a9e9ae7f69 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 05:45:44 +0000 Subject: [PATCH 10/11] fix(mobile): a message sent during worktree setup is sent as queued --- .../mobile/src/state/use-thread-composer-state.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 12a9999c8a03..c1fd74334af1 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -677,8 +677,18 @@ export function useThreadComposerState() { alternateModifier: followUpOverride !== undefined && followUpOverride !== followUpBehavior, activeTurnDefault: followUpBehavior, }); - const followUpDispatchMode = - followUpAction === "auto" ? null : followUpAction === "queue" ? "queue" : "auto"; + // A send while the worktree is still being set up waits behind that setup, + // so the server can hold it if the setup ends without a worktree. + const setupRunning = + selectedThreadActivityRun?.status === "preparing" || + selectedThreadActivityRun?.status === "starting"; + const followUpDispatchMode = setupRunning + ? "queue" + : followUpAction === "auto" + ? null + : followUpAction === "queue" + ? "queue" + : "auto"; const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); @@ -735,6 +745,7 @@ export function useThreadComposerState() { saveQueuedRunEdit, selectedEnvironmentRuntime?.connectionState, selectedEnvironmentRuntime?.serverConfig, + selectedThreadActivityRun?.status, selectedThreadCreation, selectedThreadShell, uploadThreadFeedback, From 579ea42430b4d22e1001b5b3d3a18ed621cb9f18 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 05:52:03 +0000 Subject: [PATCH 11/11] fix(mobile): setup detection also reads the thread runtime before detail loads --- apps/mobile/src/state/use-thread-composer-state.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index c1fd74334af1..172f647cff0f 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -679,9 +679,11 @@ export function useThreadComposerState() { }); // A send while the worktree is still being set up waits behind that setup, // so the server can hold it if the setup ends without a worktree. - const setupRunning = - selectedThreadActivityRun?.status === "preparing" || - selectedThreadActivityRun?.status === "starting"; + // Before detail loads, the shell's latest run can be a queued follow-up + // while its runtime still reports the setup. + const setupRunning = [selectedThreadActivityRun?.status, selectedThreadRuntime?.status].some( + (status) => status === "preparing" || status === "starting", + ); const followUpDispatchMode = setupRunning ? "queue" : followUpAction === "auto" @@ -747,6 +749,7 @@ export function useThreadComposerState() { selectedEnvironmentRuntime?.serverConfig, selectedThreadActivityRun?.status, selectedThreadCreation, + selectedThreadRuntime?.status, selectedThreadShell, uploadThreadFeedback, ],