Repository navigation
fix(cursor): end a Run that goes quiet mid-turn instead of hanging the reply - #364
Conversation
…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.
yetone
left a comment
There was a problem hiding this comment.
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.21is 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.
|
Thanks. Both fixed in 6a17d61. Blocking:
|
…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.
|
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 Broken by hand: the long-lived 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. |
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:
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.NativeSession.pump:batchWaitstarts only at a count or a call, so before either only the 15 minlifetimeis left.In yetone/magpie#2672 two
composer-2.5turns waited 305 s until omp gave up (magpie: 499,usage: null). They were on the stateless path: their call ids were Cursor's owntool_…, notcall_cursor_….idle, so a step whose calls keep coming still seals however long it takes in all (cursor: "Cursor did not complete its declared native tool batch" (502) when a large parallel tool step stalls under batchWait=30s #155);batchWaitis unchanged.quietis under magpie's 5 min keepalive, so a Run that gets only Cursor's heartbeats still ends before the agent's own stall timer.thinking,tool call, …): the next report says what Cursor did.@cursor/sdk1.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
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 beforebatchWait(native). Passing either way: a step of calls each withinidle; a retained Run parked pastidlecontinuessame-run.After: all 8 pass;
HOME=$(mktemp -d) bun test: 2452 pass, 0 fail (213 files);bun scripts/check.mjsok.loader.fetchwith oneread_filetool:autonative and stateless, a call then "Pelican", 200 each;composer-2.5native and stateless, 200;autonative withidleat 3 s and the continuation sent after 8 s parked:same-run, 200.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.