Skip to content

fix(cursor): end a Run that goes quiet mid-turn instead of hanging the reply - #364

Merged
yetone merged 2 commits into
magpie-community:mainfrom
loosheng:cursor-quiet-run
Oct 24, 2026
Merged

yetone merged 2 commits into
magpie-community:mainfrom
loosheng:cursor-quiet-run

Conversation

@loosheng

Copy link
Copy Markdown
Contributor

Summary

Cursor can leave a Run's stream open after some thinking with nothing more to come: no text, no call, no count, no TurnEnded. Neither path ends such a Run:

  • stateless runOnce / decode: ends only on an end frame, TurnEnded, a complete count or EOF; the plugin's 5 s heartbeats keep the stream alive. No timer at all.
  • retained NativeSession.pump: batchWait starts only at a count or a call, so before either only the 15 min lifetime is left.

In yetone/magpie#2672 two composer-2.5 turns waited 305 s until omp gave up (magpie: 499, usage: null). They were on the stateless path: their call ids were Cursor's own tool_…, not call_cursor_….

every frame of a Run, both paths
  → Quiet: time spent waiting on Cursor's next frame
      no frame at all for idle (60 s)                 → 502 "Cursor went quiet: …"
      only InteractionUpdate.heartbeat for quiet (240 s) → 502 "Cursor went quiet: …"
      retained Run in awaiting-results                → paused (resultWait bounds it, as before)

@cursor/sdk 1.0.35 ends a Run the same way (stall detector, 30 s without a message, then retries; dist/cjs/689.js). Its server-heartbeat path resets its meaningful-activity clock too (reset("inbound_message","heartbeat"), third arg defaults true), so Cursor's heartbeats would hold such a Run open there; here they are counted apart.

Evidence

  • Before: the 8 new cases against unchanged index.mjs / native-session.mjs: 6 fail, 2 pass. Failing: thinking then silence (native, stateless); Cursor heartbeats only (native, stateless); a call with no count then silence (stateless); a call then silence before batchWait (native). Passing either way: a step of calls each within idle; a retained Run parked past idle continues same-run.
    After: all 8 pass; HOME=$(mktemp -d) bun test: 2452 pass, 0 fail (213 files); bun scripts/check.mjs ok.
  • Live, a Cursor Pro account via cursor-agent's token, loader.fetch with one read_file tool: auto native and stateless, a call then "Pelican", 200 each; composer-2.5 native and stateless, 200; auto native with idle at 3 s and the continuation sent after 8 s parked: same-run, 200.
  • Not reproduced: Cursor going quiet for real. Whether it then sends nothing or its own heartbeats is unknown; both are covered by tests.

Merge Danger

Door: two-way

Revert restores the old behavior; no schema, config or stored state.

Blast Radius: Cursor

A Run where Cursor is legitimately silent for over 60 s (no message, not even a heartbeat) between frames, or over 4 min with heartbeats only, now fails with 502 instead of waiting. Time parked for the caller's tool results never counts.

…ss (yetone/magpie#2672)

Cursor can leave a Run's stream open after some thinking with nothing more
to come: no text, call, count or TurnEnded. Nothing on either path ended
such a Run. The stateless decode stops only on an end frame, TurnEnded, a
complete count or EOF, and the plugin's 5 s heartbeats keep the stream
alive; a retained Run's batchWait starts only at a count or a call, leaving
the 15 minute lifetime. In yetone/magpie#2672 two turns (composer-2.5, on
the stateless path: their call ids were Cursor's own tool_..., not
call_cursor_...) waited 305 s until omp gave up and magpie logged a 499.

Quiet wraps a Run's frames on both paths and ends the Run with a 502 that
says so: NATIVE_LIMITS.idle (60 s) with no message at all, or
NATIVE_LIMITS.quiet (240 s, under magpie's 5 minute keepalive) with only
InteractionUpdate.heartbeat (magpie-community#13). Only time spent waiting on Cursor's next
frame counts: not this side handling one, not a slow reader, and not a
retained Run parked in awaiting-results, which resultWait bounds as
before. Each frame resets idle, so a step whose calls keep coming still
seals however long it takes in all (magpie-community#155). The error names how long and
the last kind of message Cursor sent. In the stateless decode a quiet Run
is an error even after some calls, not the EOF-with-calls success.

@cursor/sdk (1.0.35, 689.js) ends a Run the same way, 30 s without a
message, then retries. A heartbeat resets its meaningful-activity clock
too, so Cursor's own heartbeats would keep such a Run open there; this
counts them separately.

Test: native-e2e and native-session gain 8 cases. Against the old source,
6 fail (thinking then silence, native and stateless; server heartbeats
only, both; a call with no count then silence, stateless; a call then
silence before batchWait, native) and 2 guards pass (a step of calls each
within idle; a Run parked past idle continues same-run). All pass now.

Run at this tree:
- HOME=$(mktemp -d) bun test: 2452 pass, 0 fail (213 files).
- bun scripts/check.mjs: every package ok.
- Live, a Cursor Pro account through cursor-agent's token, loader.fetch
  with one read_file tool: auto, native and stateless, a call then
  "Pelican", 200 each; composer-2.5 stateless and native, 200; auto
  native with idle at 3 s and the continuation sent after 8 s parked:
  same-run, 200.
Not reproduced: Cursor going quiet for real. What it sends then, nothing
or its own heartbeats, is unknown; both are covered.
@loosheng loosheng changed the title cursor 0.2.21: a Run Cursor stops answering ends, retained or stateless (yetone/magpie#2672) fix(cursor): end a Run that goes quiet mid-turn instead of hanging the reply Oct 24, 2026

@yetone yetone 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.

Reviewed at cbd5d3b (on main df55294). Context: yetone/magpie#2672.

The design is right and the tests are good:

  • idle is 60s with no frame and quiet is 240s with heartbeats only. Both are under magpie's 5 min keepalive ceiling (keepaliveLongest, internal/gateway/gateway.go).
  • The timers pause while a Run is awaiting results and resume after the submit.
  • Both the stateless path and the retained path are wrapped.
  • The errors are clear, and 0.2.21 is the right bump (npm has 0.2.20).

I broke each part by hand and the tests went red each time:

Change Tests failed
stateless path unwrapped 3
no pause at seal 2
heartbeat counted as a frame 2
idle not reset per frame 3, including "a step whose calls each come within idle outlasts idle in all", which covers #155

bun test in packages/cursor passes (Bun 1.4.2).

Blocking: Quiet.frames keeps every frame alive for the life of the stream

const gone = new Promise((r) => { this.gone = r })
...
r = await Promise.race([next, gone])

gone is created once and stays pending until the stream ends. Each Promise.race adds a reaction to gone, and that reaction holds the race's result promise, which holds the frame it resolved with. None of it can be collected until gone settles. A long Cursor run streams many MB of frames, so memory grows with the whole turn.

Repro: run a script that sends 20,000 frames of 50KB through new Quiet(NATIVE_LIMITS, () => {}).frames(src) and prints process.memoryUsage().rss at the end.

  • At this head: 1376 MB RSS.
  • With the fix below: 63 MB.

Fix (verified; the suite still passes, and the heartbeat test passed 6 of 6 repeats):

r = await Promise.race([next, new Promise((w) => { this.gone = w })])

This makes a fresh wake promise each iteration and drops the long-lived gone. fire and pause still call this.gone, which is now the current iteration's resolver.

Minor

  • The heartbeat e2e test has a 350ms lower bound. Under a load average around 400 it took 282ms once. Give it more room on that side, or wait for the state instead of measuring the time.

Not tried: a real Cursor account, Windows.

magpie-community#364 review)

One wake promise lived as long as the stream, and each read's
Promise.race added a reaction to it that held the race's result, so every
frame passed on stayed reachable until the stream ended. A long turn
streams many MB through one Run. Each read now races a wake of its own;
fire and pause wake the current one.

The heartbeat e2e test checked a lower time bound (350 ms) that a loaded
machine missed once (282 ms, yetone's review). It now checks that the Run
ended on the heartbeats-only branch, which only quiet reaches, not how long
it took.

Test: a new native-session case passes 200 frames of 64 KB through
Quiet.frames, keeps the stream open, forces GC and counts the frames still
reachable: 200 before, at most 2 now.
Run at this tree:
- 20,000 frames of 50 KB through Quiet.frames: 1458 MB RSS before, 108 MB
  now.
- The heartbeat test, 8 runs with 2x cores of busy loops: 8 of 8 pass.
- HOME=$(mktemp -d) bun test: 2453 pass, 0 fail (213 files).
- bun scripts/check.mjs: every package ok.
@loosheng

Copy link
Copy Markdown
Contributor Author

Thanks. Both fixed in 6a17d61.

Blocking: Quiet.frames held every frame

Reproduced at cbd5d3b: 20,000 frames of 50 KB through Quiet.frames → 1458 MB RSS. With your fix (a fresh wake per read; fire/pause wake the current one, cleared after each read) → 108 MB.

Added a regression test that doesn't depend on RSS: 200 frames of 64 KB through Quiet.frames with the stream kept open, then Bun.gc(true) and count the frames still reachable via WeakRef. At cbd5d3b it fails (200 held); now it passes (at most 2, the generator's own last value).

Minor: heartbeat test lower bound

Dropped both timing bounds. The test now checks that the Run ended on the heartbeats-only branch (N heartbeats and nothing else, N > 0), which only quiet reaches; idle alone would say no message for. Ran it 8 times with 2× cores of busy loops: 8 of 8 pass.

Run at this head

  • HOME=$(mktemp -d) bun test: 2453 pass, 0 fail (213 files).
  • bun scripts/check.mjs: every package ok.

@loosheng
loosheng requested a review from yetone October 24, 2026 12:00
@yetone
yetone merged commit cf1873e into magpie-community:main Oct 24, 2026
yetone added a commit that referenced this pull request Oct 24, 2026
…ds (#364)

Test only. #364's tests covered the pause at a sealed batch but not the
resume after the submit: with resume() leaving the Run paused, every
test passed and a retained Run that went quiet on its second model call
would wait for lifetime again. This one is red with that break (times
out at 5 s) and green at cf1873e.

Run: packages/cursor native-e2e.test.mjs, Bun 1.4.2, sandbox HOME:
131 pass, 0 fail.
@yetone

yetone commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

Merged at the reviewed head 6a17d61 as cf1873e. Thanks for the quick turnaround.

Re-reviewed every line at 6a17d61. The memory fix holds: 20,000 frames of 50 KB through Quiet.frames, stream left open, end at 95 MB RSS (1376 MB at cbd5d3b). packages/cursor bun test: 288 pass, 0 fail; bun scripts/check.mjs ok (Bun 1.4.2, sandbox HOME).

Broken by hand: the long-lived gone put back, heartbeats counted as frames, and the stateless path unwrapped each turned tests red. One break turned nothing red: if resume() left the Run paused, a retained Run that went quiet on its second model call would wait for lifetime again. I added a test for that in c9a0215 (a continued Run that goes quiet after the caller's results). It is red with that break and green at your head.

Not tried on my side: a real Cursor account, Windows. 0.2.21 is publishing now. magpie's Cursor mover min will be raised so moved users get it.

@loosheng
loosheng deleted the cursor-quiet-run branch October 24, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants