Skip to content

feat(sdk,core): close resumed chat streams promptly when caught up - #4349

Open
ericallam wants to merge 24 commits into
mainfrom
feat/s2-caught-up-reads
Open

feat(sdk,core): close resumed chat streams promptly when caught up#4349
ericallam wants to merge 24 commits into
mainfrom
feat/s2-caught-up-reads

Conversation

@ericallam

@ericallam ericallam commented Jul 23, 2026

Copy link
Copy Markdown
Member

Summary

Resuming a chat session stream (page reload, tab refocus, reconnect) is one of the most frequent things a live chat does, and it was doing more work than it needed to. On a resume the client either held the SSE connection open for the entire long-poll window or issued a separate probe request to decide whether the stream had settled, even when every buffered record had already been delivered. This makes the client detect when it has caught up to the latest output and close the resumed stream immediately.

Net effect on the resume path: one network round-trip instead of two, one connection instead of two, and an idle reconnect that settles in tens of milliseconds instead of waiting out the poll window.

Mechanism

S2's per-stream heartbeat already fires the moment a reader crosses the tail. As of @s2-dev/streamstore 0.25.0 both the batch frame and the heartbeat ping carry the stream tail ({seq_num, timestamp}), so "last delivered seq + 1 === tail" is a reliable caught-up signal with no extra request. SSEStreamSubscription feeds those raw wire signals into CaughtUpTracker (a public export in 0.25.0, so no vendoring) and exposes caughtUp(). The SDK's resume path awaits that and closes the connection the moment the tracker reaches the tail, replacing the old readiness probe.

One correctness note worth calling out: command records (trim, fence) consume a sequence number and count toward the tail, but are never delivered to consumers. The tracker therefore keys off raw wire counts, not post-filter counts, otherwise it would never register as caught up on a stream that ends with a trim. The tracker is also ended on every terminal path (cancel, non-retryable status, user abort, done, auth error, max retries) so a pending caughtUp() can never leak.

Impact

Before writing any of this I ran a standalone red/green benchmark of the current approach against the caught-up primitive, on real cloud S2, taking medians across storage classes. Absolute latencies are RTT-dominated (a local reader talking to cloud S2 has a ~100ms floor); in production the reader sits next to S2, so the durable win is the reduction in round-trips and connections, with latency following.

Express storage class (our default), median:

resume scenario before after
idle reconnect 218ms / 2 reqs / 2 conns 108ms / 1 req / 1 conn
resume mid-turn 121ms / 2 reqs / 2 conns 124ms / 1 req / 1 conn
  • Idle reconnect, the common case: the separate readiness probe disappears. Round-trips and connections halve, and median settle time roughly halves. This is the primary win.
  • Resume mid-turn: latency unchanged, but connections halve (no second tailing connection and no overlap-dedupe pass).
  • No regression on any scenario measured, including reconnecting when already at the tail. Standard storage class shows the same structure.

End to end in a real browser against cloud S2: after a completed turn, a reload settled to ready in about 50ms with no duplicated records, and a follow-up turn streamed cleanly.

Compatibility

Fully backward compatible and feature-detected. When the tail is absent (older self-hosted stream backends that do not emit it yet), CaughtUpTracker never reports caught up and the client falls back to the previous readiness behavior, so existing clients and self-hosters are unaffected. The change bumps @s2-dev/streamstore to 0.25.0 across core, webapp, and cli, and moves to the S2 hosts that version defaults to.

Dev CORS fix (bundled)

While testing this end to end, the dev server was returning a duplicated Access-Control-Allow-Origin header: Vite's dev-server CORS middleware reflects the request Origin on every Express response, on top of the app's own apiCors, and browsers reject the doubled value. Setting server.cors: false in apps/webapp/vite.config.ts makes the app the single source of CORS headers. Dev-only; production builds do not run the Vite middleware, so they are unaffected.

@changeset-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cab3b03

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 27 packages
Name Type
@trigger.dev/sdk Patch
@trigger.dev/core Patch
@trigger.dev/python Patch
@internal/dashboard-agent Patch
@internal/sdk-compat-tests Patch
@trigger.dev/build Patch
trigger.dev Patch
@trigger.dev/redis-worker Patch
@trigger.dev/schema-to-json Patch
@internal/cache Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/metrics-pipeline Patch
@trigger.dev/rbac Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/run-store Patch
@internal/schedule-engine Patch
@trigger.dev/sso Patch
@internal/testcontainers Patch
@internal/tracing Patch
@internal/tsql Patch
@trigger.dev/react-hooks Patch
@trigger.dev/rsc Patch
@trigger.dev/database Patch
@trigger.dev/otlp-importer Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

S2 Streamstore dependencies and REST endpoint defaults are updated to use the newer s2.dev hosts, and Vite server CORS is disabled. SSE subscriptions now track live-tail progress from batch and ping events, expose caught-up state, and report caught-up callbacks. Session stream management logs caught-up tails. Chat transport uses caught-up detection to abort resumed peek streams that receive no response chunk. S2-backed test containers, session-stream helpers, end-to-end tests, workflow image preparation, and protocol documentation are added or updated.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the change well, but it does not follow the required template sections like Closes #, Testing, Changelog, Screenshots, or the checklist. Add the required template sections: issue reference, checklist, testing steps, a short changelog, and screenshots if applicable.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: resumed chat streams now close promptly once caught up.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/s2-caught-up-reads

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.

coderabbitai[bot]

This comment was marked as resolved.

@pkg-pr-new

pkg-pr-new Bot commented Jul 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@cab3b03

trigger.dev

npm i https://pkg.pr.new/trigger.dev@cab3b03

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@cab3b03

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@cab3b03

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@cab3b03

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@cab3b03

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@cab3b03

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@cab3b03

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@cab3b03

commit: cab3b03

coderabbitai[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

@ericallam
ericallam force-pushed the feat/s2-caught-up-reads branch 7 times, most recently from fdcd835 to f202ccc Compare July 27, 2026 21:29
@ericallam
ericallam marked this pull request as ready for review July 27, 2026 21:45
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

ericallam added 14 commits July 30, 2026 14:05
Resuming a chat session stream (page reload or reconnect) held the SSE
connection open for the whole long-poll window even after every buffered
output record had already arrived. The client now detects when it has
caught up to the latest output and closes the resumed stream right away,
so reconnecting to an idle chat settles immediately.

Detection reuses the stream's tail-carrying heartbeat: batch and ping
frames now carry the tail, and CaughtUpTracker from @s2-dev/streamstore
0.25.0 turns "last delivered seq + 1 === tail" into a caught-up signal.
When the tail is absent (older self-hosted stream backends) the client
keeps its previous behavior, so nothing regresses. Also moves to the
current S2 hosts that 0.25.0 defaults to.
In local development, Vite's dev-server CORS middleware reflected the
request Origin on every Express response, on top of the app's own CORS
handling. The two layers produced a duplicated Access-Control-Allow-Origin
header, which browsers reject, breaking cross-origin API calls from a
separate frontend in dev. Disabling Vite's dev CORS lets the app be the
single source of CORS headers. Dev-only; production is unaffected.
caughtUp() is documented to reject when the stream ends before reaching
the tail, but cancel(), non-retryable HTTP responses, and the user-abort
branches closed or errored the stream without ending the tracker, so a
caller awaiting caughtUp() could stay pending forever. End the tracker on
every terminal path.

Also removes a stray closing tag from the changeset and applies oxfmt
formatting.
Two follow-up fixes to the caught-up close, from review of the streaming
path:

- A resumed stream that only receives a tail-bearing heartbeat (nothing new
  to deliver) now settles promptly. The client used to wait for the first
  visible record before it could observe caught-up, so a quiescent reconnect
  never triggered the fast close.

- Caught-up is now reported only after the consumer has actually drained
  every record up to the tail, not when records are merely decoded. A
  resumed stream carrying a backlog that reaches the tail delivers all of it
  before the stream can close, with no dropped records.

The decode path now hands records to a demand-driven reader that marks the
tail boundary as the consumer pulls past it, and ends tracking at that same
point.
The client-protocol reference now describes the tail carried on the
heartbeat ping, the caught-up test a hand-rolled client can run against it,
and the SSEStreamSubscription's own close-on-caught-up on the reconnect path.
Boots the real webapp plus Postgres, Redis, and s2-lite via testcontainers
and drives the session `.out` wire protocol directly: a producer appends
records straight to S2 while the client subscribes through the webapp SSE
proxy using the real stream-subscription code. Covers basic delivery,
multi-turn continuation, resume without duplication, trim and command-record
filtering, a quiescent reconnect settling at the tail, and a backlog that
reaches the tail delivering every record before the stream closes.
Adds a real-Chromium leg to the session-stream e2e: a page on a different
origin than the webapp cross-origin fetches and streams the session `.out`
through the proxy, exercising the browser CORS preflight and streaming read
that a node-fetch client skips. Runs against the same testcontainer stack.
Download Chromium without `--with-deps` (the apt step exited 100 on the CI
runner and aborted the whole e2e job before any test ran) and skip the leg
if the browser still can't launch, so a browser hiccup can never take down
the wire-protocol suite.
Adds wire-level legs drawn from the ai-chat smoke-test catalog: the
`.in/append` client-channel round-trip, the server-side peek fast-close
(`X-Session-Settled`) that pairs with the client caught-up close, a 413 on an
oversized `.in/append` body still carrying CORS headers, and rejection of a
subscribe with an invalid token. Seeds a real Session row so the append path
resolves.
Runs the genuine chat.agent run loop in-process against the testcontainer
stack (webapp + Postgres + Redis + s2-lite + MinIO): the agent's `.in`/`.out`
go through the real webapp Session streams and its snapshots through the real
object store, with the model injected as a deterministic MockLanguageModelV3.
Turns are driven by appending to `.in` over HTTP and read back through the SSE
proxy, so no real LLM or dev worker is involved. Covers a basic turn,
multi-turn continuation on one run, and hydrateMessages.

Adds two small test-only utilities to make this possible: a
`StandardSessionStreamManager` export and a `sessionStreamManager` option on
`runInMockTaskContext`, both from `@trigger.dev/core/v3/test`.
Extends the chat.agent e2e harness to cover the run-lifecycle scenarios that previously needed the run-engine and a supervisor: idle-timeout suspend and resume, chat.endRun() continuation, and chat.requestUpgrade().

session.in.wait() suspends a run on a waitpoint whose only task-visible effect is that runtime.waitUntil() eventually resolves with the next .in record. A TestRuntimeManager plus a SessionWaitpointBackend reproduce that in one process: the two waitpoint apiClient calls are stubbed, and the backend opens its own tail on the session channel and resolves the wait with the next record, so the same run() invocation continues in place (matching local-dev warm resume and the task-observable semantics of a deployed CRIU restore). endRun/requestUpgrade exit the run; a small session orchestrator then spawns the next run as a continuation, mirroring the server re-triggering on the next append.

It deliberately does not model server-side waitpoint bookkeeping or process checkpoint/restore, none of which are visible to task code on the resume path.
ericallam added 10 commits July 30, 2026 14:05
…ation legs

Builds on the waitpoint backend to add the run-lifecycle scenarios it unlocks: a HITL tool approval that crosses a suspend/resume boundary (the answer arrives after the run suspends on the idle waitpoint), a run that suspends and resumes across multiple turns, a requestUpgrade that defers the message so the continuation run processes it, and a three-hop endRun chain that restores history across each continuation.
Rounds out the run-lifecycle coverage: onChatSuspend/onChatResume fire around a real suspend/resume, an OutOfMemoryError on the first attempt fails the run the way a machine swap needs and the attempt-2 retry recovers the in-flight message, and a run with no next message times out on the waitpoint and exits. Adds waitpoint-timeout support to the in-process backend (honors the wire timeout, resolves ok:false) so the idle-timeout path is exercised.
A preloaded chat.agent run (started via transport.preload) that retried after an out-of-memory error dropped the message it was processing at crash time. Boot recovery restores the in-flight message into the dispatch queue and advances the .in cursor past it, but the preload branch fired onPreload and waited for a first message without draining that queue, so the recovered message was stranded behind the advanced cursor and the run sat waiting. The preload branch now dispatches a recovered message as the first turn before waiting.

Includes a full-stack e2e that reproduces the drop (preloaded run, OOM on attempt 1, in-flight message on .in) and passes with the fix.
Cancelling the returned stream aborted the internal connection, but the retry path only bailed on the caller-provided abort signal, so a consumer cancel was treated as a transient drop and kept reconnecting (up to maxRetries), issuing SSE requests after the consumer was gone. Track consumer cancellation separately, wake any pending backoff, and short-circuit the retry. Adds a regression test.

Also from review: drop the unused s2 networkEndpoint field, order the webapp test-server env so the worker-disable vars win over extraEnv as documented, abort the cross-origin browser read on a timer instead of a between-reads deadline, and correct the client-protocol docs (the caught-up check counts the highest raw seq_num including skipped command records; the chat transport closes on caught-up, SSEStreamSubscription only exposes the signal).
The e2e-webapp job timed out at 20 min and E9 (server-peek fast-close) failed. Three causes:

- The e2e vitest config ran files in parallel, so several files each booted a full Docker stack (Postgres, Redis, s2-lite, MinIO) plus a webapp process at once and thrashed the runner. Run e2e files serially, matching the repo pnpm test --no-file-parallelism.
- The wire test seeded a Session with no triggerConfig.basePayload, so .in/append threw a server ZodError. Seed an empty basePayload.
- E9 raw peek fetch had no client timeout, so a peek that did not settle under load hung ~66s until the socket dropped. Bound the fetch with an abort deadline and retry the peek until it settles.

Also raises the job timeout to 30 min for the larger suite.
The in-process chat.agent e2e harness runs every leg against the single
globalThis API registry. When a leg closed while its run was still
parked on a session waitpoint, that run stayed alive into the next leg
and its later teardown unregistered the globals out from under the
active run. In CI this surfaced as Noop run-metadata and "can only be
used from inside task.run()" errors across most legs.

close() now aborts the run and awaits full teardown instead of racing a
10s timeout; the abort disables the session-waitpoint backend so any
in-flight or later wait() returns at once; and the backend keeps its
pending entry until the wait resolves so disable() can abort it. Runs
no longer overlap, so the shared globals stay consistent.
The e2e .out collector subscribed once and stopped at the first graceful
SSE close. The proxy serves a bounded wait window and then closes; real
clients reconnect with Last-Event-ID to keep draining. A pre-populated
stream fits one window, so the wire legs passed, but a streaming agent
turn spans several windows and a slower runner reliably lands the
content past the first close, leaving the collector with empty output.

collectSessionOut now re-subscribes from the last seq it saw (deduping)
until its predicate holds or the deadline passes, matching how the
session manager consumes the channel.
…pped

Self-hosted deployments that run S2 with REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS
and no configured access token got an empty token back from the session
channel initialize call. The SDK's session-stream writer treats an empty
access token as missing S2 credentials and never opens the writer, so a
chat.agent turn's assistant output was silently dropped: .out stayed empty
with no error surfaced.

The initialize response now hands back a non-empty placeholder token when
access tokens are skipped (S2 ignores it in that mode, but the client requires
a token to be present). The session-agent e2e runs the skip-access-tokens path
with no token so the regression stays covered.
The server-to-server AgentChat client's reconnect() now sends X-Peek-Settled,
so resuming an .out that is already at a turn-complete tail closes promptly
instead of holding the full SSE long-poll window. This matches the browser
transport's reconnectToStream and gives server-to-server consumers the same
fast tail-close. The active send-message path is unchanged (no peek, so it
never races a newly-triggered turn's first chunk).
@ericallam
ericallam force-pushed the feat/s2-caught-up-reads branch from c02115a to cab3b03 Compare July 30, 2026 13:07

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

Open in Devin Review

Comment on lines +1755 to +1764
if (options?.peekSettled) {
subscription
.caughtUp()
.then(() => {
if (!sawFirstChunk && !combinedSignal.aborted) {
internalAbort.abort();
}
})
.catch(() => {});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Reloading a chat while the assistant is mid-answer can cut off the rest of the answer

A resumed chat stream is closed as soon as no further output is pending (internalAbort.abort() at packages/trigger-sdk/src/v3/chat.ts:1760) even when the assistant is only pausing mid-answer, so the remainder of that answer never reaches the page.
Impact: If a user reloads or reconnects during a pause in the assistant's reply (for example while a tool call is running), the reply stays frozen half-finished until they reload again or send a new message.

Why a mid-turn pause looks identical to a settled stream to the caught-up signal

reconnectToStream (packages/trigger-sdk/src/v3/chat.ts:1145-1173) passes peekSettled: true, which now also arms the client-side caught-up close. The server's X-Peek-Settled peek only fast-closes when the tail record is a turn-complete (apps/webapp/app/services/realtime/s2realtimeStreams.server.ts:411-416, 443-470); when the turn is still in flight it deliberately keeps the long-poll open so the next chunk is delivered on the same connection.

The new client signal makes no such distinction. CaughtUpTracker resolves from a bare tail-carrying ping with an empty backlog (see the new test "resolves caughtUp() from a ping tail with an empty backlog (open-at-tail)" in packages/core/src/v3/apiClient/runStream.test.ts:734-745). On a reconnect whose Last-Event-ID is already at the tail — exactly what happens when the agent is pausing between chunks, e.g. executing a tool — S2's heartbeat fires within one round-trip, no visible record is delivered, so sawFirstChunk is still false and the connection is aborted.

After the abort the loop takes the next.done path and closes the consumer stream without a turn-complete; state.isStreaming stays true, activeStreams is cleared in the finally, and nothing re-subscribes automatically, so the remaining chunks of the live turn are never rendered.

A correctness-preserving version of this optimisation needs the same "settled" condition the server uses (tail record is turn-complete, e.g. via the X-Session-Settled response header) in addition to being caught up, rather than treating "nothing pending right now" as "turn finished".

Prompt for agents
In packages/trigger-sdk/src/v3/chat.ts, the reconnect path (reconnectToStream -> subscribeToSessionStream with peekSettled: true) now aborts the SSE connection as soon as SSEStreamSubscription.caughtUp() resolves and no visible chunk has been seen. CaughtUpTracker resolves from a tail-bearing heartbeat ping with an empty backlog, which is indistinguishable from a turn that is merely paused (e.g. the agent is running a tool and has not written its next chunk yet). In that case the client closes the resumed stream, no turn-complete is observed, state.isStreaming remains true, and nothing re-subscribes, so the rest of the in-flight turn is never rendered. The server-side X-Peek-Settled peek is stricter: it only fast-closes when the tail record is a turn-complete control record (see apps/webapp/app/services/realtime/s2realtimeStreams.server.ts #peekIsSettled) and otherwise keeps the long-poll open. Consider gating the client-side caught-up close on evidence that the stream is actually settled — for example only arming it when the response carried X-Session-Settled: true, or requiring that the last record seen at/behind the tail be a turn-complete — so a mid-turn quiescent reconnect keeps the connection open as before.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +17 to +18
import type { CaughtUpBoundary } from "@s2-dev/streamstore";
import { CaughtUpTracker } from "@s2-dev/streamstore";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Root-package import of @s2-dev/streamstore lands in browser bundles

runStream.ts is part of the @trigger.dev/core/v3 surface consumed by the browser transports (packages/trigger-sdk/src/v3/chat.ts) and react-hooks. Importing CaughtUpTracker from the @s2-dev/streamstore root brings the whole S2 client entrypoint into that module graph; previously only server-side files (streamsWriterV2.ts, sessionStreamOneshot.ts) touched it. Worth checking whether the package exposes a narrower subpath (or whether tree-shaking actually drops the HTTP client) before this ships to browser bundles.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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