Split out of #9396 review.
Problem
The activity-ordered session list cursors order rows by the later of the persisted transcript mtime and the bridge's in-memory live activity watermark. The watermark is not durable, so the ordering key is not monotonic per session identity: when a session's live entry retires between two page fetches, its key falls back to mtime, and a page whose cursor was encoded from the higher watermark re-admits that row.
Affected surfaces (all use an opaque activity cursor):
GET /workspace/:id/sessions?view=organized
GET /workspace/:id/sessions with parentSessionId or sourceType
Reproduction shape: persisted session S has mtime M; the bridge watermark for S is W > M; page size 1; S is emitted on page 1 keyed by W; S's live entry retires; page 2's strictly-older-than-cursor filter admits S again because M < W.
Before the watermark existed, activity keys came from mtime alone and could only advance, so pagination could skip a row but never repeat one. Duplication is the new mode.
Why it was not fixed in #9396
No stateless fix preserves the feature:
- Encoding the cursor from the persisted key while sorting by the merged key deterministically skips every row whose merged key lands between the tail row's persisted and merged keys — strictly worse than a race-window duplicate.
- Dropping the watermark from the ordering key contradicts the feature: a completed turn would no longer move its session in activity order.
- Preserving retired watermarks re-introduces durability the protocol deliberately does not claim, and needs an eviction policy.
- Flushing the recorder before publishing each terminal was rejected in the design: it turns UI recency into a synchronous durability barrier on the prompt response path.
A correct fix needs the cursor to carry the highest key already emitted per identity so the after-cursor filter can compare against that instead of the row's current key. That changes the opaque cursor payload on every activity-cursor route, so it is a pagination-contract change rather than part of a timestamp change.
Current exposure
No first-party consumer accumulates multiple pages from these routes:
- web-shell requests
view=organized but only ever loads one page; it never consumes nextCursor.
- The CLI session picker does paginate, but on the default numeric-cursor path.
- The VS Code companion does not paginate session lists.
So the exposure is limited to SDK consumers writing their own pagination loops. #9396 documents the behavior in docs/developers/qwen-serve-protocol.md and the design doc, and tells such callers to key accumulated pages by sessionId.
Scope
- Extend the organized / metadata activity cursor payloads to carry per-identity emitted keys (bounded by the live-inflated rows observed during the pass).
- Apply the recorded key in the after-cursor filter instead of the row's current merged key.
- Regression test: a live entry with
W > M retires between two page fetches; the session must appear exactly once across the pass.
- Decide and document the cursor size bound and what happens when it is exceeded.
Split out of #9396 review.
Problem
The activity-ordered session list cursors order rows by the later of the persisted transcript mtime and the bridge's in-memory live activity watermark. The watermark is not durable, so the ordering key is not monotonic per session identity: when a session's live entry retires between two page fetches, its key falls back to mtime, and a page whose cursor was encoded from the higher watermark re-admits that row.
Affected surfaces (all use an opaque activity cursor):
GET /workspace/:id/sessions?view=organizedGET /workspace/:id/sessionswithparentSessionIdorsourceTypeReproduction shape: persisted session S has mtime
M; the bridge watermark for S isW > M; page size 1; S is emitted on page 1 keyed byW; S's live entry retires; page 2's strictly-older-than-cursor filter admits S again becauseM < W.Before the watermark existed, activity keys came from mtime alone and could only advance, so pagination could skip a row but never repeat one. Duplication is the new mode.
Why it was not fixed in #9396
No stateless fix preserves the feature:
A correct fix needs the cursor to carry the highest key already emitted per identity so the after-cursor filter can compare against that instead of the row's current key. That changes the opaque cursor payload on every activity-cursor route, so it is a pagination-contract change rather than part of a timestamp change.
Current exposure
No first-party consumer accumulates multiple pages from these routes:
view=organizedbut only ever loads one page; it never consumesnextCursor.So the exposure is limited to SDK consumers writing their own pagination loops. #9396 documents the behavior in
docs/developers/qwen-serve-protocol.mdand the design doc, and tells such callers to key accumulated pages bysessionId.Scope
W > Mretires between two page fetches; the session must appear exactly once across the pass.