[Bug]: Unsupported attachment (e.g. HEIC) permanently wedges the turn in "working" with a dead stop button#4200
Conversation
ClaudeAdapter.sendTurn (and CursorAdapter.sendTurn, which had the same ordering) marked the session running, set activeTurnId, and emitted turn.started before validating/building the outgoing user message. When validation failed (e.g. an unsupported Claude image mime type, or an unresolvable Cursor attachment id), the already-emitted turn.started could race the error-recovery path and leave the session stuck "running" with a turn that can never complete — a dead stop button. Reorder both adapters so message/prompt validation runs before any turn-state mutation or event emission, so a validation failure leaves the session untouched and the existing recovery path produces a clean "ready" state with the error surfaced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
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 Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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 |
ApprovabilityVerdict: Needs human review This bug fix introduces significant turn lifecycle state management changes in CursorAdapter with subtle edge case handling. An unresolved review comment identifies a potential bug where turn completion events may fire after session exit during stopSession, potentially corrupting session state. You can customize Macroscope's approvability policy. Learn more. |
A concurrent sendTurn arriving while the first prompt awaited session configuration or attachment I/O saw promptsInFlight > 0 but activeTurnId still unset, so it minted a second turn id and emitted a second turn.started. Only the last prompt to resolve emits turn.completed, so the other turn id never settled. Reserving the turn id in the same synchronous step as the counter that gates steering closes the window. The reservation is adapter-internal; the session snapshot and turn.started emission stay after validation. Reported by Macroscope on PR pingdotgg#4200. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…unter" This reverts commit 84dae70.
…endTurns Reserve the turn id in the same synchronous step as the in-flight counter that gates steering: a concurrent sendTurn arriving while the first prompt awaits session configuration or attachment I/O previously minted a second turn id and emitted a second turn.started that never settled. The reservation alone reintroduces two hazards when the reserving prompt fails preparation after a steer has joined its turn: - The steered prompt used to skip turn.started (it saw itself as steering an already-announced turn), completing a turn that never started. Now whichever prompt survives preparation first announces the turn. - The steered prompt could resolve while the failing prompt still held the in-flight count, so neither emitted turn.completed and the turn wedged. The last prompt to drain now settles an announced-but-unsettled turn with the last known stop reason. A reservation that never announced a turn is dropped on drain so the next sendTurn opens a fresh turn instead of steering into a dead one. The mock ACP agent gains T3_ACP_SET_CONFIG_OPTION_DELAY_MS to hold a prompt inside its preparation window; a same-value model selection is skipped runtime-side, so the regression tests request a model change to force a real session/set_config_option round-trip. Addresses the concurrent-steer race and failed-prep findings on PR pingdotgg#4200. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The drain-time settle emitted turn.completed with state "completed" whenever the last prompt exited, including when every prompt of the announced turn failed and no stop reason was ever recorded. That reported a failed turn as successful: ingestion moved the session to ready, racing the caller's turn-start failure recovery and masking the real error. Gate the drain settle on a recorded stop reason. When no prompt of the merged turn resolved, the error keeps propagating to each caller and failure recovery settles the thread, as before the drain settle existed. The mock ACP agent gains T3_ACP_FAIL_FIRST_PROMPT so the regression test can fail the announced turn's only prompt while the follow-up turn succeeds. Addresses cursor bot's drain-settle finding on PR pingdotgg#4200. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mpt fails The drain-time settle previously chose between two bad outcomes when all prompts of an announced turn failed: emitting state "completed" reported the failure as success (flipping the session to ready and masking the error), while emitting nothing left the turn.started unanswered and wedged the thread in "working" — the turn-start failure recovery can lose that race when turn.started is ingested after it runs. Settle as state "failed" with the drained prompt's error message instead. The emission trails the turn.started already in the event queue, so ingestion deterministically ends in the error state regardless of how the recovery interleaves. Interrupt-only drains (session teardown) still skip settling and leave it to session lifecycle events. Addresses cursor bot's re-wedge finding on PR pingdotgg#4200. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…esult on interrupt The interrupt-only drain guard returned before consulting the recorded stop reason, so interrupting the last in-flight prompt after a sibling of the merged turn had already resolved skipped settling entirely and left the announced turn open. Scope the interrupt guard to the no-recorded-result branch: an interrupted drain with no result is session teardown and stays with session lifecycle events, but a recorded sibling result is the turn's outcome and now settles regardless of how the last holder exited. Addresses cursor bot's interrupt-skip finding on PR pingdotgg#4200. 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 dd18ff4. Configure here.
| }); | ||
| return; | ||
| } | ||
| ctx.activeTurnSettled = true; |
There was a problem hiding this comment.
Interrupt settle resurrects stopped session
Medium Severity
The new onExit path settles an announced turn with a recorded sibling result even when the last holder exits via interrupt-only, and it never checks ctx.stopped. During stopSession, scope close can interrupt a still-preparing holder after a sibling already recorded a result, so turn.completed can publish after session.exited. Ingestion then maps that completion to ready, undoing the stopped session.
Reviewed by Cursor Bugbot for commit dd18ff4. Configure here.


What Changed
sendTurnnow validates attachments and builds the user message before mutating any turn state, in both adapters that had the wrong order.ClaudeAdapter.sendTurn:buildUserMessageEffect(which validates attachment mime types) is hoisted abovesession.status = "running", theactiveTurnIdassignment, and theturn.startedemission.CursorAdapter.sendTurn: same hoist for its attachment-id and empty-prompt validation.activeTurnId, noturn.started— soProviderCommandReactor's existingrecoverTurnStartFailurepath produces a cleanreadystate with the error surfaced.turn.started, and leaves the sessionreadywith noactiveTurnId— plus that a subsequent valid turn emits exactly oneturn.startedwith the correct turn id.ClaudeAdapter.test.ts60/60,CursorAdapter.test.ts19/19.Why
Pasting an image the provider rejects (e.g. a HEIC screenshot from an iPhone, which passes the composer's
image/*check) permanently wedges the thread: it shows "working" forever and the stop button does nothing.The cause is an ordering race, not the validation itself. Because
turn.startedis emitted before validation runs, a validation failure leaves an event already in flight.recoverTurnStartFailurecorrectly resets the session toready, butProviderRuntimeIngestionprocesses that staleturn.startedasynchronously; when it lands after the recovery it flips the session back torunningwith anactiveTurnIdfor a turn that can never finish — the message was never queued, so noturn.completedcan ever arrive, andquery.interrupt()has nothing to interrupt.Reordering is the smallest fix that removes the race at its source: if the failure happens before any state is published, there is no stale event to lose the race to, and no new recovery machinery is needed. The alternative — emitting a compensating turn-end event on failure — adds a second event path to keep correct and still leaves a window where the UI shows a phantom running turn.
UI Changes
No UI code changed. The user-visible difference is that a rejected attachment now surfaces its error and returns the thread to ready, instead of leaving it stuck in "working" with an unresponsive stop button.
Checklist
Note
Medium Risk
Changes core provider turn lifecycle, event ordering, and concurrent
sendTurnsettlement in Cursor—high user impact if wrong, but scope is adapter-layer with extensive new regression tests.Overview
Fixes threads stuck in working when a provider rejects an attachment (e.g. HEIC): validation used to run after
turn.startedand session turn state were published, so async ingestion could flip the session back to running with no matchingturn.completed.ClaudeAdapter moves
buildUserMessageEffect(including mime-type checks) before marking the session running, settingactiveTurnId, or emittingturn.started.CursorAdapter does the same for prompt/attachment validation, and expands turn handling for overlapping
sendTurncalls: earlyturnIdreservation whilepromptsInFlightis tracked,turn.startedonly after validation (first surviving prompt announces), and anonExitdrain that emits a matchingturn.completed(including failed) when the last prompt exits without a normal settle—covering steer-during-prep, reserver prep failure, interrupt, and first-prompt ACP failure.The ACP mock agent gains
T3_ACP_SET_CONFIG_OPTION_DELAY_MSandT3_ACP_FAIL_FIRST_PROMPT; adapter tests lock in no phantomturn.started, clean session state after validation errors, and merged-turn boundaries.Reviewed by Cursor Bugbot for commit dd18ff4. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Fix unsupported attachments wedging turns in 'working' state with a dead stop button
ClaudeAdapter.ts, reorderssendTurnto build and validate the user message before emittingturn.startedor mutating session state, so unsupported attachments (e.g. HEIC) return an error without leaving a stuck turn.CursorAdapter.ts, reserves the active turn ID and incrementspromptsInFlightimmediately so concurrentsendTurncalls steer into the same turn, and defersturn.startedemission until after validation succeeds.CursorAdapternow tracksactiveTurnStarted,activeTurnSettled, andactiveTurnLastStopReasonon session context to guarantee exactly oneturn.startedper turn and always settle an announced turn even when the last prompt holder exits without resolving.acp-mock-agent.tsgainsT3_ACP_FAIL_FIRST_PROMPTandT3_ACP_SET_CONFIG_OPTION_DELAY_MSenv vars to support testing these failure modes.turn.started, so the UI stop button will not appear and the turn will not appear to be running when attachment validation fails.Macroscope summarized dd18ff4.