Skip to content

fix(server): a draft can retry its first send after a failed bootstrap - #8226

Open
shivamhwp wants to merge 2 commits into
pingdotgg:mainfrom
shivamhwp:fix/draft-retry-after-bootstrap-failure
Open

fix(server): a draft can retry its first send after a failed bootstrap#8226
shivamhwp wants to merge 2 commits into
pingdotgg:mainfrom
shivamhwp:fix/draft-retry-after-bootstrap-failure

Conversation

@shivamhwp

@shivamhwp shivamhwp commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Sending the first message on a new thread bootstraps thread.create and thread.turn.start together. When the bootstrap fails partway (worktree prep on a repo with no commits, a setup script, a dropped connection), the server rolls back with thread.delete. That is a soft delete, and the draft keeps its client-minted thread id, so the retry's thread.create hit requireThreadAbsent and was refused with "Thread already exists and cannot be created twice", on every retry, until the draft was abandoned. #7664 rotates the id on the client when the server reports the deletion, which covers the common path but not an interrupted send or a failed cleanup.

requireThreadAbsent now only blocks on a live row. Each per-thread projector drops its rows for the old incarnation when it applies thread.created, so a re-created id starts with an empty timeline and per-projector replay stays deterministic. A replayed thread.deleted that a later thread.created supersedes no longer removes attachment files, since those already belong to the new incarnation. Both thread.create paths in ws.ts wait for the deletion reactor to drain first, so the old incarnation's session stop and terminal close always finish before the new thread can own those resources.

Closes #4647. Closes #5721.

Verification: server suites 452/452, typecheck, lint. Each new test fails with its fix reverted. Live app with client id rotation disabled: nine same-id retries against a repo with no commits, zero invariant errors, clean projection. Full report with screenshots: https://y5pnrxd87iej.postplan.dev

Out of scope, noted for follow-up: the provider resume cursor and checkpoint git refs are not cleaned on delete (pre-existing, unreachable from this path since the failed incarnation never starts a turn). The same stale-child-rows hole exists in orchestrator V2's ON CONFLICT DO UPDATE on thread.created.

Claude Fable 5 via Claude Code.

Note

Allow draft retry after failed bootstrap by resetting projections on thread.created

  • requireThreadAbsent in commandInvariants.ts now accepts thread.create when a soft-deleted thread with the same id exists, so a draft can retry its first send.
  • All per-thread projectors in ProjectionPipeline.ts now delete stale rows on thread.created, and thread.deleted only removes attachment files when no later thread.created supersedes it (checked via new eventStore.hasEventAfter).
  • ThreadDeletionReactor.drain in ThreadDeletionReactor.ts now blocks until all published thread.deleted events are consumed and the worker queue is empty.
  • ws.ts awaits ThreadDeletionReactor.drain before dispatching thread.create (including bootstrap), preventing cleanup of a prior incarnation from racing with the new one.
  • Behavioral Change: thread.create on a previously soft-deleted thread id now silently clears all existing per-thread projection rows; callers reusing a thread id after deletion will see empty projections for the new incarnation.

Macroscope summarized 1c06a88.


Note

Medium Risk
Changes core thread lifecycle (invariants, projection replay, deletion cleanup ordering, and WS dispatch) with broad test coverage but non-trivial race and stale-state edge cases.

Overview
Fixes failed first-send bootstrap rollbacks blocking retries: drafts keep their client-minted thread id after a soft thread.delete, but thread.create used to fail on “already exists.”

requireThreadAbsent now only rejects creation when the thread row is still live (deletedAt is null). On thread.created, per-thread projectors clear their tables for that id (messages, turns, sessions, activities, plans, pending approvals) so a re-created thread does not inherit stale projection rows. During replay, thread.deleted skips attachment cleanup when hasEventAfter finds a later thread.created for the same id, so on-disk files for the retried thread are not wiped.

ThreadDeletionReactor.drain waits until the subscriber has seen published deletions up to the current head, then drains the worker queue. ws.ts awaits that drain before direct thread.create and before bootstrap thread.create, so session stop / terminal close for the old incarnation cannot race the new thread.

Extensive tests cover invariants, projection replay, engine re-create, reactor drain, and WS ordering.

Reviewed by Cursor Bugbot for commit 1c06a88. Bugbot is set up for automated code reviews on this repo. Configure here.

A new thread's first send creates the thread and starts the turn in one
bootstrap. When the bootstrap fails partway (worktree prep, setup script,
a dropped connection), the server rolls back with thread.delete. That is
a soft delete, the draft keeps its client-minted thread id, and the
retry's thread.create hit requireThreadAbsent, which treated the
tombstone as a live thread: "Thread already exists and cannot be created
twice", on every retry, until the draft was abandoned.

requireThreadAbsent now only blocks on a live row. Each per-thread
projector drops its rows for the old incarnation when it applies
thread.created, so a re-created id starts with an empty timeline and
per-projector replay stays deterministic. A replayed thread.deleted that
a later thread.created supersedes no longer removes attachment files,
since those already belong to the new incarnation. Both thread.create
paths in ws.ts wait for the deletion reactor to drain first, so the old
incarnation's session stop and terminal close always finish before the
new thread can own those resources.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b6582fda-4719-4da4-8d67-7d8adc2da11e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 25, 2026
Comment thread apps/server/src/ws.ts
…criber

The drain that gates thread.create only waited for the reactor's queue to
empty, but thread.deleted reaches that queue through an asynchronous
subscriber. A retry arriving between publish and enqueue could re-create
the id and then have the old cleanup stop the new session. The reactor now
tracks the highest event sequence its subscriber has handed on, and drain
first waits for that to reach the engine's latestSequence before draining
the worker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@shivamhwp
shivamhwp marked this pull request as ready for review August 25, 2026 21:53

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1c06a88. Configure here.

Stream.filter((seen) => seen >= target),
Stream.runHead,
);
yield* worker.drain;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drain misses post-snapshot deletes

Medium Severity

drain reads latestSequence once, then waits only until seenSequence reaches that snapshot. A thread.deleted that commits after that read can still be unpublished to the subscriber when drain finishes, so ws.ts can dispatch thread.create while the old incarnation’s stopSession / terminal close remains queued and later runs against the new id.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1c06a88. Configure here.

@macroscopeapp

macroscopeapp Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This is a cross-cutting thread-lifecycle fix that changes projection replay, attachment cleanup, websocket ordering, and asynchronous resource cleanup. The drain implementation still has a plausible race where a deletion committed after its sequence snapshot is not awaited before the replacement thread is created.

You can add or adjust custom eligibility rules. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

1 participant