diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b21d18de6b1ce7e82d72637facff8447fa703056..109d926b74c979694f4141b1f56195580603bbba 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -10836,17 +10836,30 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(response.sequence, 5); + assert.equal(response.sequence, 6); assert.deepEqual( dispatchedCommands.map((command) => command.type), [ "thread.create", + "thread.session.set", "thread.meta.update", "thread.activity.append", "thread.activity.append", "thread.turn.start", ], ); + // The checkout can take minutes, so the thread reads as working from + // the moment setup starts rather than only once the turn is dispatched. + const preparingCommand = dispatchedCommands[1]; + assertTrue(preparingCommand?.type === "thread.session.set"); + if (preparingCommand?.type === "thread.session.set") { + assert.equal(preparingCommand.session.status, "starting"); + assert.equal(preparingCommand.session.activeTurnId, null); + assert.equal( + preparingCommand.session.providerInstanceId, + defaultModelSelection.instanceId, + ); + } assert.deepEqual(createWorktree.mock.calls[0]?.[0], { cwd: "/tmp/project", refName: fetchedOriginCommit, @@ -10902,7 +10915,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { setupActivities.map((command) => command.activity.kind), ["setup-script.requested", "setup-script.started"], ); - const finalCommand = dispatchedCommands[4]; + const finalCommand = dispatchedCommands[5]; assertTrue(finalCommand?.type === "thread.turn.start"); if (finalCommand?.type === "thread.turn.start") { assert.equal(finalCommand.bootstrap, undefined); @@ -11284,10 +11297,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(response.sequence, 4); + assert.equal(response.sequence, 5); assert.deepEqual( dispatchedCommands.map((command) => command.type), - ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], + [ + "thread.create", + "thread.session.set", + "thread.meta.update", + "thread.activity.append", + "thread.turn.start", + ], ); const setupFailureActivity = dispatchedCommands.find( (command): command is Extract => @@ -11411,10 +11430,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(response.sequence, 4); + assert.equal(response.sequence, 5); assert.deepEqual( dispatchedCommands.map((command) => command.type), - ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], + [ + "thread.create", + "thread.session.set", + "thread.meta.update", + "thread.activity.append", + "thread.turn.start", + ], ); const setupActivities = dispatchedCommands.filter( (command): command is Extract => @@ -11676,7 +11701,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.strictEqual(result.failure.bootstrapThreadDisposition, "deleted"); assert.deepEqual( dispatchedCommands.map((command) => command.type), - ["thread.create", "thread.delete"], + ["thread.create", "thread.session.set", "thread.delete"], ); assert.isDefined(pendingAttachmentId); assert.isTrue( @@ -11874,8 +11899,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.strictEqual(result.failure.bootstrapThreadDisposition, undefined); assert.deepEqual( dispatchedCommands.map((command) => command.type), - ["thread.create", "thread.delete"], + ["thread.create", "thread.session.set", "thread.delete", "thread.session.set"], ); + // The surviving thread must not keep its preparing session, or it would + // read as working forever. + const failedSession = dispatchedCommands[3]; + assertTrue(failedSession?.type === "thread.session.set"); + if (failedSession?.type === "thread.session.set") { + assert.equal(failedSession.session.status, "error"); + assert.include(failedSession.session.lastError ?? "", "worktree exploded"); + } }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2286387cadc7384523917e0d2db19dda48899c2e..debefdd62a97c5c2cf7392c49696b5379813f05a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1017,6 +1017,30 @@ const makeWsRpcLayer = ( // one so terminals the user opened meanwhile survive. let setupTerminalId: string | null = null; + // Set once the checkout starts; see the session.set below. + let preparingSessionSet = false; + const markPreparingSessionFailed = (detail: string) => + Effect.gen(function* () { + const failedAt = yield* nowIso; + yield* dispatchFromClient({ + type: "thread.session.set", + commandId: yield* serverCommandId("bootstrap-thread-preparing-failed"), + threadId, + session: { + threadId, + status: "error", + providerName: null, + providerInstanceId: + bootstrap?.createThread?.modelSelection.instanceId ?? + command.modelSelection?.instanceId, + runtimeMode: command.runtimeMode, + activeTurnId: null, + lastError: detail.trim().length > 0 ? detail : "Worktree setup failed.", + updatedAt: failedAt, + }, + createdAt: failedAt, + }); + }); const cleanupCreatedThread = () => createdThread ? serverCommandId("bootstrap-thread-delete").pipe( @@ -1340,6 +1364,32 @@ const makeWsRpcLayer = ( } if (prepareWorktree && shouldPrepareWorktree && worktreeBaseRef) { + if (bootstrap?.createThread && createdThread) { + // The checkout and setup script can run for minutes before the + // turn starts, and the created thread carries no message or + // turn until then. Project a starting session now so every + // client lists the thread as working and a reopened thread + // knows to follow the setup stream. A failed or cancelled setup + // deletes the thread, so nothing lingers. + const preparingAt = yield* nowIso; + yield* dispatchFromClient({ + type: "thread.session.set", + commandId: yield* serverCommandId("bootstrap-thread-preparing"), + threadId, + session: { + threadId, + status: "starting", + providerName: null, + providerInstanceId: bootstrap.createThread.modelSelection.instanceId, + runtimeMode: command.runtimeMode, + activeTurnId: null, + lastError: null, + updatedAt: preparingAt, + }, + createdAt: preparingAt, + }); + preparingSessionSet = true; + } yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running"); let checkoutTotal: number | null = null; const worktree = yield* gitWorkflow.createWorktree( @@ -1483,7 +1533,19 @@ const makeWsRpcLayer = ( Effect.logWarning("bootstrap thread cleanup failed", { threadId, detail: Cause.pretty(cleanupCause), - }).pipe(Effect.flatMap(() => Effect.fail(dispatchError))), + }).pipe( + // The thread outlived its setup. Its preparing session + // must not read as working forever, so record the failure + // on it instead. + Effect.andThen( + preparingSessionSet + ? markPreparingSessionFailed(dispatchError.message).pipe( + Effect.ignoreCause({ log: true }), + ) + : Effect.void, + ), + Effect.flatMap(() => Effect.fail(dispatchError)), + ), onSuccess: (threadDeleted) => Effect.fail( threadDeleted diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6b8c65410edb2daa532b9a604f20fe505ee2e45b..53683ce7ed6a1a434e257d06f5c6ddb012173179 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -349,7 +349,7 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; -import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; +import { resolveTimelineIsAtEnd, worktreeSetupAgentStarted } from "./chat/MessagesTimeline.logic"; import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; @@ -3493,14 +3493,47 @@ export default function ChatView(props: ChatViewProps) { const worktreeSetupOwnerKey = draftId ?? routeThreadKey; const worktreeSetupActive = worktreeSetupRef !== null && worktreeSetupRef.ownerKey === worktreeSetupOwnerKey; + // A thread reopened mid-setup has no dispatch ref: this view remounted. + // The server projects a starting session before any message or turn exists + // for exactly that window, so follow the setup stream from the thread's own + // state. The ref is adopted below so the card then follows the same + // lifecycle as the original send, including an async setup script that + // keeps running after the agent's turn lands. + const resumedWorktreeSetupRef = + !worktreeSetupActive && + isServerThread && + activeThreadRef !== null && + activeThreadShell?.session?.status === "starting" && + activeThreadShell.latestTurn === null && + activeThreadShell.latestUserMessageAt === null + ? activeThreadRef + : null; + useEffect(() => { + if (!resumedWorktreeSetupRef) return; + // Only a ref owned by this route survives adoption. This component is + // reused across routes, so a ref left by another thread's send is stale + // here and would leave the resumed setup with no target after handoff. + setWorktreeSetupRef((current) => + current?.ownerKey === worktreeSetupOwnerKey + ? current + : { ...resumedWorktreeSetupRef, ownerKey: worktreeSetupOwnerKey }, + ); + }, [resumedWorktreeSetupRef, worktreeSetupOwnerKey]); // The setup runs on the environment that received the dispatch, so both // the subscription and cancel target that one even if the draft's machine // picker changes underneath. + const worktreeSetupTarget = useMemo( + () => + worktreeSetupActive + ? { environmentId: worktreeSetupRef.environmentId, threadId: worktreeSetupRef.threadId } + : resumedWorktreeSetupRef, + [resumedWorktreeSetupRef, worktreeSetupActive, worktreeSetupRef], + ); const worktreeSetupQuery = useEnvironmentQuery( - worktreeSetupActive + worktreeSetupTarget ? vcsEnvironment.worktreeSetup({ - environmentId: worktreeSetupRef.environmentId, - input: { threadId: worktreeSetupRef.threadId }, + environmentId: worktreeSetupTarget.environmentId, + input: { threadId: worktreeSetupTarget.threadId }, }) : null, ); @@ -3511,7 +3544,7 @@ export default function ChatView(props: ChatViewProps) { if (latestWorktreeSetup) setHeldWorktreeSetup(latestWorktreeSetup); }, [latestWorktreeSetup]); const worktreeSetup = - worktreeSetupActive && heldWorktreeSetup?.threadId === worktreeSetupRef.threadId + worktreeSetupTarget !== null && heldWorktreeSetup?.threadId === worktreeSetupTarget.threadId ? heldWorktreeSetup : null; // A finished card is dropped once the agent's turn shows in the timeline: @@ -3537,16 +3570,32 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { if (worktreeSetupSettledKey) pendingWorktreeSetupByThreadKey.delete(worktreeSetupSettledKey); }, [worktreeSetupSettledKey]); + // 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. A resumed setup has no + // snapshot until its first stream event, so that gap blocks as well. The + // gap is judged by the shell, not by the resumed ref, since adopting the + // ref clears it before the query has delivered anything. + const worktreeSetupAwaitingFirstSnapshot = + worktreeSetup === null && + worktreeSetupTarget !== null && + isServerThread && + activeThreadShell?.session?.status === "starting" && + activeThreadShell.latestTurn === null; + const worktreeSetupBlocksSend = + worktreeSetup !== null + ? worktreeSetup.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup) + : worktreeSetupAwaitingFirstSnapshot; const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { reportFailure: false, }); const onCancelWorktreeSetup = useCallback(() => { - if (!worktreeSetup || !worktreeSetupRef || worktreeSetup.phase !== "running") return; + if (!worktreeSetup || !worktreeSetupTarget || worktreeSetup.phase !== "running") return; void cancelWorktreeSetup({ - environmentId: worktreeSetupRef.environmentId, + environmentId: worktreeSetupTarget.environmentId, input: { threadId: worktreeSetup.threadId }, }); - }, [cancelWorktreeSetup, worktreeSetup, worktreeSetupRef]); + }, [cancelWorktreeSetup, worktreeSetup, worktreeSetupTarget]); // The setup terminal belongs to the thread that was set up. A failed // bootstrap deletes that thread and closes its terminals, so only offer the // terminal while the setup thread is still the active one. @@ -9313,7 +9362,9 @@ export default function ChatView(props: ChatViewProps) { ? "Sending feedback" : threadDetailLoading ? "Messages loading" - : projectCloneSendBlockReason + : worktreeSetupBlocksSend + ? "Preparing worktree" + : projectCloneSendBlockReason } isPreparingWorktree={isPreparingWorktree} bannerItems={composerBannerItems} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 89bcf214783fa411c84dcfba88b902ca483172d0..3ce0a5fd64497142b9b22b1b9d5a78e4270d73d8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1336,7 +1336,7 @@ export function deriveMessagesTimelineRows(input: { export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; /** True once the bootstrap handed off to the agent (async setup script may still run). */ -function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean { +export function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean { return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done"); }