Local-runtime tool calls (e.g. edit) can hang forever in RUNNING state — stale toolCallCompleted silently dropped after NAL stream stall/retry

Where does the bug appear (feature/product)?

Something else… — this is not the Cursor IDE/CLI itself, but the @cursor/sdk TypeScript SDK’s local agent runtime (the programmatic Agent/run.stream() API used to build a custom self-hosted chat frontend on top of Cursor, running inline in a Node process against SqliteLocalAgentStore, no cloud runtime involved).


Describe the Bug

When the event stream between the local SDK and the backend stalls and the SDK internally retries (bumping an internal “attempt generation” counter), any toolCallCompleted message that arrives tagged with the stale (pre-retry) generation is silently dropped instead of being reconciled with the new attempt — only a debug-level warning (nal.await_stall.stale_completion_dropped) is logged internally, nothing is surfaced to the SDK consumer. Because toolCallStarted (tool status running) was already emitted and persisted before the stall, the affected tool call is left permanently stuck at running, with no completed event ever following — even for trivial, fast operations (reproduced with a single edit call on a small file). The parent run never transitions out of RUNNING; there is no error, timeout, or any signal to the caller that something went wrong. The only recovery is for the caller to notice the run has gone silent (via wall-clock heuristics) and manually call run.cancel().

Relevant code path (@cursor/[email protected], dist/esm/357.js, minified but consistent):

function ce(e, t, r) {
  return {
    sendUpdate: async (n, o) => {
      if (t === r()) return e.sendUpdate(n, o);
      "toolCallCompleted" === o.message?.case &&
        se.warn(n, "nal.await_stall.stale_completion_dropped", { attemptGen: t, currentGen: r() })
    },
    ...
  }
}

t is the generation the completion belongs to, r() is the current generation; on mismatch the completion is dropped, never re-requested or converted into a synthetic failure for the new generation. Separately, the stall/retry policy (fe(...)) does eventually throw a "Connection stalled repeatedly" ConnectError after a retry cap — but that error does not appear to propagate into the run’s terminal status in either reproduction below; the run simply stays RUNNING forever.


Steps to Reproduce

We don’t have a deterministic repro (it depends on a transient stream stall), but the pattern is consistent across two independent occurrences:

  1. Start a local-runtime Agent, send a message that triggers a normal multi-step tool-use turn (any mix of read/grep/edit/shell).
  2. If the underlying stream stalls at any point while a tool call is in flight, the SDK retries the turn internally (visible in the model’s own thinking stream as it notices it’s not getting a result and retries the same logical tool call under a new call_id).
  3. Inspect the persisted event log for that run (SQLite run_events table under ~/.cursor/projects/<workspace>/sdk-agent-store/<hash>/index.db): the original tool call’s toolCallStarted/running event is present, but its matching toolCallCompleted never arrives — and this is silent, not surfaced anywhere in the public stream/API.
  4. The run stays in RUNNING indefinitely; no application-visible error occurs.

Observed twice:

  • Run A: 3 tool calls (grep ×2, read ×1) got stuck as running at the very start of a turn; the run ran 38.6 minutes total before eventually completing on new call_ids for those same logical calls.
  • Run B: a single edit call’s call_id appears twice with status: "running" in the event log (the SDK’s own retry), and never gets a completed event for either attempt. Confirmed on disk that the target file was never written. The run has been stuck in RUNNING for 10+ minutes with zero further stream activity at time of writing.

Expected Behavior

Either:

  • the stale toolCallCompleted should be reconciled against the current generation (re-emitted / re-associated) instead of being silently dropped, or
  • if reconciliation isn’t possible, the tool call should be marked as failed/unknown for the current generation so the turn can proceed or terminate cleanly, or
  • at minimum, the stall+retry+drop event should be surfaced through the public event stream (not just an internal log), and the eventual "Connection stalled repeatedly" error (which the retry policy already throws internally) should propagate to the run’s terminal status instead of leaving it in RUNNING forever.

Screenshots / Screen Recordings

N/A — this manifests only in the underlying local SQLite event log (run_events table), not in any UI. Happy to attach raw JSON excerpts of the toolCallStarted/toolCallCompleted sequences for both incidents on request.


Operating System

Linux (Ubuntu, kernel 5.15.0-179-generic, x86_64)


Version Information

Not applicable in the IDE/CLI sense — this is the @cursor/sdk npm package used programmatically:

Package: @cursor/sdk
Version: 1.0.23 (latest on npm as of 2026-07-03; no newer release available)
Node.js: v22.22.0
Runtime: local (Agent inline in Node process, SqliteLocalAgentStore), not cloud/Background Agent

For AI issues: which model did you use?

Reproduced with grok-4.5; also reproduced with composer-2.5 in the same workspace on other runs.


For AI issues: add Request ID with privacy disabled

Not available — this isn’t driven through the Cursor IDE UI, so there’s no IDE Request ID. Closest equivalents from our own logs:

Agent ID (Run A / claude-code-web-chat workspace): agent-... (Turn 11, "会话管理功能询问")
Agent ID (Run B / cursor-web-chat workspace): agent-f0f50773-c5ec-46b8-a4f3-73776c70e098
Run ID (Run B): run-516c8648-e2a2-470b-a3ef-e0e28026ada0
Call ID (stuck edit, Run B): call-2afbf73f-2c8e-46e4-b286-36c84137878f-35

Additional Information

This is easy to conflate with a separate, unrelated-but-similarly-confusing behavior we also found in the same SDK: a shell timeout in TIMEOUT_BEHAVIOR_BACKGROUND mode does not kill the process — it gets backgrounded and keeps running, later reporting status: "success" with the full executionTime, even if that’s 15+ minutes past the declared timeout. That one is at least “working as designed” if undocumented; the stale-completion-drop issue described here is a real bug with no recovery path other than an external watchdog manually cancelling the run.


Does this stop you from using Cursor?

Sometimes - I can sometimes use Cursor (workaround: an external script polls the local SQLite store for runs stuck in RUNNING with no recent event activity and force-cancels them; without that, affected runs hang forever and block the session)

Hi there!

We detected that this may be a bug report, so we’ve moved your post to the Bug Reports category.

To help us investigate and fix this faster, could you edit your original post to include the details from the template below?

Bug Report Template - Click to expand

Where does the bug appear (feature/product)?

  • Editor, Tab & Chat (autocomplete, Composer, in-editor agent)
  • Terminal & commands
  • Models, pricing & API keys (availability, Auto/Max, BYOK/Bedrock)
  • MCP & tools
  • Cloud Agents & Automations (cursor.com/agents, scheduled/event)
  • BugBot & Code Review
  • Cursor CLI
  • Cursor Mobile
  • Remote (SSH / Dev Containers / WSL)
  • Account, billing & login
  • Something else…

Describe the Bug
A clear and concise description of what the bug is.


Steps to Reproduce
How can you reproduce this bug? We have a much better chance at fixing issues if we can reproduce them!


Expected Behavior
What is meant to happen here that isn’t working correctly?


Screenshots / Screen Recordings
If applicable, attach images or videos (.jpg, .png, .gif, .mp4, .mov)


Operating System

  • Windows 10/11
  • MacOS
  • Linux

Version Information

  • For Cursor IDE: Menu → About Cursor → Copy
  • For Cursor CLI: Run agent about in your terminal
IDE:
Version: 2.xx.x
VSCode Version: 1.105.1
Commit: ......

CLI:
CLI Version 2026.01.17-d239e66

For AI issues: which model did you use?
Model name (e.g., Sonnet 4, Tab…)


For AI issues: add Request ID with privacy disabled
Request ID: f9a7046a-279b-47e5-ab48-6e8dc12daba1
For Background Agent issues, also post the ID: bc-…


Additional Information
Add any other context about the problem here.


Does this stop you from using Cursor?

  • Yes - Cursor is unusable
  • Sometimes - I can sometimes use Cursor
  • No - Cursor works, but with this issue

The more details you provide, the easier it is for us to reproduce and fix the issue. Thanks!

@system Please repair as soon as possible, has seriously affected the use

Hey @golden_miner

When the event stream stalls and the runtime retries under a new attempt generation, an in-flight tool completion tagged to the previous generation gets dropped instead of reconciled, so a tool call that already surfaced as running is left orphaned with no completed to follow. This is a class of issue we’ve seen reported before (a CLI stream-json variant hits the same underlying path), and I’ve logged your SDK local-runtime case with the team so this surface, and the “run sits in RUNNING with no signal” symptom, are captured alongside it.

A few things that may help in the meantime:

  1. Your external, out-of-process watchdog (polling the local run store for runs stuck in running with no recent activity, then calling run.cancel()) is exactly the right interim pattern. Keep it out-of-process: an in-process timer or AbortController can itself get starved during a bad stall and never fire.
  2. If the stalls track with your network path, it’s worth testing configureCursorSdk({ local: { useHttp1ForAgent: true } }), which forces the local agent stream onto HTTP/1.1 + SSE instead of HTTP/2. That can cut down the HTTP/2 transport stalls that trigger the drop. It won’t fix the reconciliation gap itself and it does change the transport, so treat it as an experiment rather than a fix.
  3. On the separate shell timeout + TIMEOUT_BEHAVIOR_BACKGROUND point: that one is intended behavior (the process is backgrounded rather than killed), but you’re right it isn’t documented clearly, and that’s fair feedback on the docs.

No need to send the raw event-log excerpts, the report already has what’s needed to confirm. I’ll keep this thread posted as it progresses. Thanks again for the detail.