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) => {
78937893 assertTrue(result._tag === "Failure");
78947894 assertTrue(result.failure._tag === "OrchestrationDispatchCommandError");
78957895 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);
78967979 assert.deepEqual(
78977980 dispatchedCommands.map((command) => command.type),
78987981 ["thread.create", "thread.delete"],
apps/server/src/ws.ts+23-3
......@@ -775,9 +775,9 @@ const makeWsRpcLayer = (
775775 threadId: command.threadId,
776776 }),
777777 ),
778 Effect.ignoreCause({ log: true }),
778 Effect.as(true),
779779 )
780 : Effect.void;
780 : Effect.succeed(false);
781781
782782 const recordSetupScriptLaunchFailure = (input: {
783783 readonly error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError;
......@@ -964,7 +964,27 @@ const makeWsRpcLayer = (
964964 if (Cause.hasInterruptsOnly(cause)) {
965965 return Effect.fail(dispatchError);
966966 }
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 );
968988 }),
969989 );
970990 });
apps/web/src/components/ChatView.tsx+15
......@@ -26,6 +26,7 @@ import {
2626 connectionStatusTitle,
2727 type EnvironmentConnectionPresentation,
2828} from "@t3tools/client-runtime/connection";
29import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
2930import {
3031 changeRequestAutoSettles,
3132 effectiveSettled,
......@@ -5454,6 +5455,20 @@ function ChatViewContent(props: ChatViewProps) {
54545455 }
54555456 if (!isAtomCommandInterrupted(failure)) {
54565457 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 }
54575472 setThreadError(
54585473 threadIdForSend,
54595474 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", () => {
823823 });
824824 });
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
826863 it("clears only matching project draft mapping entries", () => {
827864 const store = useComposerDraftStore.getState();
828865 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
packages/client-runtime/src/errors/index.ts+1
......@@ -1,3 +1,4 @@
11export * from "./errorTrace.ts";
2export * from "./orchestration.ts";
23export * from "./safeLog.ts";
34export * 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 {
77 DEFAULT_RUNTIME_MODE,
88 ModelSelection,
99 OrchestrationCommand,
10 OrchestrationDispatchCommandError,
1011 OrchestrationEvent,
1112 OrchestrationGetFullThreadDiffInput,
1213 OrchestrationGetTurnDiffInput,
......@@ -54,6 +55,19 @@ const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPaylo
5455const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand);
5556const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent);
5657const 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
5872it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>
5973 Effect.gen(function* () {
packages/contracts/src/orchestration.ts+1
......@@ -1711,6 +1711,7 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass<O
17111711 {
17121712 message: TrimmedNonEmptyString,
17131713 cause: Schema.optional(Schema.Defect()),
1714 bootstrapThreadDisposition: Schema.optional(Schema.Literal("deleted")),
17141715 },
17151716) {}
17161717