diff --git a/apps/server/src/orchestration-v2/AttachmentClaims.test.ts b/apps/server/src/orchestration-v2/AttachmentClaims.test.ts index a67f7ee180db7..64d00d6949c84 100644 --- a/apps/server/src/orchestration-v2/AttachmentClaims.test.ts +++ b/apps/server/src/orchestration-v2/AttachmentClaims.test.ts @@ -149,18 +149,14 @@ describe("AttachmentClaims", () => { const mismatched = { ...staged, sizeBytes: 999 }; const config = yield* ServerConfig.ServerConfig; - const result = yield* Effect.exit( + const failure = yield* Effect.flip( claimPendingAttachments({ threadId: "thread-claims-4", attachments: [good, mismatched], }), ); - - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") { - const error = result.cause; - expect(String(error)).toContain("stored size does not match"); - } + expect(failure.message).toContain("stored size does not match"); + expect(failure.attachmentId).toBe(mismatched.id); const claimedFiles = NodeFS.readdirSync(config.attachmentsDir).filter((entry) => entry.startsWith("thread-claims-4-"), ); diff --git a/apps/server/src/orchestration-v2/AttachmentClaims.ts b/apps/server/src/orchestration-v2/AttachmentClaims.ts index be48ef68b5559..2bc018ea07bbe 100644 --- a/apps/server/src/orchestration-v2/AttachmentClaims.ts +++ b/apps/server/src/orchestration-v2/AttachmentClaims.ts @@ -21,6 +21,7 @@ export class AttachmentClaimError extends Schema.TaggedError new AttachmentClaimError({ + attachmentId: attachment.id, message: `Attachment '${attachment.name}' cannot be sent: attachment not found.`, cause, }), @@ -113,6 +116,7 @@ export const claimPendingAttachments = Effect.fn("AttachmentClaims.claimPendingA ); if (Number(info.size) !== attachment.sizeBytes) { return yield* new AttachmentClaimError({ + attachmentId: attachment.id, message: `Attachment '${attachment.name}' cannot be sent: stored size does not match.`, }); } @@ -133,6 +137,7 @@ export const claimPendingAttachments = Effect.fn("AttachmentClaims.claimPendingA }); if (expectedPath !== claim.finalPath) { return yield* new AttachmentClaimError({ + attachmentId: attachment.id, message: `Attachment '${attachment.name}' cannot be sent: attachment type does not match the upload.`, }); } @@ -142,6 +147,7 @@ export const claimPendingAttachments = Effect.fn("AttachmentClaims.claimPendingA Effect.mapError( (cause) => new AttachmentClaimError({ + attachmentId: attachment.id, message: `Failed to claim attachment '${attachment.name}' for this thread.`, cause, }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4de72cce57d83..e908bae1f24fe 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1998,6 +1998,9 @@ export const layerWsRpc = ( commandType: command.type, message: detail ?? "Failed to dispatch orchestration V2 command", ...(detail === undefined ? {} : { detail }), + ...(cause._tag === "AttachmentClaimError" && cause.attachmentId !== undefined + ? { attachmentId: cause.attachmentId } + : {}), cause, }); }), @@ -2221,6 +2224,9 @@ export const layerWsRpc = ( commandId: input.commandId, projectId: input.projectId, message: cause.message, + ...(cause.attachmentId !== undefined + ? { attachmentId: cause.attachmentId } + : {}), cause, }), ThreadLaunchError: (cause) => diff --git a/apps/web/src/components/chat/chatViewSend.ts b/apps/web/src/components/chat/chatViewSend.ts index 58f12549d15f3..47863c079ad05 100644 --- a/apps/web/src/components/chat/chatViewSend.ts +++ b/apps/web/src/components/chat/chatViewSend.ts @@ -81,6 +81,7 @@ export async function startFanoutThread(input: { readonly uncertainSubmissions: Map; readonly start: (threadId: ThreadId) => Promise>; readonly openThread: (threadId: ThreadId) => void; + readonly onFailure?: (error: unknown) => void; }): Promise { const { retryKey, uncertainSubmissions } = input; const uncertainThreadId = uncertainSubmissions.get(retryKey); @@ -103,6 +104,7 @@ export async function startFanoutThread(input: { } return true; } catch (error) { + input.onFailure?.(error); if (requestMayHaveStarted && !uncertainSubmissions.has(retryKey)) { uncertainSubmissions.set(retryKey, targetThreadId); } diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 1335bbdde2d14..7e7d5fbd6959b 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -108,6 +108,7 @@ import { useMultipleModelSend } from "./useMultipleModelSend"; import { type ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; import { getUploadedAttachments, + markAttachmentClaimFailure, refreshExpiredAttachmentUploads, readAttachmentUpload, releaseDraftAttachments, @@ -1494,6 +1495,11 @@ export function useComposerSend(input: ComposerSendInput) { } if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); + markAttachmentClaimFailure({ + environmentId, + attachments: composerAttachmentsSnapshot, + error, + }); setThreadError( threadIdForSend, error instanceof Error ? error.message : "Failed to send message.", diff --git a/apps/web/src/components/chat/useMultipleModelSend.ts b/apps/web/src/components/chat/useMultipleModelSend.ts index ccf3b32621ce2..c6ada6a6cd314 100644 --- a/apps/web/src/components/chat/useMultipleModelSend.ts +++ b/apps/web/src/components/chat/useMultipleModelSend.ts @@ -18,7 +18,10 @@ import { useComposerDraftStore, } from "../../composerDraftStore"; import type { ComposerHandleRef } from "../../composerHandleContext"; -import { releaseDraftAttachments } from "../../lib/attachmentUploadQueue"; +import { + markAttachmentClaimFailure, + releaseDraftAttachments, +} from "../../lib/attachmentUploadQueue"; import type { buildMessageContext } from "../../lib/composerContextRecords"; import { appAtomRegistry } from "../../rpc/atomRegistry"; import { threadEnvironment } from "../../state/threads"; @@ -162,6 +165,12 @@ export function useMultipleModelSend(input: MultipleModelSendInput) { ]), model: target.selection.model, uncertainSubmissions: uncertainSubmissionsRef.current, + onFailure: (error) => + markAttachmentClaimFailure({ + environmentId, + attachments: attachmentsSnapshot, + error, + }), start: (targetThreadId) => { return startThreadTurn({ environmentId, diff --git a/apps/web/src/lib/attachmentUploadQueue.test.ts b/apps/web/src/lib/attachmentUploadQueue.test.ts index a9483e1755e35..9c3669261c654 100644 --- a/apps/web/src/lib/attachmentUploadQueue.test.ts +++ b/apps/web/src/lib/attachmentUploadQueue.test.ts @@ -56,6 +56,7 @@ vi.mock("../state/session", () => ({ import { awaitAttachmentUploads, getUploadedAttachments, + markAttachmentClaimFailure, readAttachmentUpload, refreshExpiredAttachmentUploads, releaseAttachmentUpload, @@ -211,6 +212,96 @@ describe("attachmentUploadQueue", () => { vi.unstubAllGlobals(); }); + it("marks only the matching upload when same-named images fail server validation", () => { + const images = [makeImage("first"), makeImage("second")].map((image) => ({ + ...image, + name: "image.png", + })); + useAttachmentUploadStore.setState({ + uploadsByImageId: { + first: { status: "ready", environmentId: firstEnvironment, attachmentId: "pending-1" }, + second: { status: "ready", environmentId: firstEnvironment, attachmentId: "pending-2" }, + }, + }); + markAttachmentClaimFailure({ + environmentId: firstEnvironment, + attachments: images, + error: { attachmentId: "pending-2" }, + }); + expect(readAttachmentUpload("first")?.status).toBe("ready"); + expect(readAttachmentUpload("second")).toMatchObject({ + status: "failed", + attachmentId: "pending-2", + }); + }); + + it("rechecks a server-rejected restored document when local bytes are unavailable", async () => { + const file = { + ...makeFile("restored"), + file: null, + uploadedAttachmentId: "pending-original", + uploadEnvironmentId: firstEnvironment, + }; + useAttachmentUploadStore.setState({ + uploadsByImageId: { + [file.id]: { + status: "ready", + environmentId: firstEnvironment, + attachmentId: "pending-original", + }, + }, + }); + mocks.executeAtomQuery.mockResolvedValue({ _tag: "Success", value: {} }); + markAttachmentClaimFailure({ + environmentId: firstEnvironment, + attachments: [file], + error: { attachmentId: "pending-original" }, + }); + retryAttachmentUpload({ environmentId: firstEnvironment, image: file }); + await awaitAttachmentUploads([file.id]); + expect( + getUploadedAttachments({ environmentId: firstEnvironment, images: [file] })?.[0]?.id, + ).toBe("pending-original"); + expect(TestXmlHttpRequest.requests).toHaveLength(0); + }); + + it("reuploads a server-rejected persisted document instead of reusing its ID", async () => { + const file = { + ...makeFile("rejected"), + uploadedAttachmentId: "pending-original", + uploadEnvironmentId: firstEnvironment, + }; + useAttachmentUploadStore.setState({ + uploadsByImageId: { + [file.id]: { + status: "ready", + environmentId: firstEnvironment, + attachmentId: "pending-original", + }, + }, + }); + markAttachmentClaimFailure({ + environmentId: firstEnvironment, + attachments: [file], + error: { attachmentId: "pending-original" }, + }); + retryAttachmentUpload({ environmentId: firstEnvironment, image: file }); + await vi.waitFor(() => expect(TestXmlHttpRequest.requests).toHaveLength(1)); + const failed = awaitAttachmentUploads([file.id]); + TestXmlHttpRequest.requests[0]!.complete(500); + await failed; + expect(readAttachmentUpload(file.id)).toMatchObject({ status: "failed", forceReupload: true }); + retryAttachmentUpload({ environmentId: firstEnvironment, image: file }); + await vi.waitFor(() => expect(TestXmlHttpRequest.requests).toHaveLength(2)); + const settled = awaitAttachmentUploads([file.id]); + TestXmlHttpRequest.requests[1]!.complete(); + await settled; + expect( + getUploadedAttachments({ environmentId: firstEnvironment, images: [file] })?.[0]?.id, + ).not.toBe("pending-original"); + expect(mocks.executeAtomQuery).not.toHaveBeenCalled(); + }); + it("reuploads an expired image while retaining other ready images", async () => { const images = [makeImage("first"), makeImage("second")].map((image) => ({ ...image, diff --git a/apps/web/src/lib/attachmentUploadQueue.ts b/apps/web/src/lib/attachmentUploadQueue.ts index cf9bbedeb853a..2bb934e909604 100644 --- a/apps/web/src/lib/attachmentUploadQueue.ts +++ b/apps/web/src/lib/attachmentUploadQueue.ts @@ -48,6 +48,7 @@ interface UploadJob { /** Original owner; completion resolves the current owner because uploads can outlive draft moves. */ readonly draftTarget?: ComposerThreadTarget; readonly previous?: ReadyAttachmentUpload; + readonly forceReupload?: boolean; /** Draft-owned; delete only through releasePersistedAttachmentUpload, never queue cancel/retry. */ readonly persistedAttachmentId?: string; readonly settled: Promise; @@ -85,6 +86,39 @@ export function readAttachmentUpload(imageId: string): AttachmentUploadState | u return useAttachmentUploadStore.getState().uploadsByImageId[imageId]; } +/** Associate a server claim failure with the local draft attachment, regardless of its name. */ +export function markAttachmentClaimFailure(input: { + readonly environmentId: EnvironmentId; + readonly attachments: ReadonlyArray; + readonly error: unknown; +}): void { + if ( + typeof input.error !== "object" || + input.error === null || + !("attachmentId" in input.error) || + typeof input.error.attachmentId !== "string" + ) + return; + for (const attachment of input.attachments) { + const upload = readAttachmentUpload(attachment.id); + if ( + upload?.status !== "ready" || + upload.environmentId !== input.environmentId || + upload.attachmentId !== input.error.attachmentId + ) + continue; + setUploadState(attachment.id, { + status: "failed", + environmentId: input.environmentId, + forceReupload: attachment.file !== null, + ...(attachment.type !== "file" || attachment.uploadedAttachmentId !== upload.attachmentId + ? { attachmentId: upload.attachmentId } + : {}), + reason: input.error instanceof Error ? input.error.message : "Attachment could not be sent.", + }); + } +} + function resolveCurrentFileDraftTarget(job: DraftFileOwner): ComposerThreadTarget | undefined { if (job.draftTarget === undefined || job.image.type !== "file") { return undefined; @@ -170,6 +204,7 @@ async function runUpload(job: UploadJob): Promise { setUploadState(job.image.id, { status: "failed", environmentId: job.environmentId, + ...(job.forceReupload ? { forceReupload: true } : {}), reason: "This connection cannot upload attachments.", ...(job.previous ? { previous: job.previous } : {}), }); @@ -216,6 +251,7 @@ async function runUpload(job: UploadJob): Promise { setUploadState(job.image.id, { status: "failed", environmentId: job.environmentId, + ...(job.forceReupload ? { forceReupload: true } : {}), reason: verification.status === "missing" ? "Uploaded file expired. Remove it and attach it again." @@ -236,6 +272,7 @@ async function runUpload(job: UploadJob): Promise { setUploadState(job.image.id, { status: "failed", environmentId: job.environmentId, + ...(job.forceReupload ? { forceReupload: true } : {}), reason: "Unsupported image type", ...(job.previous ? { previous: job.previous } : {}), }); @@ -246,6 +283,7 @@ async function runUpload(job: UploadJob): Promise { setUploadState(job.image.id, { status: "failed", environmentId: job.environmentId, + ...(job.forceReupload ? { forceReupload: true } : {}), reason: "Original file is no longer available", ...(job.previous ? { previous: job.previous } : {}), }); @@ -257,6 +295,7 @@ async function runUpload(job: UploadJob): Promise { setUploadState(job.image.id, { status: "failed", environmentId: job.environmentId, + ...(job.forceReupload ? { forceReupload: true } : {}), reason: "This connection cannot upload attachments.", ...(job.previous ? { previous: job.previous } : {}), }); @@ -332,6 +371,7 @@ async function runUpload(job: UploadJob): Promise { setUploadState(job.image.id, { status: "failed", environmentId: job.environmentId, + ...(job.forceReupload ? { forceReupload: true } : {}), reason: result.step === "mint" ? "Upload could not start" @@ -367,6 +407,7 @@ function pumpUploads(): void { setUploadState(job.image.id, { status: "failed", environmentId: job.environmentId, + ...(job.forceReupload ? { forceReupload: true } : {}), reason: "Upload failed", ...(job.previous ? { previous: job.previous } : {}), }); @@ -445,6 +486,7 @@ export function startAttachmentUpload(input: { image: input.image, environmentId: input.environmentId, ...(input.draftTarget !== undefined ? { draftTarget: input.draftTarget } : {}), + ...(input.force ? { forceReupload: true } : {}), ...(previous ? { previous } : {}), ...(!input.force && input.image.type === "file" && @@ -569,7 +611,10 @@ export function retryAttachmentUpload(input: { } else { clearUploadState(input.image.id); } - startAttachmentUpload(input); + startAttachmentUpload({ + ...input, + force: previous?.status === "failed" && previous.forceReupload === true, + }); } /** Verifies uploads before stash restore; pending uploads expire after 24 hours. */ diff --git a/apps/web/src/lib/attachmentUploadState.ts b/apps/web/src/lib/attachmentUploadState.ts index a07f1d108dd5e..e0457f6939d4c 100644 --- a/apps/web/src/lib/attachmentUploadState.ts +++ b/apps/web/src/lib/attachmentUploadState.ts @@ -18,6 +18,8 @@ export type AttachmentUploadState = readonly status: "failed"; readonly environmentId: EnvironmentId; readonly reason: string; + /** A server claim rejected this upload; retry must not reuse its persisted ID. */ + readonly forceReupload?: boolean; /** Retry/release deletes this id; never use a persisted draft attachment id. */ readonly attachmentId?: string; readonly previous?: ReadyAttachmentUpload; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 44e9aa8290e9a..76abddf31bf44 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -4193,6 +4193,7 @@ export class OrchestrationV2DispatchCommandError extends Schema.TaggedError