Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
29 changes: 25 additions & 4 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -848,7 +848,7 @@ const makeWsRpcLayer = (
let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd;
let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null;

const cleanupCreatedThread = () =>
const cleanupCreatedThread = (): Effect.Effect<boolean> =>
createdThread
? serverCommandId("bootstrap-thread-delete").pipe(
Effect.flatMap((commandId) =>
Expand All @@ -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;
Expand Down Expand Up @@ -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,
),
),
);
}),
);
});
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
EnvironmentId,
MessageId,
OrchestrationDispatchCommandError,
ProjectId,
ProviderInstanceId,
ThreadId,
Expand All @@ -25,6 +26,7 @@ import {
reconcileRetainedMountedThreadIds,
resolveThreadMetadataUpdateForNextTurn,
resolveSendEnvMode,
shouldRenewDraftThreadIdAfterFailure,
shouldShowBranchMismatchBanner,
shouldWriteThreadErrorToCurrentServerThread,
} from "./ChatView.logic";
Expand Down Expand Up @@ -64,6 +66,25 @@ function makeThread(overrides: Partial<Thread> = {}): 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,
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type EnvironmentId,
isProviderDriverKind,
OrchestrationDispatchCommandError,
ProjectId,
type ModelSelection,
type ProviderDriverKind,
Expand All @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ import {
resolveSendEnvMode,
revokeBlobPreviewUrl,
revokeUserMessagePreviewUrls,
shouldRenewDraftThreadIdAfterFailure,
waitForStartedServerThread,
} from "./ChatView.logic";
import { useLocalStorage } from "~/hooks/useLocalStorage";
Expand Down Expand Up @@ -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.",
Expand Down
81 changes: 81 additions & 0 deletions apps/web/src/composerDraftStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
52 changes: 52 additions & 0 deletions apps/web/src/composerDraftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2472,6 +2483,47 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
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;
}
Comment thread
cursor[bot] marked this conversation as resolved.
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) {
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1415,6 +1415,7 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass<O
{
message: TrimmedNonEmptyString,
cause: Schema.optional(Schema.Defect()),
retryWithNewThreadId: Schema.optional(Schema.Boolean),
},
) {}

Expand Down
Loading