Skip to content

fix(cursor-native): follow the TUI onto its new chat after /clear - #7295

Open
omni-resolve-agent[bot] wants to merge 4 commits into
mainfrom
fix/34731956277
Open

omni-resolve-agent[bot] wants to merge 4 commits into
mainfrom
fix/34731956277

Conversation

@omni-resolve-agent

@omni-resolve-agent omni-resolve-agent Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Related issue

Closes #7289
Resolves OMNI-7409

Summary

After /clear in Cursor's terminal, web chat and later resume could remain attached to the old conversation.

ELI5: The forwarder now discovers eligible new chat stores, resets its cursor, and signals a sanctioned external-session rotation to the server. It will not take a store claimed by another live session, and ordinary ID updates remain write-once.

Cursor /clear → discover new chat → rebind forwarder → update resume target

Test Plan

Regression coverage in this PR targets the reported behavior. The original description did not record a test-run result; the linked resolve update contains the historical verification context.

Changed regression coverage:

  • tests/e2e_ui/messages/test_cursor_native_clear_rotation.py
  • tests/server/integration/test_sessions_endpoints.py

Suggested check from the PR checkout, with the repository test dependencies installed (not rerun for this edit):

python -m pytest tests/e2e_ui/messages/test_cursor_native_clear_rotation.py tests/server/integration/test_sessions_endpoints.py -q
Source reports and verification runs

Original resolve workflow

Latest resolve-agent update

Demo

  • Visual demo attached below
  • Non-visual evidence provided below or in Test Plan
  • Not applicable — no behavioral change

Recordings from the resolve-agent update; Linear sign-in required. Captions describe the recorded environment, not a new test run.

  • Before fix (bug reproduced) — start a cursor-native session → send a composer turn (reply mirrors into the web chat) → open Terminal view and type /clear in the pane → back in Chat, send another composer message → the message is accepted but no assistant reply ever appears in the web transcript (session looks dead)
    before-web-strand.mp4
  • After fix (bug resolved) — start a cursor-native session → send a composer turn (reply mirrors into the web chat) → open Terminal view and type /clear in the pane (TUI starts a new chat) → back in Chat, send another composer message → the reply now appears in the web transcript and the session's cold-resume target points at the new chat
    after-web-rotation.mp4

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

Coverage notes

Test results above are attributed to the original resolve runs. No product tests were rerun for this description-only update.

A cursor-agent TUI /clear (or /new, /new-chat) starts a brand-new chat
store while the old one stays on disk, so the forwarder — which only
re-discovers when its bound store disappears — stayed pinned to the
cleared-away chat: composer replies landed in the TUI's new chat but
never reached the web transcript (from the browser the session looked
dead), and the one-shot external_session_id patch left a later cold
resume targeting the pre-/clear chat.

The forwarder now detects a newer sibling chat store (created after the
bound chat and at/after this launch) once each poll's backlog is
drained, re-binds the mirror to it (fresh rowid cursor and model
dedupe), and re-points the cold-resume target. A candidate claimed by
any live sibling session is never taken, so a concurrent same-cwd
session's first chat cannot be stolen.

external_session_id is write-once by contract (a divergent wrapper
write signals a bug worth surfacing loudly), so the re-point rides a
new wrapper event, external_session_rotated, which the server maps to
the one sanctioned overwrite (set_external_session_id with
allow_rotation=True); the plain PATCH keeps its loud-failure contract
for every other caller.
@github-actions github-actions Bot added size/XL Pull request size: XL P2-medium Priority: bug with workaround, important feature request labels Sep 13, 2026
@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

UI Preview for this PR has been removed.

Comment thread tests/e2e_ui/messages/test_cursor_native_clear_rotation.py Fixed
@omnigent-ci

omnigent-ci Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Polly AI Review

Missing visual demonstration

This PR fixes a user-visible broken behavior: before it, a TUI /clear stranded the web session on the cleared-away chat (nothing from the new chat reached the transcript, and the browser view looked dead). That's exactly the "backend bug that was stuck/broken and is now fixed" case that warrants a before/after demonstration. The description has no screenshot or video (the attachment scan found none). Please attach a short recording/GIF showing the web transcript following the TUI onto the new chat after /clear (and, ideally, a cold resume landing on the new chat). The new e2e journey test is good, but a visual makes the fix reviewable without a checkout.

Blocking issues

  1. Transient server 5xx/429 permanently strands the cold-resume target. In _post_external_session_rotated (forwarder.py), any resp.status_code >= 400 returns True ("settled"), so the rotation block then advances patched_chat_id to the new chat even though the server never applied the overwrite. A transient 429/5xx (e.g. a momentary DB error) is therefore treated identically to a deterministic "old server doesn't know this event" rejection, and the code never retries. external_session_id stays pinned to the pre-/clear chat, and a supervisor restart can't recover it: the cold-bind path uses the write-once _patch_external_session_id, which rejects the divergent value. This defeats the PR's own stated goal on exactly the failure class the item-post path does retry. Retry 429/5xx (return False) and reserve "settled" for 4xx/unsupported-event responses.

  2. The external_session_rotated overwrite is not restricted to wrapper/runner origin, despite the contract saying it must be. Both the store docstring and the route comment state "Only a wrapper bridge reporting a detected rotation may pass it," and the whole point of the event is to be the one sanctioned bypass of the write-once external_session_id guard. But the handler in _wake_bound_runner_for_control (routes_events.py) only rides the generic LEVEL_EDIT gate — it does not call _has_runner_created_by_authority (which already exists in this file) or any owner-level check. So any editor-level caller can overwrite an established external_session_id with an arbitrary non-empty string, redirecting the owner's cold-resume --resume <chatId> target to a chat they don't control — precisely the divergent write the write-once contract was built to reject loudly. Gate this event on runner-tunnel authority (as the documented "wrapper bridge only" intent requires), or justify why LEVEL_EDIT parity with the other external_* events is acceptable for a field that otherwise refuses overwrites. (Note: siblings like external_session_superseded also sit at LEVEL_EDIT, so there's a parity argument — but none of them defeat a write-once invariant, so the bar here is higher.)

Security vulnerabilities

Covered by blocking item #2: the new event weakens the existing write-once external_session_id boundary and is reachable by any LEVEL_EDIT principal, not just the runner that legitimately observed the rotation. No secret exposure, injection, or SSRF introduced.

Non-blocking notes

  • Sibling-claim TOCTOU. _chat_claimed_by_any reads a sibling's on-disk state file, but a concurrent same-cwd session's new store.db can become discoverable before that sibling writes its claim/heartbeat. An established (earlier-launched) session can then observe it "unclaimed," rotate onto it, and write its own claim first; the real owner subsequently yields in _chat_claimed_by_other, cross-wiring the two conversations. Low probability and consistent with the existing filesystem-claim design, but the added test writes the claim before creating the store, so it sidesteps rather than exercises this window. Consider a note or a small grace re-check.
  • Old-store drain race. retrying_items only reflects failures from the already-captured _read_new_items snapshot. A row committed to the old store after that read but before _discover_rotated_store rebinds (last_rowid = 0 on the new store) is never mirrored. After /clear the old store is normally frozen, so this is a narrow window — but worth a comment acknowledging it.
  • Newest-only rotation candidate. _discover_rotated_store returns only the single newest qualifying store, then _chat_claimed_by_any filters that one. If the newest is a sibling's claimed chat, an older unclaimed chat that this session's own /clear created is never considered, so the session can stay pinned to its pre-/clear store. Degraded (not broken), but the intended fix silently doesn't fire in this multi-chat case.
  • Path-hash-mismatch fallback. _discover_rotated_store scans only store_path.parent.parent (the bound store's hash dir). If the bound store was resolved via _discover_store's cross-workspace-dir fallback, a /clear chat created under the true md5(cwd) dir won't be found. Edge case, but the two discovery paths diverge here.

Approach

The design is coherent and fits repo conventions: reusing _live_sibling_claims, mirroring the claude-/codex-native rotation model, and threading the overwrite through a dedicated external_session_rotated event rather than loosening the PATCH contract is the right shape. The patched_chat_id refactor (replacing the one-shot bool) is clean and correctly re-patches only on chat-id change. The main gap is enforcement, not structure: the event needs origin restriction (#2) and proper transient-failure retry (#1) to actually uphold the "one sanctioned overwrite" invariant it advertises. No materially simpler alternative — the write-once guard genuinely requires an explicit escape hatch.

Summary

A well-structured fix for a real stranding bug, with a thorough e2e journey test. Two issues should block merge: transient 5xx/429 during the rotation POST permanently strands the cold-resume target (and can't self-heal against the write-once PATCH), and the new overwrite event isn't limited to wrapper/runner origin even though its contract says it must be — letting any editor bypass the write-once external_session_id guard. The remaining races (sibling-claim TOCTOU, old-store drain, newest-only candidate) are low-probability edges worth noting but not blocking. Address the two blockers and this is in good shape.


Automated review by Polly · workflow run

@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark results (SQLite, PR #7295)

Commit: 89779a90068ae5d31b0934df5163cc3f0ef9116e

Benchmark comparison

Regression threshold: 100% on run-median P50 or P95.

Journey Status Base run-med P50 ms Cand run-med P50 ms Δ P50 Base run-med P95 ms Cand run-med P95 ms Δ P95 Req/op
project_order_save_1000 ✅ ok 22.7 23.9 +5.5% 24.7 25.2 +2.3% 2.0
project_order_get_custom_1000 ✅ ok 4.5 4.6 +0.7% 4.8 5.1 +6.5% 1.0
project_order_projects_custom_1000 ✅ ok 15.6 16.0 +2.6% 103.4 111.3 +7.6% 1.0
project_order_session_projects_custom_1000 ✅ ok 20.4 21.4 +5.0% 111.0 130.2 +17.3% 1.0
list_sessions ✅ ok 12.3 12.9 +5.4% 13.3 14.6 +10.2% 1.0
create_session ✅ ok 321.7 326.3 +1.4% 332.5 332.7 +0.1% 2.0
get_session ✅ ok 15.3 15.3 -0.1% 16.0 16.3 +2.1% 1.0
load_conversation_history ✅ ok 4.6 4.5 -0.3% 5.4 5.2 -4.1% 1.0
search_sessions ✅ ok 188.9 187.9 -0.5% 222.2 191.5 -13.8% 1.0
list_projects ✅ ok 7.5 7.6 +1.6% 8.0 7.9 -0.8% 1.0
list_project_sessions ✅ ok 14.4 14.3 -0.7% 15.2 14.8 -2.5% 1.0
fork_session ✅ ok 20.1 20.4 +1.4% 21.3 21.7 +1.9% 1.0
add_comment ✅ ok 5.3 5.1 -2.8% 5.5 5.6 +1.1% 1.0
policy_evaluate ✅ ok 9.5 9.5 -0.2% 9.8 10.2 +4.1% 1.0
session_cold_start ✅ ok 3826.4 3859.4 +0.9% 3831.4 3910.4 +2.1% 14.0
session_cold_restart ✅ ok 4053.4 4180.3 +3.1% 4096.9 4219.8 +3.0% 13.0
warm_turn ✅ ok 159.3 167.4 +5.1% 173.3 180.0 +3.9% 3.0
time_to_first_token ✅ ok 373.4 388.3 +4.0% 389.3 406.0 +4.3% 4.9→5.0
interrupt ✅ ok 112.5 126.2 +12.2% 124.4 145.9 +17.3% 5.0
read_runner_file ✅ ok 8.7 10.4 +20.1% 10.9 14.4 +32.0% 1.0
native_hook_spawn ✅ ok 46.8 50.3 +7.5% 53.2 57.2 +7.5% 0.0
cli_startup ✅ ok 14979.5 15358.4 +2.5% 15306.4 15456.0 +1.0% 29.3→31.2

PASS — no regressions detected.

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
Comment thread tests/test_cursor_native_forwarder.py Fixed
omni-resolve-agent Bot and others added 2 commits September 27, 2026 03:59
…on runner authority

Address review of the /clear cold-resume rotation:

- _post_external_session_rotated now retries on transient rejections
  (429/5xx) instead of settling. Settling advanced patched_chat_id and
  stranded the cold-resume target against the write-once PATCH; only a
  2xx ack or a deterministic 4xx (an old server without this event)
  settles now.
- external_session_rotated is the one sanctioned overwrite of the
  write-once external_session_id, so restrict it to the session's runner
  bridge via _has_runner_created_by_authority (403 otherwise). The cursor
  forwarder now presents the runner tunnel binding token so its legitimate
  rotation post is authorized on both tunnel- and bearer-auth hosts.

Adds transient-retry and negative-authority regression tests, notes the
sibling-claim and old-store-drain race windows, and drops a redundant
import flagged by CodeQL.
@omni-resolve-agent

Copy link
Copy Markdown
Contributor Author

Resolve-agent reviewed this contributor PR as the candidate fix. Future actionable maintainer review feedback may be remediated automatically.

@omni-resolve-agent omni-resolve-agent Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolve-agent review outcome: fixed.

Summary
PR #7295 makes the forwarder discover an eligible new chat store after /clear, drain the backlog, rebind with a fresh cursor, and POST a sanctioned external_session_rotated event so the server re-points external_session_id via set_external_session_id(allow_rotation=True), while plain PATCH writes stay write-once. On top of that PR head I committed 89779a9 to address Polly's two blockers: (1) forwarder.py _post_external_session_rotated now returns False (retry next poll) on 429/5xx and settles only on 2xx or a deterministic 4xx, so a transient server hiccup can no longer permanently strand the cold-resume target; (2) routes_events.py gates external_session_rotated on runner-tunnel authority (_has_runner_created_by_authority), returning 403 to any non-runner caller, and orchestration.py sends the runner tunnel binding token (X-Omnigent-Runner-Tunnel-Token) with the cursor forwarder's requests so the real bridge is authorized. I also added the sibling-claim TOCTOU and old-store drain-race notes Polly flagged as non-blocking.

Root cause
cursor-native's forwarder resolved the chat store once and only re-discovered it when the file disappeared; /clear creates a new sibling ~/.cursor/chats/<md5(cwd)>//store.db while the old file remains, so the forwarder stayed pinned to the pre-/clear chat and external_session_id (the --resume target), patched write-once behind chat_id_patched, was never re-pointed. No rotation logic existed for cursor-native (grep -ci rotat over the package returned 0).

CI
not run in this session — CI is implementation-only for the agent; the publisher owns the CI loop after it pushes 89779a9 to fix/34731956277.

Automated review
not clean/not re-run: the prior Polly review (issue comment 5650987543, against old head a094a3b) raised two blocking findings — (1) transient 5xx/429 permanently strands the cold-resume target, and (2) the rotated overwrite is not restricted to runner origin — both addressed in commit 89779a9, plus the non-blocking sibling-claim TOCTOU and old-store drain-race notes. No fresh comment exists for the current head; the agent does not dispatch Polly in CI, so a fresh Polly review on the new head is still required (publisher/CI owns it). Not claiming clean.

Reviewed and tested commit: 89779a90068ae5d31b0934df5163cc3f0ef9116e.

This branch has not been deployed

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

Labels

P2-medium Priority: bug with workaround, important feature request size/XL Pull request size: XL ui-preview

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] cursor-native: a TUI /clear (/new, /new-chat) strands the web session on the old chat — no session rotation

1 participant