fix(server): harden Cursor adapter turn lifecycle - #9051
Conversation
Closes pingdotgg#9047 - sendTurn binds activeTurnId in the same synchronous stretch as the in-flight increment; a concurrent sendTurn no longer steers onto the previous turn - a started turn that fails during preparation or prompting now emits turn.completed { state: "failed" } before the error propagates (guarded on the last-in-flight rule and !ctx.stopped); pre-start failures remain reactor-owned - interruptTurn records the active turn id so a turn cancelled while still preparing settles as cancelled and never reaches cursor-agent - per-thread semaphores are released on session stop/failed start instead of accumulating for the adapter lifetime Model: kimi-code/k3 via Oh My Pi (omp)
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — The production Cursor adapter now uses new concurrency and cancellation state to change when prompts execute and when terminal runtime events are emitted, while also changing permission-response behavior. Although the scope is contained and extensively tested, these are material request-path lifecycle changes that warrant human review. You can add or adjust custom eligibility rules. Learn more. |
- interruptTurn honors its optional turnId: a stale interrupt for a completed turn no longer cancels the thread's active turn; a matching in-preparation turn is marked cancelled without an ACP cancel - steering turns with multiple in-flight prompts keep the cancellation marker until the last prompt drains, settling cancelled exactly once - revert the semaphore deletion from the previous commit: removing a lock while held or queued lets a later startSession run on a fresh lock concurrently with waiters on the old one — worse than the bounded retention it removed Model: kimi-code/k3 via Oh My Pi (omp)
- interactive approval responses resolve the optionId by matching the decision's ACP kind against the agent-advertised options, falling back to the legacy hyphenated ids only when the kind is not offered - a turn that already published its terminal event can no longer be closed a second time by an interrupt landing before the finalizer Model: kimi-code/k3 via Oh My Pi (omp)
|
Hi @juliusmarminge @t3dotgg — quick ping on this small one when convenient. 🙂 It's the Cursor-side port of the turn-lifecycle fixes that were reviewed in #9038 (issue #9047): cancellation targeting, terminal event on started-turn failure, semaphore retention, and the double-settle race. All checks green, all threads resolved, 25/25 adapter tests passing including regression tests for each fix. Should be a quick read — happy to tweak anything. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit c18df1d. Configure here.
| payload: { state: "cancelled", stopReason: "cancelled" }, | ||
| }); | ||
| return true; | ||
| }); |
There was a problem hiding this comment.
Cancelled settle ignores stopped sessions
High Severity
settleIfCancelled publishes turn.completed whenever the last in-flight prompt sees a cancel marker, but it never checks ctx.stopped. The failure and ensuring paths already skip in that case so a late failure cannot land on a torn-down or replaced session. A cancel that lands during preparation, followed by stopSession or a startSession replacement, still emits that terminal event on the shared thread. Ingestion can then apply it after session.exited and move a stopped session back to ready, or clear a replacement session that is still starting.
Reviewed by Cursor Bugbot for commit c18df1d. Configure here.
| : collapsed, | ||
| }, | ||
| }); | ||
| }), |
There was a problem hiding this comment.
Steering failures settle unstarted turns
Medium Severity
The post-start failure handler treats every steering sendTurn as already started. tapError emits turn.completed { failed } unless turnStartedEmitted is false and steeringTurnId is unset. A follow-up that steers onto a turn still in session configuration can therefore be the last in-flight prompt, fail before any turn.started, and still publish a failed completion. That contradicts the pre-start rule (those errors stay with handleTurnStartFailure) and can mark the session error for a turn the UI never opened. If the original prompt was already cancelled, the failure path also drops the cancel marker and reports failed instead of cancelled.
Reviewed by Cursor Bugbot for commit c18df1d. Configure here.


Summary
Ports the four turn-lifecycle fixes verified in #9038 (omp provider) back to the Cursor adapter they were inherited from. Closes #9047.
activeTurnIdbound too late —promptsInFlight += 1and theactiveTurnIdassignment were separated by effectful session configuration; a concurrentsendTurnin that window steered onto the previous turn. The id is now bound in the same synchronous stretch.turn.started(attachment reads, session configuration,acp.prompt) now emitturn.completed { state: "failed", errorMessage }before propagating, guarded on the last-in-flight rule and!ctx.stoppedso a late failure never publishes onto a stopped or replacement session. Pre-start failures remain owned byProviderCommandReactor.handleTurnStartFailureas before.interruptTurnrecords the active turn id in a per-session set;sendTurnchecks it beforeturn.startedand again beforeacp.prompt, settling cancelled turns ascancelledwithout ever sending the prompt. Ids are removed on settle.threadLocksRefgrew forever — per-thread semaphores are now deleted onstopSession/stopAll/finalizer and onstartSessionfailure. ThestartSessionreplacement path deliberately keeps the lock it is holding.Verification
turn.startedemitsturn.completed(failed)(andsendTurnstill fails); an interrupt landing mid-preparation (mock delayed via the new env-gatedT3_ACP_SET_CONFIG_OPTION_DELAY_MSsleep, additive and off by default) settles as cancelled with zerosession/promptrequests.CursorAdapter.test.ts21/21 green; fullsrc/provider/Layers+src/provider/acpsuites (123 tests) green against the shared mock; apps/server typecheck clean.Model: kimi-code/k3 via Oh My Pi (omp)
Note
Medium Risk
Changes core Cursor turn/cancel/interrupt semantics and terminal event ordering; mistakes could leave turns stuck or cancel the wrong prompt, but behavior is heavily covered by new adapter tests.
Overview
Hardens CursorAdapter turn lifecycle so cancellation, steering, failures, and permissions behave predictably under races.
sendTurnnow bindsactiveTurnIdin the same synchronous step aspromptsInFlight, avoiding steers attaching to the wrong turn during slow session config. Pre-prompt cancels use a per-sessioncancelledTurnIdsset (checked beforeturn.startedand beforesession/prompt) becauseacp.canceldoes not apply until the prompt is on the wire. Asettledguard plus ensuring logic publishes one terminalturn.completedacross success, cancel, steer-drain, and late interrupts. Errors afterturn.startedemitturn.completedwithstate: "failed"and a trimmederrorMessagewhile still failingsendTurn.interruptTurnaccepts an optionalturnId: stale cancels for a completed turn no longer callsession/cancelon the active prompt; targeted cancels during prepare only record markers.Permission responses use
selectCursorPermissionOptionIdso wire replies use the agent’s advertisedoptionIdfor the decision kind, not only the generic hyphenated fallback.The ACP mock gains
T3_ACP_SET_CONFIG_OPTION_DELAY_MSto stretch prepare-phase tests;CursorAdapter.test.tsadds coverage for prompt failure, prepare cancel, stale interrupt, steered cancel, double-settle, and custom permission option ids.Reviewed by Cursor Bugbot for commit c18df1d. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Harden
CursorAdapterturn lifecycle: add pre-prompt cancellation, single-terminal guarantees, and targeted interruptssendTurnnow supports pre-prompt cancellation via acancelledTurnIdsset onCursorSessionContext; if a turn is marked cancelled before the prompt reaches the wire, it skips sendingsession/promptand publishes exactly oneturn.completedwith statecancelled.settledandturnStartedEmittedflags; errors afterturn.startednow emit a failed terminal event with an error message trimmed toCURSOR_TURN_ERROR_MAX_CHARS(1,000 chars).interruptTurnaccepts an optionalturnId; interrupts targeting a non-active or completed turn record a cancel marker without callingacp.cancel, preventing accidental cancellation of the wrong prompt.selectCursorPermissionOptionIdmaps approval decisions to the actual agent-advertisedoptionIdinstead of a generic fallback, fixing permission responses when agent-provided options exist.T3_ACP_SET_CONFIG_OPTION_DELAY_MSsupport to the mock agent to simulate delayed config writes for cancellation race tests.interruptTurnsignature changed to accept an optionalturnId; callers that do not pass it get the prior behavior (interrupt active turn). Interrupts addressed to completed turns are now silently ignored rather than sendingsession/cancel.Macroscope summarized c18df1d.