diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 871b79eca90..b680c455d73 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -7223,6 +7223,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assertTrue(result._tag === "Failure"); assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); assert.include(result.failure.message, "worktree exploded"); + assertTrue(result.failure.retryWithNewThreadId === true); assert.deepEqual( dispatchedCommands.map((command) => command.type), ["thread.create", "thread.delete"], diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f6f46d1e76e..541676125a3 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -848,7 +848,7 @@ const makeWsRpcLayer = ( let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; - const cleanupCreatedThread = () => + const cleanupCreatedThread = (): Effect.Effect => createdThread ? serverCommandId("bootstrap-thread-delete").pipe( Effect.flatMap((commandId) => @@ -858,9 +858,18 @@ const makeWsRpcLayer = ( threadId: command.threadId, }), ), - Effect.ignoreCause({ log: true }), + Effect.as(true), + Effect.catchCause((cause) => + Effect.logWarning("Failed to clean up bootstrap thread").pipe( + Effect.annotateLogs({ + threadId: command.threadId, + cause: Cause.pretty(cause), + }), + Effect.as(false), + ), + ), ) - : Effect.void; + : Effect.succeed(false); const recordSetupScriptLaunchFailure = (input: { readonly error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError; @@ -1038,7 +1047,19 @@ const makeWsRpcLayer = ( if (Cause.hasInterruptsOnly(cause)) { return Effect.fail(dispatchError); } - return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(dispatchError))); + return cleanupCreatedThread().pipe( + Effect.flatMap((cleanedUp) => + Effect.fail( + cleanedUp + ? new OrchestrationDispatchCommandError({ + message: dispatchError.message, + cause: dispatchError.cause, + retryWithNewThreadId: true, + }) + : dispatchError, + ), + ), + ); }), ); }); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 57c12959ffb..8f289133924 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,6 +1,7 @@ import { EnvironmentId, MessageId, + OrchestrationDispatchCommandError, ProjectId, ProviderInstanceId, ThreadId, @@ -25,6 +26,7 @@ import { reconcileRetainedMountedThreadIds, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + shouldRenewDraftThreadIdAfterFailure, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -64,6 +66,25 @@ function makeThread(overrides: Partial = {}): Thread { }; } +describe("shouldRenewDraftThreadIdAfterFailure", () => { + it("only renews identities explicitly retired by bootstrap cleanup", () => { + expect( + shouldRenewDraftThreadIdAfterFailure( + new OrchestrationDispatchCommandError({ + message: "bootstrap failed", + retryWithNewThreadId: true, + }), + ), + ).toBe(true); + expect( + shouldRenewDraftThreadIdAfterFailure( + new OrchestrationDispatchCommandError({ message: "transport failed" }), + ), + ).toBe(false); + expect(shouldRenewDraftThreadIdAfterFailure(new Error("unrelated"))).toBe(false); + }); +}); + const completedTurn = { turnId: TurnId.make("turn-1"), state: "completed" as const, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 466c9b24c87..acb30cd2f84 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,6 +1,7 @@ import { type EnvironmentId, isProviderDriverKind, + OrchestrationDispatchCommandError, ProjectId, type ModelSelection, type ProviderDriverKind, @@ -26,6 +27,11 @@ export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); + +export function shouldRenewDraftThreadIdAfterFailure(error: unknown): boolean { + return isOrchestrationDispatchCommandError(error) && error.retryWithNewThreadId === true; +} export function resolveThreadMetadataUpdateForNextTurn(input: { currentModelSelection: ModelSelection; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab1256cddb3..314610f8ccc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -266,6 +266,7 @@ import { resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, + shouldRenewDraftThreadIdAfterFailure, waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -4812,6 +4813,16 @@ function ChatViewContent(props: ChatViewProps) { } if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); + if (isLocalDraftThread && shouldRenewDraftThreadIdAfterFailure(error)) { + useComposerDraftStore + .getState() + .renewDraftThreadId( + composerDraftTarget, + threadIdForSend, + newThreadId(), + new Date().toISOString(), + ); + } setThreadError( threadIdForSend, error instanceof Error ? error.message : "Failed to send message.", diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index bc1b7107306..45d32939697 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -767,6 +767,87 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); + it("renews a failed bootstrap identity without losing draft content or project mapping", () => { + const store = useComposerDraftStore.getState(); + const nextThreadId = ThreadId.make("thread-renewed"); + const nextCreatedAt = "2026-01-02T00:00:00.000Z"; + const previousThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "preserve me"); + + expect(store.renewDraftThreadId(draftId, threadId, nextThreadId, nextCreatedAt)).toBe(true); + + const next = useComposerDraftStore.getState(); + expect(next.getDraftThread(draftId)).toMatchObject({ + threadId: nextThreadId, + createdAt: nextCreatedAt, + promotedTo: null, + }); + expect(next.getDraftThreadByProjectRef(projectRef)?.threadId).toBe(nextThreadId); + expect(next.getDraftThreadByRef(previousThreadRef)).toBeNull(); + expect(next.getComposerDraft(draftId)?.prompt).toBe("preserve me"); + }); + + it("renews a draft marked promoting by a bootstrap thread deleted during cleanup", () => { + const store = useComposerDraftStore.getState(); + const nextThreadId = ThreadId.make("thread-reminted"); + const renewedAt = "2026-01-03T00:00:00.000Z"; + const bootstrapThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "retry me"); + // Bootstrap creates the server thread before preparing the worktree, so + // the client marks the draft as promoting just before the bootstrap fails + // and deletes that thread again. + markPromotedDraftThreadByRef(bootstrapThreadRef); + expect(store.getDraftThread(draftId)?.promotedTo).toEqual(bootstrapThreadRef); + + expect(store.renewDraftThreadId(draftId, threadId, nextThreadId, renewedAt)).toBe(true); + + const next = useComposerDraftStore.getState(); + expect(next.getDraftThread(draftId)).toMatchObject({ + threadId: nextThreadId, + createdAt: renewedAt, + promotedTo: null, + }); + // Clearing the stale promotion marker makes the draft the project's active + // draft session again, so the retry reuses it instead of stranding it. + expect(next.getDraftThreadByProjectRef(projectRef)?.draftId).toBe(draftId); + expect(next.getComposerDraft(draftId)?.prompt).toBe("retry me"); + }); + + it("does not renew a draft that was promoted to a different server thread", () => { + const store = useComposerDraftStore.getState(); + const nextThreadId = ThreadId.make("thread-should-not-win"); + const promotedRef = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, otherThreadId); + + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.markDraftThreadPromoting(draftId, promotedRef); + + expect( + store.renewDraftThreadId(draftId, threadId, nextThreadId, "2026-01-03T00:00:00.000Z"), + ).toBe(false); + const next = useComposerDraftStore.getState(); + expect(next.getDraftThread(draftId)?.threadId).toBe(threadId); + expect(next.getDraftThread(draftId)?.promotedTo).toEqual(promotedRef); + }); + + it("does not renew a draft whose reserved identity changed concurrently", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + + expect( + store.renewDraftThreadId( + draftId, + otherThreadId, + ThreadId.make("thread-should-not-win"), + "2026-01-02T00:00:00.000Z", + ), + ).toBe(false); + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.threadId).toBe(threadId); + }); + it("clears only matching project draft mapping entries", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index d4f38adcacd..20b5859fc28 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -399,6 +399,17 @@ interface ComposerDraftStoreState { markDraftThreadPromoting: (threadRef: ComposerThreadTarget, promotedTo?: ScopedThreadRef) => void; /** Removes draft-session metadata after promotion is complete. */ finalizePromotedDraftThread: (threadRef: ComposerThreadTarget) => void; + /** + * Replaces the reserved server thread identity after a failed bootstrap + * conclusively cleaned up the previous identity. Any promotion marker left + * behind for that now-deleted identity is cleared with it. + */ + renewDraftThreadId: ( + threadRef: ComposerThreadTarget, + expectedThreadId: ThreadId, + nextThreadId: ThreadId, + createdAt: string, + ) => boolean; clearDraftThread: (threadRef: ComposerThreadTarget) => void; setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; setPrompt: (threadRef: ComposerThreadTarget, prompt: string) => void; @@ -2472,6 +2483,47 @@ const composerDraftStore = create()( return removeDraftThreadReferences(state, threadKey); }); }, + renewDraftThreadId: (threadRef, expectedThreadId, nextThreadId, createdAt) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return false; + } + let renewed = false; + set((state) => { + const existing = state.draftThreadsByThreadKey[threadKey]; + if (existing === undefined || existing.threadId !== expectedThreadId) { + return state; + } + // Bootstrap materializes the server thread before it prepares the + // worktree, so the client can observe `thread.created` and mark the + // draft as promoting moments before bootstrap fails and deletes + // that same thread again. A promotion marker pointing at the + // identity we are replacing is therefore stale, and clearing it is + // exactly the recovery this renewal performs. A marker pointing at + // any *other* server thread means the draft really was promoted, + // and its identity must not be reminted. + const stalePromotedTo = scopeThreadRef(existing.environmentId, expectedThreadId); + if ( + existing.promotedTo != null && + !scopedThreadRefsEqual(existing.promotedTo, stalePromotedTo) + ) { + return state; + } + renewed = true; + return { + draftThreadsByThreadKey: { + ...state.draftThreadsByThreadKey, + [threadKey]: { + ...existing, + threadId: nextThreadId, + createdAt, + promotedTo: null, + }, + }, + }; + }); + return renewed; + }, clearDraftThread: (threadRef) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 84b7a8fa07f..bb91ede0a69 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1415,6 +1415,7 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass