| author | |
| committer | |
| log | 8824f8f24f8b81fd6aed6f228a57a1d4013d01d9 |
| tree | 360334326c9ca7bc2fadded858b63d91f60bbbb5 |
| parent | beab6886f45bf42906d0bd01aefe5dfe9e66a867 |
| signature | Signed by PGP key B5690EEEBB952194 |
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); |
| 781 | 781 | ||
| 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"; |
| 29 | import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; | ||
| 29 | import { | 30 | import { |
| 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 | }); |
| 825 | 825 | ||
| 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 @@ |
| 1 | export * from "./errorTrace.ts"; | 1 | export * from "./errorTrace.ts"; |
| 2 | export * from "./orchestration.ts"; | ||
| 2 | export * from "./safeLog.ts"; | 3 | export * from "./safeLog.ts"; |
| 3 | export * from "./transport.ts"; | 4 | export * from "./transport.ts"; |
packages/client-runtime/src/errors/orchestration.test.ts created+23| ... | @@ -0,0 +1,23 @@ | ||
| 1 | import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { wasBootstrapThreadDeleted } from "./orchestration.ts"; | ||
| 5 | |||
| 6 | describe("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 @@ | ||
| 1 | import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | |||
| 4 | const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); | ||
| 5 | |||
| 6 | export 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 |
| 54 | const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand); | 55 | const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand); |
| 55 | const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); | 56 | const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); |
| 56 | const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); | 57 | const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); |
| 58 | const decodeDispatchCommandError = Schema.decodeUnknownEffect(OrchestrationDispatchCommandError); | ||
| 59 | |||
| 60 | it.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 | ); | ||
| 57 | 71 | ||
| 58 | it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => | 72 | it.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 | ) {} |
| 1716 | 1717 |