fix(server): retrying a new thread's first send no longer fails with "already exists" - #7608
fix(server): retrying a new thread's first send no longer fails with "already exists"#7608pc-style wants to merge 4 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Taking a look at this |
|
LGTM! |
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This bug fix enables thread ID reuse after deletion by purging old projection data on thread recreation. While the changes are well-scoped and heavily tested, an unresolved Medium severity finding about error handling in the deletion reactor warrants human review. You can add or adjust custom eligibility rules. Learn more. |
d67bd7e to
b602fb0
Compare
…"already exists" Thread deletion is a soft delete, but requireThreadAbsent treated any row with the thread id as occupying it. When a first-turn bootstrap failed (e.g. worktree prep from origin), its cleanup thread.delete only marked deletedAt, so every retry from the same draft, which keeps its client-generated thread id, was rejected with "Thread already exists and cannot be created twice", permanently. A soft-deleted thread now counts as absent for thread.create. Both the in-memory projector and the SQLite projection pipeline already upsert thread.created wholesale (including deletedAt: null), so re-creation resets the row cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A re-created thread id (draft retry after a failed first-turn bootstrap) kept the old incarnation's message, activity, plan, session, turn, and pending-approval rows, so detail hydration could expose stale history. Each projector now clears its own rows when it applies thread.created, so per-projector replay from any cursor rebuilds the new incarnation correctly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
f62a9ce to
2be91ff
Compare
The deletion reactor stops sessions and closes terminals keyed only by threadId, asynchronously after thread.deleted. Now that a draft retry can re-create a deleted thread id, queued cleanup could race the new incarnation and stop its fresh session. Cleanup now skips when the thread row is live again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A failed projection read now propagates to the worker's logging catch instead of defaulting to the destructive path. The idle-session reaper backstops any cleanup this skips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a27f2b0. Configure here.
|
sol on behalf of shivamhwp Thanks for the work here. We reproduced the exact failure this PR targets. After a failed first-send bootstrap, retrying reused a deleted thread ID and hit #7664 fixes that same path and has now merged. We verified it through the real web app. After the initial bootstrap failure, the retry minted a fresh thread ID and reached the original Git failure again instead of the invariant error. We are closing this as superseded because #7608 fixes the symptom by allowing a soft-deleted Thank you for digging into this and for the test coverage. |

Sending the first message on a new thread could fail during worktree prep. After that, every retry from the same draft errored with "Thread ... already exists and cannot be created twice", forever. The cleanup's
thread.deleteis a soft delete, andrequireThreadAbsentcounted the soft-deleted row as still occupying the id, which draft threads keep across retries.The decider now treats a soft-deleted thread as absent, so the retry re-creates the id. The projection pipeline also purges the old incarnation's dependent rows on re-create, so the thread starts clean. Tests cover the invariant, a create/delete/re-create decider round-trip, and an engine-dispatch check that the purge runs.
Proof of work
Backend-only change (
apps/server), no UI surface, so no screenshots.vp test runon the three touched test files: 3 files, 30 tests, all passtsgo --noEmitinapps/server: cleanvp linton the five touched files: cleanWritten by Claude Fable 5 via Claude Code.
Note
Medium Risk
Changes orchestration invariants and multi-projector thread lifecycle behavior; scoped to soft-delete/re-create paths with solid test coverage.
Overview
Fixes first-send retries on new drafts that reuse the same client-generated thread id after a failed attempt triggers cleanup via soft
thread.delete.Command invariants:
requireThreadAbsentnow treats threads withdeletedAtset as absent, sothread.createwith the same id is allowed instead of failing with "already exists".Projections: On
thread.created, dependent projectors purge prior rows for that thread id (messages, turns, activities, proposed plans, sessions, pending approvals). The threads projector still upserts a fresh shell withdeletedAt: null. That keeps live dispatch and per-projector replay (threads projector runs last) aligned with the new incarnation.Tests cover the invariant, decider delete→recreate, engine dispatch purge, and bootstrap replay.
Reviewed by Cursor Bugbot for commit 2be91ff. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Fix retry of new thread's first send failing with "already exists" after deletion
requireThreadAbsentin commandInvariants.ts now treats a thread as absent if it is soft-deleted (deletedAt !== null), allowing re-creation with the same idthread.created, so stale projections from a prior incarnation don't conflictThreadDeletionReactorin ThreadDeletionReactor.ts skips provider session stop and terminal close when the thread id has already been re-created and is livethread.createdevent now performs destructive deletes across all projection tables for thatthreadIdbefore re-insertingMacroscope summarized a27f2b0.