fix(mothership): make chat fork complete, consistent and safe to retry - #8583
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 15 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
|
12d12f5 to
7d69384
Compare
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 15 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
An unreachable host is a connection that never opened, so the request never reached its destination and one more attempt is as safe as after ECONNREFUSED or ENETUNREACH.
…g retries The worker refuses a fork it cannot make with 404 (the chat or message is gone), 409 (the response has not finished) or 413 (the cut is above its ceiling), but every non-2xx became a generic error and a 500. Those are now classified and passed through by the fork route with a message the person can act on. The retry loop's own catch swallowed every first-attempt failure, so a 400, a 500, a malformed receipt and a timed-out attempt were all sent again. A timed-out attempt may still be copying, so only a recognized socket failure or a 502/503/504 gets one more attempt now. The per-attempt timeout is sized from measured copy latency at the worker's ceiling with headroom, so a legitimate fork finishes inside one attempt and a timed-out one is abandoned (the worker rolls back a copy whose caller left).
A 409 or 413 from the fork route tells the person what to do (wait for the response to finish, or fork from an earlier message), so the toast shows that message instead of a generic failure.
When anything after the worker copy failed, most often the final transaction that publishes the chat, the worker kept a conversation Sim had no chat for. The fork now asks the worker to clean that chat up, through the same cleanup endpoint chat deletion uses. It is best effort and a no-op when the worker never committed.
… holds - A file whose blob copy failed is never published, yet the fork's messages and the worker's history were rewritten to its id and key. References to it now stay on the source file, and its resource tab is dropped as before. - In-app /workspace/<id>/files/<fileId> links were left on the source file; the fork stays in the same workspace, so only the file id moves. - Tool-call arguments and display titles kept the source file's id or key. - A Sources tab for a response past the cut was copied, pointing at a message the fork does not have.
- Chat images are stored under the chat's id but were never deleted with the chat. Their keys are rebuilt from the assistant messages that published them, the same way a fork finds them to copy. - Message attachments were deleted as copilot storage whatever their key. Attachments in Chat are workspace-bucket files owned by their workspace_files row, which a fork can share with its source (a failed copy, a deleted file), and the copilot bucket falls back to the workspace bucket on GCS and can be configured to it on S3. Only copilot keys are deleted from messages now.
…es it Organization Chat attachments (assistant/ keys) have no workspace_files row and a fork carries the same key, so the purge now deletes one under its own storage context only after confirming no remaining chat of that organization still references it.
A fork can share keys with its source (organization attachments, files whose copy failed), and chat cleanup deletes a shared key once no remaining chat references it, checking after it deletes the source row. The fork's publish transaction now holds the source row with FOR KEY SHARE: a purge that already removed it refuses the fork (404, worker copy discarded), and one that has not waits for the commit and then sees the fork's references.
7d69384 to
d55d4fa
Compare
d55d4fa to
ef3e272
Compare
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
Summary
Sim side of making chat fork work for long chats and leave nothing inconsistent behind. Companion worker PR: simstudioai/mothership#579 (set-based copy, 50,000-event ceiling with 413, rollback when the caller disconnects).
copyWorkerConversationturned every worker non-2xx into a generic error, so the route answered 500 even when the worker refused for a reason the person can act on. Worker 404 →not_found, 409 →conflict, 413 →payload_too_large; the fork route passes 409/413 through with a caller-facing message (404/400 mappings unchanged), and the fork action's toast shows that message instead of "Failed to fork chat". The route's existing 413 for more than 200 inline images now also reaches the person instead of becoming a 500.catchswallowed every first-attempt failure, so a 400, a 500, a malformed receipt and a timed-out attempt were all resent while the first copy could still be running. Only a recognized socket failure (isRetryableNetworkError, which now includesEHOSTUNREACH: a connection that never opened) or a 502/503/504 gets one more attempt.POST /api/tasks/cleanup, the endpoint chat deletion already uses. Best effort, logged, and a no-op when nothing was committed.failedFileCopiesstill reports it./workspace/<ws>/files/<id>links are rewritten (the fork stays in the same workspace, so only the file id moves).params(a string that is a mapped id or key is replaced whole; other strings get the URL grammar),activityDescriptionanddisplay.titleare rewritten.FOR KEY SHARE) until commit, so a purge either already removed it (the fork returns 404 and its worker copy is discarded) or waits for the fork and then sees its references.chat-images/<chatId>/…) were never deleted with their chat. Their keys are rebuilt from the assistant messages that published them, sharing the fork's enumeration (moved toinline-image-key.tsso cleanup doesn't loadsharp).copilotstorage whatever their key. Chat attachments are workspace-bucket files owned by theirworkspace_filesrow, a fork can share one with its source (failed copy, soft-deleted file), and the copilot bucket falls back to the workspace bucket on GCS (GCS_COPILOT_BUCKET_NAMEunset) and can be configured to it on S3. So purging either chat could delete a file the other, or the workspace, still uses. Workspace keys are now left to their rows;copilot/keys are deleted as before.assistant/<orgId>/…) have noworkspace_filesrow and a fork carries the same key. They are deleted under their own context, and only once no remaining chat of that organization references the key (checked after the purged rows are gone, so a fork or a soft-deleted chat keeps it).Deferred (need an owner decision)
autoAllowedToolsis not carried to the fork. It is a per-chat consent grant ("allow for the rest of this chat"). Copying it widens consent to a chat the person never granted it in; not copying it re-prompts. That is a product/security call, not a bug fix.task_armedpill. Pill state comes from the transcript, and the source's background task notifies only the source, so a fork cut between arm and notification shows the task pending forever. Whether a fork should show it as not carried over, mirror the source's status, or re-arm its own task is a product decision; the worker PR defers it too.copilot/attachment keys shared by a source and its fork. Chat uploads never produce them (they areworkspace/orassistant/keys), so this only affects old rows; closing it needs a reference check across chats or copying those blobs on fork.Mixed versions and deploy order
Worker first is preferred; every combination is safe:
discardWorkerConversation.Worker copy time (local, relative)
From the worker PR: prod-shaped synthetic chats, cut at the last turn with its response, 5 reps, a separate writer process appending to the source, local Postgres 18 on a loaded machine. Use for ratios.
Type of Change
Testing
origin/staging, green now (each guard reverted on its own and its test watched go red):fork-worker.test.ts(fake worker recording the requests it receives): 404/409/413 classified with one request; 500/400, a timed-out attempt and a non-socketTypeErrornot repeated;EHOSTUNREACH/ENETUNREACH/ECONNRESETand 502/503/504 retried once; a 404 whose body fails to cancel still classified.fork/route.test.ts: worker 404/409/413 passed through; a failed publish sends the worker a cleanup for the new chat id (red without the discard); a failed blob copy leaves the message and the worker maps on the source file (red with the full plan maps); in-app links and tool-call arguments rewritten (red without the workspace id, and red without the whole-value match); a Sources tab past the cut dropped and one addressed by a kept request id kept (red without the filter, and red without the request-id match).fork/route.test.ts: a source purged while the fork copied is refused with 404, nothing published, worker copy discarded (red without the source-row check).chat-cleanup.test.tsthroughprepareChatCleanup: a workspace attachment never deleted as copilot storage (red without the prefix guard); an organization attachment deleted only when no remaining chat references it (red without the reference check, and red without collecting it); a chat's inline images deleted (red without the collection).POST /api/mothership/chats/[chatId]/fork→ the upload is copied (new key, bytes on disk), Sim's and the worker's fork history name only the copy, the worker receipt is at cut+1, and a new turn on the fork completes with the model seeing the kept turn and not the dropped one. An unknown message id is refused with 400. JSON report written per check.vitest run lib/mothership app/api/mothership lib/cleanup lib/core/errors background/cleanup: 2,813 pass.bun run testfrom the root,bun run lint,apps/simtype-check,bun run check:audits(58),check:api-validation:strict,docs-manifest:checkall pass.Checklist
test-auditauthoring gate)