Skip to content

[Bug]: the Cursor T04 heartbeat-only case still states a no-deadline-expired contract against real elapsed time #5168

Closed

Description

@lidge-jun

Client or integration

Other

Area

Provider adapter

Summary

The Cursor T04 heartbeat-only test case states a correct production contract in a way that only holds while the CI runner keeps up, so it can report a failure while the watchdog is behaving correctly.

tests/providers/cursor/cursor-stream-health.test.ts asserts that liveness-only traffic survives the silence threshold and then fails at the longer heartbeat-only threshold. It demonstrates the first half by keeping a real setInterval writing heartbeat and checkpoint frames every 40ms, which requires that no decoded-frame gap ever exceeds the silence budget for the whole heartbeat-only window. On CI that budget is floored to 5s by isolationBudgetMs, inside a 22s fixture window.

That is an assertion that a deadline did not expire, and it carries a term for elapsed real time. The synthetic HTTP/2 server shares one Bun process with the rest of the suite, so under contention its own frames arrive late, the silence watchdog fires first, and the case fails with no inbound frames while both watchdogs are correct. The production contract is not at fault: CURSOR_STREAM_SILENCE_FAIL_MS is 30s and CURSOR_STREAM_HEARTBEAT_ONLY_FAIL_MS is 90s.

This is the defect #5131 removed from the sibling re-arming case in the same file, left behind on the one case that still needed a real interval. Scaling the budget is not a remedy: #3940 already scaled it once, and a larger floor lengthens the window during which no pause may exceed one budget.

Reproduction

Static source trace at f6b59da1919ba7a73ef6a76d265b119a43eec0d1, plus the hosted CI observations below. No local suite was run.

  1. Read tests/providers/cursor/cursor-stream-health.test.ts: silenceMs is isolationBudgetMs(1_000) (5s on CI), heartbeatOnlyMs is twice that, and the fixture writes liveness frames on a real 40ms interval.
  2. Read armStreamHealthTimer in src/adapters/cursor/live-transport.ts: the single timer fires at the minimum of lastInboundFrameAt + silenceMs and lastMeaningfulFrameAt + heartbeatOnlyMs, and the reported message is selected by heartbeatOnly = stalledFor < silenceMs.
  3. Delay decoded frame arrival past the silence budget at any point in that 10s window, which both the unsharded and the sharded macOS lanes do. The silence watchdog wins and the case fails on the message assertion.

The same defect has already produced a real failure in this file. Run 35386429688, job 105822272974, on the unsharded macos control lane reported Cursor stream stalled: no inbound frames for 5s before turnEnded from live-transport.ts:895 against the sibling re-arming case at 16733.88ms; that occurrence was dispositioned by #5131. The heartbeat-only case has not failed yet, and it has come within 1.2s of the same outcome: 11219.78ms in that job and 10029.66ms in job 105880533745 (macos 2/2, run 35436695398), both against a 10s budget.

Version

f6b59da1919ba7a73ef6a76d265b119a43eec0d1 (dev, after #5131 landed as e668aa1f2e).

Operating system

Observed on the hosted macOS lanes (macos control and macos 2/2). The mechanism is platform-independent; the Windows shards carry a larger floor and the same shape.

Provider and model

cursor / composer-2 against a synthetic in-process HTTP/2 Connect fixture. No credentials and no live provider are involved.

Logs or error output

# run 35386429688, job 105822272974 (macos control) - sibling case, dispositioned by #5131
(pass) Cursor inbound stream-health watchdog (T04) > heartbeat-only traffic survives the silence threshold but fails at the heartbeat-only threshold [11219.78ms]
error: Cursor stream stalled: no inbound frames for 5s before turnEnded
      at <anonymous> (src/adapters/cursor/live-transport.ts:895:18)
(fail) Cursor inbound stream-health watchdog (T04) > meaningful frames keep resetting both clocks; turnEnded finishes cleanly [16733.88ms]

# run 35436695398, job 105880533745 (macos 2/2)
(pass) Cursor inbound stream-health watchdog (T04) > heartbeat-only traffic survives the silence threshold but fails at the heartbeat-only threshold [10029.66ms]

Screenshots and supporting files

Redacted configuration

{ "adapter": "cursor", "baseUrl": "http://127.0.0.1:0", "apiKey": "test-token" }

Implementation path

  1. Move the heartbeat-only case onto the existing streamHealthClock seam and advance virtual time by hand between liveness frames whose arrival the case awaits. Contention can then delay a frame without any deadline passing.
  2. Synchronise on a decoded frame rather than on elapsed time. A conversationCheckpointUpdate is a progress frame, so it yields one outward heartbeat message; a bare heartbeat frame yields none, and the frame chain is serialized, so awaiting the checkpoint message proves both frames were decoded and the watchdog re-armed.
  3. Keep the firing half of the contract on real timers in the silence case, so a genuine stall still fails the turn for real.
  4. Leave production budgets, the first-frame timer, the turnEnded grace and the outbound heartbeat interval untouched. No raised timeout, no retry, no platform skip.

Acceptance criteria and verification

  • No T04 assertion claims that a deadline did not expire while depending on real elapsed time.
  • The case still fails, and still names which watchdog won, if liveness frames wrongly refresh the progress clock, if the deadline stops being the minimum of the two, or if the watchdog never fires.
  • Production silence and heartbeat-only budgets are unchanged in src/adapters/cursor/live-transport.ts.
  • Execution proof comes from exact-head hosted CI only. No local suite, focused test, typecheck or build is run for this work.

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

  1. added
    providerProvider adapters, OpenAI-compat presets, upstream API quirks
    on Sep 19, 2026
  2. lidge-jun commented on Sep 19, 2026

    @lidge-jun
    OwnerAuthor

    Closed by #5169, squash-merged into dev as ef7e59f930.

    Two corrections belong in this record, because the brief that opened this lane was wrong on both counts and the fix is better for having been argued down.

    HEARTBEAT_MS = 5_000 in live-transport.ts is the outbound client heartbeat interval, not a watchdog threshold. The production T04 budgets are CURSOR_STREAM_SILENCE_FAIL_MS at 30s and CURSOR_STREAM_HEARTBEAT_ONLY_FAIL_MS at 90s, and the 5s that appears in CI output is a budget the test itself shrinks, floored by isolationBudgetMs. Nothing in #5169 touches a production budget.

    The specific occurrence named at dispatch — Cursor stream stalled: no inbound frames for 5s before turnEnded at 16733.88ms in job 105822272974 — had already been dispositioned by #5131, which landed as e668aa1f2e and put the re-arming case on an injected clock. The defect class was real; that instance was closed.

    What remained open was the sibling case in the same file. The heartbeat-only case kept a real 40ms interval and required no decoded-frame gap beyond the 5s silence floor across a 10s window, which is the same "a deadline did not expire" shape #5131 removed from its neighbour. It had not failed, and it had come within 1.2s twice: 11219.78ms in that same job and 10029.66ms on macos 2/2 of run 35436695398, both against a 10s budget. Waiting for it to go red would have taught reviewers that a red Cursor lane is something you rerun.

    Adversarial review with five parallel reviewers found a defect CI would have passed, and it is the more important half of this change. The min(silence, progress) rule was red-capable nowhere: a watchdog reading only the progress deadline would relax production silence detection from 30s to 90s while every real-timer case stayed green, because a later deadline still produces the same message inside the case timeout. There is now a virtual-clock case requiring the turn to fail at exactly S with nothing left armed. A second finding restored real-timer coverage of the progress branch firing through the production default clock, which structure/providers/cursor.md asserted was still present; the document now states which half lives where. A third bounded the manual clock's drain loop, because an expired-deadline re-arm mutation would have spun advanceTo synchronously and wedged the lane rather than reddening one case.

    The division the file now follows is that every "a deadline did not expire" claim lives under the injected clock, and every "it fires" claim stays on real timers with budgets chosen so ordinary load delays the case rather than flipping its outcome — the real-timer progress case uses a 60s silence budget against a 600ms progress budget, so a pause long enough to be misreported as silence has already blown the case's own 15s limit.

    No local suite was run in this lane. The change touches only tests/ and structure/, so the changes filter correctly skipped the Windows shard matrix on the pull-request event; the dev push matrix covers Windows at the merged commit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingproviderProvider adapters, OpenAI-compat presets, upstream API quirks

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions