Skip to content

fix(server): harden Cursor adapter turn lifecycle - #9051

Open
Neon-Wang wants to merge 4 commits into
pingdotgg:mainfrom
Neon-Wang:fix/cursor-turn-lifecycle
Open

fix(server): harden Cursor adapter turn lifecycle#9051
Neon-Wang wants to merge 4 commits into
pingdotgg:mainfrom
Neon-Wang:fix/cursor-turn-lifecycle

Conversation

@Neon-Wang

@Neon-Wang Neon-Wang commented Sep 1, 2026

Copy link
Copy Markdown

Summary

Ports the four turn-lifecycle fixes verified in #9038 (omp provider) back to the Cursor adapter they were inherited from. Closes #9047.

  1. activeTurnId bound too latepromptsInFlight += 1 and the activeTurnId assignment were separated by effectful session configuration; a concurrent sendTurn in that window steered onto the previous turn. The id is now bound in the same synchronous stretch.
  2. No terminal event on started-turn failure — failures after turn.started (attachment reads, session configuration, acp.prompt) now emit turn.completed { state: "failed", errorMessage } before propagating, guarded on the last-in-flight rule and !ctx.stopped so a late failure never publishes onto a stopped or replacement session. Pre-start failures remain owned by ProviderCommandReactor.handleTurnStartFailure as before.
  3. Cancellation during preparation ignoredinterruptTurn records the active turn id in a per-session set; sendTurn checks it before turn.started and again before acp.prompt, settling cancelled turns as cancelled without ever sending the prompt. Ids are removed on settle.
  4. threadLocksRef grew forever — per-thread semaphores are now deleted on stopSession/stopAll/finalizer and on startSession failure. The startSession replacement path deliberately keeps the lock it is holding.

Verification

  • Two new tests ported from the omp side: prompt failure after turn.started emits turn.completed(failed) (and sendTurn still fails); an interrupt landing mid-preparation (mock delayed via the new env-gated T3_ACP_SET_CONFIG_OPTION_DELAY_MS sleep, additive and off by default) settles as cancelled with zero session/prompt requests.
  • CursorAdapter.test.ts 21/21 green; full src/provider/Layers + src/provider/acp suites (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.

sendTurn now binds activeTurnId in the same synchronous step as promptsInFlight, avoiding steers attaching to the wrong turn during slow session config. Pre-prompt cancels use a per-session cancelledTurnIds set (checked before turn.started and before session/prompt) because acp.cancel does not apply until the prompt is on the wire. A settled guard plus ensuring logic publishes one terminal turn.completed across success, cancel, steer-drain, and late interrupts. Errors after turn.started emit turn.completed with state: "failed" and a trimmed errorMessage while still failing sendTurn.

interruptTurn accepts an optional turnId: stale cancels for a completed turn no longer call session/cancel on the active prompt; targeted cancels during prepare only record markers.

Permission responses use selectCursorPermissionOptionId so wire replies use the agent’s advertised optionId for the decision kind, not only the generic hyphenated fallback.

The ACP mock gains T3_ACP_SET_CONFIG_OPTION_DELAY_MS to stretch prepare-phase tests; CursorAdapter.test.ts adds 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 CursorAdapter turn lifecycle: add pre-prompt cancellation, single-terminal guarantees, and targeted interrupts

  • sendTurn now supports pre-prompt cancellation via a cancelledTurnIds set on CursorSessionContext; if a turn is marked cancelled before the prompt reaches the wire, it skips sending session/prompt and publishes exactly one turn.completed with state cancelled.
  • Ensures only one terminal event is published per turn across success, failure, and cancellation paths by tracking settled and turnStartedEmitted flags; errors after turn.started now emit a failed terminal event with an error message trimmed to CURSOR_TURN_ERROR_MAX_CHARS (1,000 chars).
  • interruptTurn accepts an optional turnId; interrupts targeting a non-active or completed turn record a cancel marker without calling acp.cancel, preventing accidental cancellation of the wrong prompt.
  • selectCursorPermissionOptionId maps approval decisions to the actual agent-advertised optionId instead of a generic fallback, fixing permission responses when agent-provided options exist.
  • Adds T3_ACP_SET_CONFIG_OPTION_DELAY_MS support to the mock agent to simulate delayed config writes for cancellation race tests.
  • Behavioral Change: interruptTurn signature changed to accept an optional turnId; callers that do not pass it get the prior behavior (interrupt active turn). Interrupts addressed to completed turns are now silently ignored rather than sending session/cancel.

Macroscope summarized c18df1d.

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)
@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment thread apps/server/src/provider/Layers/CursorAdapter.ts
Comment thread apps/server/src/provider/Layers/CursorAdapter.ts Outdated
Comment thread apps/server/src/provider/Layers/CursorAdapter.ts
@macroscopeapp

macroscopeapp Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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.

Comment thread apps/server/src/provider/Layers/CursorAdapter.ts
Comment thread apps/server/src/provider/Layers/CursorAdapter.ts Outdated
- 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)
Comment thread apps/server/src/provider/Layers/CursorAdapter.ts
- 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)
@Neon-Wang

Copy link
Copy Markdown
Author

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.

@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 default effort and found 2 potential issues.

Fix All in Cursor

❌ 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;
});

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.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c18df1d. Configure here.

: collapsed,
},
});
}),

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.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c18df1d. Configure here.

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:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(server): Cursor adapter turn-lifecycle hardening (races, missing terminal events, lock retention)

1 participant