authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-08-20 11:46:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-21 00:16:28+05:30
log8824f8f24f8b81fd6aed6f228a57a1d4013d01d9
tree360334326c9ca7bc2fadded858b63d91f60bbbb5
parentbeab6886f45bf42906d0bd01aefe5dfe9e66a867
signature Signed by PGP key B5690EEEBB952194

fix(web): retry failed thread bootstraps with a fresh id (#7664)


9 files changed, 207 insertions(+), 3 deletions(-)

apps/server/src/server.test.ts+83
...@@ -7893,6 +7893,89 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -7893,6 +7893,89 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
7893 assertTrue(result._tag === "Failure");7893 assertTrue(result._tag === "Failure");
7894 assertTrue(result.failure._tag === "OrchestrationDispatchCommandError");7894 assertTrue(result.failure._tag === "OrchestrationDispatchCommandError");
7895 assert.include(result.failure.message, "worktree exploded");7895 assert.include(result.failure.message, "worktree exploded");
7896 assert.strictEqual(result.failure.bootstrapThreadDisposition, "deleted");
7897 assert.deepEqual(
7898 dispatchedCommands.map((command) => command.type),
7899 ["thread.create", "thread.delete"],
7900 );
7901 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
7902 );
7903
7904 it.effect("does not report a deleted bootstrap thread when cleanup fails", () =>
7905 Effect.gen(function* () {
7906 const dispatchedCommands: Array<OrchestrationCommand> = [];
7907 const createWorktree = vi.fn(
7908 (_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["createWorktree"]>[0]) =>
7909 Effect.die(new Error("worktree exploded")),
7910 );
7911
7912 yield* buildAppUnderTest({
7913 layers: {
7914 gitVcsDriver: {
7915 createWorktree,
7916 },
7917 orchestrationEngine: {
7918 dispatch: (command) => {
7919 dispatchedCommands.push(command);
7920 if (command.type === "thread.delete") {
7921 return Effect.fail(
7922 new OrchestrationListenerCallbackError({
7923 listener: "domain-event",
7924 detail: "thread cleanup exploded",
7925 }),
7926 );
7927 }
7928 return Effect.succeed({ sequence: dispatchedCommands.length });
7929 },
7930 readEvents: () => Stream.empty,
7931 },
7932 },
7933 });
7934
7935 const createdAt = "2026-01-01T00:00:00.000Z";
7936 const wsUrl = yield* getWsServerUrl("/ws");
7937 const result = yield* Effect.scoped(
7938 withWsRpcClient(wsUrl, (client) =>
7939 client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
7940 type: "thread.turn.start",
7941 commandId: CommandId.make("cmd-bootstrap-turn-start-cleanup-defect"),
7942 threadId: ThreadId.make("thread-bootstrap-cleanup-defect"),
7943 message: {
7944 messageId: MessageId.make("msg-bootstrap-cleanup-defect"),
7945 role: "user",
7946 text: "hello",
7947 attachments: [],
7948 },
7949 modelSelection: defaultModelSelection,
7950 runtimeMode: "full-access",
7951 interactionMode: "default",
7952 bootstrap: {
7953 createThread: {
7954 projectId: defaultProjectId,
7955 title: "Bootstrap Thread",
7956 modelSelection: defaultModelSelection,
7957 runtimeMode: "full-access",
7958 interactionMode: "default",
7959 branch: "main",
7960 worktreePath: null,
7961 createdAt,
7962 },
7963 prepareWorktree: {
7964 projectCwd: "/tmp/project",
7965 baseBranch: "main",
7966 branch: "t3code/bootstrap-refName",
7967 },
7968 runSetupScript: false,
7969 },
7970 createdAt,
7971 }),
7972 ).pipe(Effect.result),
7973 );
7974
7975 assertTrue(result._tag === "Failure");
7976 assertTrue(result.failure._tag === "OrchestrationDispatchCommandError");
7977 assert.include(result.failure.message, "worktree exploded");
7978 assert.strictEqual(result.failure.bootstrapThreadDisposition, undefined);
7896 assert.deepEqual(7979 assert.deepEqual(
7897 dispatchedCommands.map((command) => command.type),7980 dispatchedCommands.map((command) => command.type),
7898 ["thread.create", "thread.delete"],7981 ["thread.create", "thread.delete"],
apps/server/src/ws.ts+23-3
...@@ -775,9 +775,9 @@ const makeWsRpcLayer = (...@@ -775,9 +775,9 @@ const makeWsRpcLayer = (
775 threadId: command.threadId,775 threadId: command.threadId,
776 }),776 }),
777 ),777 ),
778 Effect.ignoreCause({ log: true }),778 Effect.as(true),
779 )779 )
780 : Effect.void;780 : Effect.succeed(false);
781781
782 const recordSetupScriptLaunchFailure = (input: {782 const recordSetupScriptLaunchFailure = (input: {
783 readonly error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError;783 readonly error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError;
...@@ -964,7 +964,27 @@ const makeWsRpcLayer = (...@@ -964,7 +964,27 @@ const makeWsRpcLayer = (
964 if (Cause.hasInterruptsOnly(cause)) {964 if (Cause.hasInterruptsOnly(cause)) {
965 return Effect.fail(dispatchError);965 return Effect.fail(dispatchError);
966 }966 }
967 return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(dispatchError)));967 return Effect.uninterruptible(cleanupCreatedThread()).pipe(
968 Effect.matchCauseEffect({
969 onFailure: (cleanupCause) =>
970 Effect.logWarning("bootstrap thread cleanup failed", {
971 threadId: command.threadId,
972 detail: Cause.pretty(cleanupCause),
973 }).pipe(Effect.flatMap(() => Effect.fail(dispatchError))),
974 onSuccess: (threadDeleted) =>
975 Effect.fail(
976 threadDeleted
977 ? new OrchestrationDispatchCommandError({
978 message: dispatchError.message,
979 ...(dispatchError.cause !== undefined
980 ? { cause: dispatchError.cause }
981 : {}),
982 bootstrapThreadDisposition: "deleted",
983 })
984 : dispatchError,
985 ),
986 }),
987 );
968 }),988 }),
969 );989 );
970 });990 });
apps/web/src/components/ChatView.tsx+15
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26 connectionStatusTitle,26 connectionStatusTitle,
27 type EnvironmentConnectionPresentation,27 type EnvironmentConnectionPresentation,
28} from "@t3tools/client-runtime/connection";28} from "@t3tools/client-runtime/connection";
29import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
29import {30import {
30 changeRequestAutoSettles,31 changeRequestAutoSettles,
31 effectiveSettled,32 effectiveSettled,
...@@ -5454,6 +5455,20 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5454,6 +5455,20 @@ function ChatViewContent(props: ChatViewProps) {
5454 }5455 }
5455 if (!isAtomCommandInterrupted(failure)) {5456 if (!isAtomCommandInterrupted(failure)) {
5456 const error = squashAtomCommandFailure(failure);5457 const error = squashAtomCommandFailure(failure);
5458 if (isLocalDraftThread && draftId && wasBootstrapThreadDeleted(error)) {
5459 const failedDraftSession = getDraftSession(draftId);
5460 if (failedDraftSession?.threadId === threadIdForSend) {
5461 setLogicalProjectDraftThreadId(
5462 failedDraftSession.logicalProjectKey,
5463 scopeProjectRef(failedDraftSession.environmentId, failedDraftSession.projectId),
5464 draftId,
5465 {
5466 threadId: newThreadId(),
5467 createdAt: new Date().toISOString(),
5468 },
5469 );
5470 }
5471 }
5457 setThreadError(5472 setThreadError(
5458 threadIdForSend,5473 threadIdForSend,
5459 error instanceof Error ? error.message : "Failed to send message.",5474 error instanceof Error ? error.message : "Failed to send message.",
apps/web/src/composerDraftStore.test.ts+37
...@@ -823,6 +823,43 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -823,6 +823,43 @@ describe("composerDraftStore project draft thread mapping", () => {
823 });823 });
824 });824 });
825825
826 it("rotates a failed bootstrap thread id without losing its draft", () => {
827 const store = useComposerDraftStore.getState();
828 const retryThreadId = ThreadId.make("thread-retry");
829 store.setProjectDraftThreadId(projectRef, draftId, {
830 threadId,
831 branch: "feature/test",
832 worktreePath: null,
833 createdAt: "2026-01-01T00:00:00.000Z",
834 envMode: "worktree",
835 startFromOrigin: true,
836 runtimeMode: "approval-required",
837 interactionMode: "plan",
838 });
839 store.setPrompt(draftId, "keep this prompt");
840 markPromotedDraftThread(threadId);
841
842 store.setLogicalProjectDraftThreadId(scopedProjectKey(projectRef), projectRef, draftId, {
843 threadId: retryThreadId,
844 createdAt: "2026-01-01T00:01:00.000Z",
845 });
846
847 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
848 threadId: retryThreadId,
849 branch: "feature/test",
850 worktreePath: null,
851 createdAt: "2026-01-01T00:01:00.000Z",
852 envMode: "worktree",
853 startFromOrigin: true,
854 runtimeMode: "approval-required",
855 interactionMode: "plan",
856 promotedTo: null,
857 });
858 expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.prompt).toBe(
859 "keep this prompt",
860 );
861 });
862
826 it("clears only matching project draft mapping entries", () => {863 it("clears only matching project draft mapping entries", () => {
827 const store = useComposerDraftStore.getState();864 const store = useComposerDraftStore.getState();
828 store.setProjectDraftThreadId(projectRef, draftId, { threadId });865 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
packages/client-runtime/src/errors/index.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1export * from "./errorTrace.ts";1export * from "./errorTrace.ts";
2export * from "./orchestration.ts";
2export * from "./safeLog.ts";3export * from "./safeLog.ts";
3export * from "./transport.ts";4export * from "./transport.ts";
packages/client-runtime/src/errors/orchestration.test.ts created+23
...@@ -0,0 +1,23 @@
1import { OrchestrationDispatchCommandError } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { wasBootstrapThreadDeleted } from "./orchestration.ts";
5
6describe("wasBootstrapThreadDeleted", () => {
7 it("accepts only a confirmed deleted bootstrap thread", () => {
8 expect(
9 wasBootstrapThreadDeleted(
10 new OrchestrationDispatchCommandError({
11 message: "Failed to create worktree.",
12 bootstrapThreadDisposition: "deleted",
13 }),
14 ),
15 ).toBe(true);
16 expect(
17 wasBootstrapThreadDeleted(
18 new OrchestrationDispatchCommandError({ message: "Failed to create worktree." }),
19 ),
20 ).toBe(false);
21 expect(wasBootstrapThreadDeleted(new Error("connection lost"))).toBe(false);
22 });
23});
packages/client-runtime/src/errors/orchestration.ts created+10
...@@ -0,0 +1,10 @@
1import { OrchestrationDispatchCommandError } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3
4const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);
5
6export function wasBootstrapThreadDeleted(error: unknown): boolean {
7 return (
8 isOrchestrationDispatchCommandError(error) && error.bootstrapThreadDisposition === "deleted"
9 );
10}
packages/contracts/src/orchestration.test.ts+14
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 DEFAULT_RUNTIME_MODE,7 DEFAULT_RUNTIME_MODE,
8 ModelSelection,8 ModelSelection,
9 OrchestrationCommand,9 OrchestrationCommand,
10 OrchestrationDispatchCommandError,
10 OrchestrationEvent,11 OrchestrationEvent,
11 OrchestrationGetFullThreadDiffInput,12 OrchestrationGetFullThreadDiffInput,
12 OrchestrationGetTurnDiffInput,13 OrchestrationGetTurnDiffInput,
...@@ -54,6 +55,19 @@ const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPaylo...@@ -54,6 +55,19 @@ const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPaylo
54const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand);55const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand);
55const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent);56const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent);
56const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload);57const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload);
58const decodeDispatchCommandError = Schema.decodeUnknownEffect(OrchestrationDispatchCommandError);
59
60it.effect("decodes a dispatch error after its bootstrap thread was deleted", () =>
61 Effect.gen(function* () {
62 const error = yield* decodeDispatchCommandError({
63 _tag: "OrchestrationDispatchCommandError",
64 message: "Failed to create worktree.",
65 bootstrapThreadDisposition: "deleted",
66 });
67
68 assert.strictEqual(error.bootstrapThreadDisposition, "deleted");
69 }),
70);
5771
58it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>72it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>
59 Effect.gen(function* () {73 Effect.gen(function* () {
packages/contracts/src/orchestration.ts+1
...@@ -1711,6 +1711,7 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass<O...@@ -1711,6 +1711,7 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass<O
1711 {1711 {
1712 message: TrimmedNonEmptyString,1712 message: TrimmedNonEmptyString,
1713 cause: Schema.optional(Schema.Defect()),1713 cause: Schema.optional(Schema.Defect()),
1714 bootstrapThreadDisposition: Schema.optional(Schema.Literal("deleted")),
1714 },1715 },
1715) {}1716) {}
17161717