Skip to content

feat(ai): user-scoped stream discovery, history rejoin, SSE hardening#2078

Merged
2witstudios merged 5 commits into
masterfrom
pu/e2-discovery
Jul 15, 2026
Merged

feat(ai): user-scoped stream discovery, history rejoin, SSE hardening#2078
2witstudios merged 5 commits into
masterfrom
pu/e2-discovery

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Summary

PR 5 of the Server Stream Durability & Rejoin epic — leaves 5.1-5.7. Depends on (already merged): #2076 (status column + includeStreaming), #2073 (time-based checkpoints + rawPartsCount).

  • 5.1 GET /api/ai/chat/active-streams?scope=user — cross-channel discovery of the caller's own in-flight streams (own rows, heartbeat-filtered, no parts), backing the history tab's streaming badge. New (user_id, status) index (migration 0211).
  • 5.2 SidebarHistoryTab badges streaming conversations, seeded from scope=user and kept fresh via the existing chat:stream_start/chat:stream_complete socket events. GlobalChatContext.loadConversation now requests includeStreaming=1 so a clicked streaming conversation's placeholder row is visible; mergeServerAndPending (already merged in feat(ai): assistant message row at stream start + status column (streaming|complete|interrupted) #2076) replaces it with the live stream once the existing channel-wide attach path discovers it — no new attach machinery needed.
  • 5.3 stream-join SSE sends : ping comment frames every ~20s so a silent multi-minute tool call survives idle-connection reaping by proxies/load balancers.
  • 5.4 When a stream-join 404s (the common cross-instance case — the multicast registry is per-process), fall back to polling the channel's parts snapshot at ~1s (matching the server's own checkpoint cadence) for a near-live view instead of freezing until chat:stream_complete finally arrives. Stopgap until the AI SDK 7 Phase 3 durable transport.
  • 5.6/5.7 — two triaged D-task fixes folded into this hardening PR:
    • withAdvisoryLock now resolves a structural connection_error outcome for pool.connect()/try-lock failures instead of throwing, so callers can distinguish lock-machinery failure from fn itself throwing without an unenforced catch-and-assume convention. startGenerationExclusive branches on the outcome directly; reconcileMachineStorageSerialized explicitly rethrows to preserve its existing propagate-and-let-cron-retry behavior for its own caller.
    • rawPartsCount now reflects the raw-stream position the surviving (capped) checkpoint content actually starts at, not the unconditional raw total — capping content away from a >5MB checkpoint no longer tells a rejoining client to skip past (and permanently lose) the raw frames that fed the dropped content.

Review fixes (post-open)

Two real P2 bugs found by automated review, both fixed and replied to inline:

  • Poll-fallback leak: a poll tick that found no matching row (stream finished, or the 404 was never a liveness gap to begin with — e.g. a private conversation) just kept ticking forever. startStreamJoinPollFallback now takes an onNotFound callback that stops the interval and fires once; useChannelStreamSocket wires it to drop the now-permanently-stale synthesized store entry.
  • rawPartsCount duplication edge case: when the single surviving merged part alone already exceeded the 5MB cap (nothing was actually droppable), wasCapped was true but survivingFromRawIndex only marked where that one part first started — discarding every raw frame that went on to extend it (e.g. thousands of text-delta chunks merged into one giant part). A rejoining client would have re-replayed nearly the entire already-seeded content on top of itself. Added a distinct prefixDropped field and keyed the persisted rawPartsCount fallback on that instead of wasCapped.

Proactive self-review after that (8 finder angles: line-by-line, removed-behavior, cross-file tracer, reuse, simplification, efficiency, altitude, CLAUDE.md conventions — run as parallel background agents) surfaced 3 more real issues, all fixed:

  • onNotFound silently dropped content: the poll fallback's onNotFound removed the store entry but never called onStreamComplete. broadcastAiStreamComplete is itself best-effort (fire-and-forget); if that socket event is also dropped, this poll noticing the row disappeared was the ONLY remaining signal, and without firing the same joinFailed:true reload-from-DB signal, the message could vanish with nothing replacing it — worse than the pre-poll-fallback behavior. Fixed to fire fireComplete(messageId, conversationId, { joinFailed: true }).
  • pollSeq reset on re-entrant restart: a duplicate chat:stream_start for a messageId already being polled aborts the old poll fallback and starts a new one; a fresh local pollSeq counter restarting at 0 would have its early writes silently dropped by the store's per-messageId monotonic seq gate against the old fallback's already-higher watermark, freezing the UI for several ticks. Counter is now keyed by messageId outside any single poll-fallback closure.
  • History-tab badge fetch/socket race: the scope=user discovery fetch is a snapshot taken when dispatched but resolving later; a blind replace on resolve silently dropped any chat:stream_start/complete that landed while it was in flight. Deltas are now recorded per fetch cycle and replayed on top of the snapshot (same shape as useChannelStreamSocket's own bootstrapGeneration guard for the identical race), extracted to a pure applyPendingDeltas.

Also deduplicated the pollControllers abort-loop (flagged independently by two finder angles) into one abortAllPolls helper instead of two copies that could drift.

Filed two more D — tasks at the epic level for lower-severity, out-of-scope-for-now findings: the poll fallback fetches the whole channel's payload every ~1s instead of a messageId-filtered query (rd7gfajzpnvmgk7ijrv68j4n), and active-streams/route.ts's channelId/scope=user branches duplicate their row-fetch+liveness-filter query pattern (ih85mvlv9xwxds1qaggpurto).

Merged master twice to resolve real conflicts (post-open)

Two unrelated PRs landed on master in quick succession after this PR opened, both touching files this PR also changed:

Full monorepo typecheck and both directly-conflicted test files re-verified green after each merge.

Board corrections

  • Marked the two epic-level D — tasks resolved by 5.6/5.7 as completed (lh1zuj3i6nfi5igcl690d7yc, fyyyo59td8si62gwxcxyk19l).
  • Filed a new D — task at the epic level (co2as25wcpme4m4gxqu4zgcj): agent-mode history rejoin (dashboard/page-sidebar) never opts into includeStreaming=1, unlike global mode — SidebarHistoryTab's badge lights up correctly for agent conversations too (discovery is user-wide), but click-through won't rejoin the same way since usePageAgentDashboardStore/usePageAgentSidebarState's loadConversation weren't in this leaf's stated scope (SidebarHistoryTab + GlobalChatContext). Real, unscheduled, not blocking.
  • Noted (not touched): a pre-existing duplicate pair of identically-titled D — tasks already on the epic board ("Live-connected clients don't badge an interrupted stream until reload...", ixpwr76xepu2x9v4pxgksyhz + gg2b2b8getkro3n1erfksj2v) — worth the planner's dedup pass.

Test plan

  • bun run typecheck — clean (full monorepo, all 16 turbo tasks); re-verified after review fixes.
  • bun run test:unit (packages/lib + web) — 926/930 test files, 13671/13693 tests pass. The 3 failing files (admin-role-version.test.ts, grouping.test.ts, activity-tools.test.ts) are untouched by this PR and fail identically on a clean master checkout in this environment (missing Postgres role "test" for DB-integration tests; one timezone-dependent grouping test) — confirmed via git stash.
  • packages/db full suite — clean except the same class of pre-existing DB-role-dependent integration tests (activity-logs-compliance.test.ts, needs test-with-db.sh, not plain vitest run).
  • New/updated unit + integration tests for every leaf (RED-before-GREEN throughout, per the epic's mid-run discovery protocol) — advisory-lock, checkpoint-serialize (+ rawPartsCount cap-trim + prefixDropped fix), stream-lifecycle, active-streams route (scope=user), stream-join route (ping frames, fake-timer coverage of every termination path), useChannelStreamSocket (poll fallback wiring + onNotFound cleanup), stream-join-poll-fallback (new module, incl. terminal not-found behavior), GlobalChatContext (includeStreaming URL), streamingConversationIds (pure badge-set transitions).
  • Staging verification (badge → click → rejoin; 3-minute silent tool call survives) — not run against a live deployment; the epic-specified scenarios are covered by deterministic fake-timer tests that simulate them directly (e.g. stream-join route's "given a silent multi-minute tool call, should send a ping on every tick" advances 3× the ping interval), but I do not have staging access in this session.
  • R — independent spec review (must be a different agent than the implementer, per epic rules — not run by me).

Not merging — R review must PASS first.

https://claude.ai/code/session_01WDuPiAGSLXE3vxC59is3Ro

Server Stream Durability & Rejoin epic, PR 5 (leaves 5.1-5.7).

- 5.1: GET /api/ai/chat/active-streams?scope=user — cross-channel discovery
  of the caller's own in-flight streams (own rows, heartbeat-filtered, no
  parts), for the history tab's streaming badge. New (user_id,status) index.
- 5.2: SidebarHistoryTab badges streaming conversations, seeded from the new
  scope=user endpoint and kept fresh via the existing chat:stream_start/
  chat:stream_complete socket events. GlobalChatContext.loadConversation now
  requests includeStreaming=1 so a clicked streaming conversation's placeholder
  row is visible and mergeServerAndPending can replace it with the live stream
  once the existing channel-wide attach path (already running) discovers it.
- 5.3: stream-join SSE sends `: ping` comment frames every ~20s so a silent
  multi-minute tool call doesn't get reaped by an idle-connection timeout.
- 5.4: when a stream-join 404s (the common cross-instance case — the
  multicast registry is per-process), fall back to polling the channel's
  parts snapshot at ~1s (matching the server's own checkpoint cadence) for a
  near-live view instead of freezing until chat:stream_complete arrives.
- 5.6/5.7 (triaged D-task fixes from PR 4 and PR 1 self-review):
  - withAdvisoryLock now resolves a structural `connection_error` outcome for
    pool.connect()/try-lock failures instead of throwing, so callers can
    distinguish lock-machinery failure from `fn` itself throwing without an
    unenforced catch-and-assume convention. startGenerationExclusive branches
    on the outcome directly; reconcileMachineStorageSerialized explicitly
    rethrows to preserve its existing propagate-and-let-cron-retry behavior.
  - rawPartsCount now reflects the raw-stream position the surviving (capped)
    checkpoint content actually starts at, not the unconditional raw total —
    capping content away from a >5MB checkpoint no longer tells a rejoining
    client to skip past (and permanently lose) the raw frames that fed the
    dropped content.

Board corrections: marked the two epic-level D tasks resolved by 5.6/5.7 as
completed; filed a new D task for a gap found in review — agent-mode history
rejoin (dashboard/page-sidebar) never opts into includeStreaming=1, unlike
global mode, so a badged agent conversation's click-through won't rejoin the
same way. Noted a pre-existing duplicate D task pair on the epic board.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@2witstudios, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ba980076-2039-4e56-bc02-2d7bad7316c6

📥 Commits

Reviewing files that changed from the base of the PR and between c0eb836 and a14b0e2.

📒 Files selected for processing (26)
  • apps/web/src/app/api/ai/chat/active-streams/__tests__/route.test.ts
  • apps/web/src/app/api/ai/chat/active-streams/route.ts
  • apps/web/src/app/api/ai/chat/stream-join/[messageId]/__tests__/route.test.ts
  • apps/web/src/app/api/ai/chat/stream-join/[messageId]/route.ts
  • apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarHistoryTab.tsx
  • apps/web/src/contexts/GlobalChatContext.tsx
  • apps/web/src/contexts/__tests__/GlobalChatContext.test.tsx
  • apps/web/src/hooks/__tests__/useChannelStreamSocket.test.ts
  • apps/web/src/hooks/useChannelStreamSocket.ts
  • apps/web/src/lib/ai/core/__tests__/checkpoint-serialize.test.ts
  • apps/web/src/lib/ai/core/__tests__/stream-join-poll-fallback.test.ts
  • apps/web/src/lib/ai/core/__tests__/stream-lifecycle.test.ts
  • apps/web/src/lib/ai/core/checkpoint-serialize.ts
  • apps/web/src/lib/ai/core/start-generation-exclusive.ts
  • apps/web/src/lib/ai/core/stream-join-client.ts
  • apps/web/src/lib/ai/core/stream-join-poll-fallback.ts
  • apps/web/src/lib/ai/core/stream-lifecycle.ts
  • apps/web/src/lib/ai/streams/__tests__/streamingConversationIds.test.ts
  • apps/web/src/lib/ai/streams/streamingConversationIds.ts
  • packages/db/drizzle/0211_nappy_polaris.sql
  • packages/db/drizzle/meta/0211_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/advisory-lock.test.ts
  • packages/db/src/advisory-lock.ts
  • packages/db/src/schema/ai-streams.ts
  • packages/lib/src/services/sandbox/machine-storage-billing.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/e2-discovery

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 28c9078a92

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/web/src/lib/ai/core/stream-join-poll-fallback.ts Outdated
Comment thread apps/web/src/lib/ai/core/stream-lifecycle.ts Outdated
2witstudios and others added 4 commits July 15, 2026 00:52
…artsCount

Two real P2 bugs found by automated review on PR #2078:

- stream-join-poll-fallback.ts: a poll tick that finds no matching row
  (stream finished, or the 404 was never a liveness gap to begin with —
  e.g. a private conversation, filtered by active-streams the same way
  the join itself was) kept ticking forever. startStreamJoinPollFallback
  now takes an onNotFound callback, fires it once, and stops the interval;
  useChannelStreamSocket wires it to remove the now-permanently-stale
  synthesized store entry instead of leaving it frozen.

- checkpoint-serialize.ts: when the single surviving merged part alone
  already exceeds CHECKPOINT_MAX_SERIALIZED_BYTES (nothing was actually
  droppable), capPartsToByteBudget reported wasCapped=true, which
  stream-lifecycle.ts used as the signal to persist survivingFromRawIndex
  instead of the true raw total. But survivingFromRawIndex there is just
  where that one part FIRST started — it discards every raw frame that
  went on to EXTEND it (e.g. thousands of text-delta chunks merged into
  one giant part), so a rejoining client would re-replay nearly the
  entire already-seeded content on top of itself. Added a distinct
  `prefixDropped` field (true only when a prefix was actually trimmed)
  and keyed the persisted rawPartsCount fallback on that instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fan-out multi-agent self-review (line-by-line, removed-behavior, cross-file,
reuse, simplification, efficiency, altitude, conventions) surfaced 3 real
issues beyond the two already fixed from automated PR review:

- useChannelStreamSocket.ts: the poll fallback's onNotFound callback removed
  the store entry but never called fireComplete/onStreamComplete. Since
  broadcastAiStreamComplete is itself best-effort (fire-and-forget), a
  dropped socket event left nothing to trigger a reload-from-DB — the
  synthesized bubble could vanish permanently instead of being replaced by
  the persisted message. onNotFound now fires the same joinFailed:true
  signal chat:stream_complete's own reload path does.

- useChannelStreamSocket.ts: a re-entrant chat:stream_start for a messageId
  whose poll fallback was already running restarted `pollSeq` at 0, which
  the store's per-messageId monotonic seq gate would silently drop against
  the old fallback's already-higher watermark — freezing the UI on stale
  content for several ticks. The counter is now keyed by messageId outside
  any single poll-fallback closure, so a restart continues it instead of
  resetting.

- SidebarHistoryTab.tsx: the streaming-badge discovery fetch is a snapshot
  taken when dispatched but resolving later; replacing state wholesale on
  resolve silently dropped any chat:stream_start/complete that landed while
  it was in flight. Deltas are now recorded per fetch cycle and replayed on
  top of the snapshot (same shape as useChannelStreamSocket's own
  bootstrapGeneration guard for the identical race), extracted to a pure
  `applyPendingDeltas` alongside the existing pure transitions.

Also: deduplicated the pollControllers abort-loop (independently flagged by
two finder angles) into a single `abortAllPolls` helper used at both
bulk-teardown sites instead of two copies that could drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
….ts conflict

PR #2080 (guard placeholder INSERT + harden run-failure classification) merged
into master and conflicted with this branch's leaf 5.6 change to
start-generation-exclusive.ts.

Resolved in favor of this branch's design: withAdvisoryLock's structural
connection_error outcome (leaf 5.6) means startGenerationExclusive never
catches withAdvisoryLock's rejection at all, only branches on its resolved
outcome. That makes #2080's runSettled flag — built to disambiguate a caught
rejection's cause before deciding whether re-invoking run unlocked was safe —
provably redundant here: with no catch site, there is no reclassification
step left for it to guard, for any rejection cause including the "post-run
lock release throws" edge case #2080 added a test for. Verified directly: all
21 tests in start-generation-exclusive.test.ts, including that edge case,
pass unchanged against the simpler shape. Updated the file's docstring and
inline comments to explain the reconciliation for future readers of either
PR's history.

The rest of the merge (both chat routes' try/catch around the placeholder
INSERT, CI config, coverage-ratchet additions) applied cleanly with no
conflicts and is orthogonal to this branch's changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDuPiAGSLXE3vxC59is3Ro
…t conflict

PR #2077 (materialize interrupted streams on dead-row reap) merged into
master right after the previous #2080 merge landed, and touched the same
GET /api/ai/chat/active-streams route this branch's leaf 5.1 added the
scope=user discovery mode to.

route.ts itself auto-merged cleanly: leaf 5.1's scope=user branch is
untouched, and #2077's lazy-reap loop (materializeInterruptedStream on
provably-dead rows) is correctly scoped to the pre-existing channelId branch
only — the two modes don't interact.

Only the test file conflicted, on the vi.hoisted() mock declaration: leaf
5.1's tests need mockWhere (asserting the scope=user WHERE clause), #2077's
tests need mockMaterializeInterruptedStream (asserting the reap side
effect). Both are exercised elsewhere in the now-merged file, so both are
kept in one combined vi.hoisted() block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDuPiAGSLXE3vxC59is3Ro
@2witstudios
2witstudios merged commit e6e534f into master Jul 15, 2026
9 checks passed
@2witstudios
2witstudios deleted the pu/e2-discovery branch July 16, 2026 05:08
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.

1 participant