Skip to content

feat(streams): a reply generated on one machine now streams to every machine - #2421

Merged
2witstudios merged 2 commits into
pu/stream-join-contextfrom
pu/remote-frame-follower
Aug 15, 2026
Merged

2witstudios merged 2 commits into
pu/stream-join-contextfrom
pu/remote-frame-follower

Conversation

@2witstudios

@2witstudios 2witstudios commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Stack

This is one of three stacked PRs, to be reviewed and merged bottom-up:

PR What Base
1 #2419 Reap by atomic claim — stops a second machine deleting a live reply master
2 #2420 The join authorizes from the DB when the registry misses pu/reap-claim
3 #2421 Follow another instance's frames from the durable log pu/stream-join-context

Each PR is a strict improvement on its own, but the user-visible cross-instance join only works once all three land. Workstream A of "make AI streaming safe at more than one machine" — the goal is raising pagespace-web from min_machines_running = 1 to 2.


The join could classify a cross-instance stream (previous leaf) but had no
frames to serve it. Cross-instance delivery was stream-join-poll-fallback
whole-array parts snapshot replacements at roughly 1200ms. This tails the
durable frame log instead: incremental frames at ~300ms.

THE ROUTE BODY IS NOT FORKED, and that is the design. A follower produces a real
StreamChannel, so subscribe / onFrame / onEnd, the SSE encoding, the 20s
ping, the 5s permission recheck, teardown, and the overflow / resumeFromSeq
semantics are literally the same code for both sources. The alternative — an
if (remote) branch in the route — meant two copies of five behaviours, one of
which nothing local exercises.

  • frame-log-cursor.tsreadFramesFrom({messageId, fromSeq}). TWO CURSORS,
    not the schema docblock's from_seq + frame_count > $X: that predicate is an
    expression over two columns, so it is not sargable against the
    (message_id, from_seq) PK and would scan the whole log on every tick, per
    follower. Instead: max(from_seq) WHERE from_seq <= $X seeks the containing
    row, then from_seq >= that range-scans forward, and the overshoot is sliced.
    Contiguity is enforced exactly as readFrames does — a row whose from_seq
    is not where the walk expected it is a HOLE, and the walk STOPS and reports
    truncated. It never skips. Folding across a hole streams a confidently-wrong
    message to a live user who cannot tell.
  • remote-frame-follower.ts — refcounted per messageId, so co-located tabs share
    one poller rather than making Postgres load scale with viewers. Starts at seq 0
    so any subscriber cursor is servable (a channel starting at one reader's cursor
    answers overflow to every reader below it, and overflow is a reseed, not a
    resume). Polls at 250ms — matched to the writer's 64-frame / 200ms / 256KB
    flush, since polling faster cannot see fresher data — backing off to 1s after 4
    empty ticks and resetting on any frame. The session status is read ONLY on
    ticks that found nothing. Lingers ~5s past the last reader so a reconnect does
    not cold-start, plus a STREAM_MAX_LIFETIME_MS eviction.
  • THREE HONEST ANSWERS AT TERMINAL, because frames are deleted on the terminal
    write: log present → serve the rest, then a clean done; log released →
    {done, aborted, reload}; row gone → the same, never a clean end. A short
    bubble that looks whole is the failure being avoided.
  • reload is a new wire field, distinct from resumeFromSeq and not to be
    collapsed into it: resumeFromSeq says "ask again from here", reload says
    there is nowhere to resume from. It plumbs ChannelEnd.truncated
    consumeStreamJoinendSession(joinFailed: true), which already triggers
    the durable-message reload.
  • streamSessionRegistry's success path ended joinFailed: false
    unconditionally. Now !session.delivered || result.reload. A join that
    resolved having delivered NOTHING — it opened, the stream was already over —
    is not authoritative whatever its terminal frame said, and consumers were
    keeping a debounced seed snapshot that can be shorter than the finished reply.
  • X-Stream-Join-Source: local | remote | terminal. An N=2 smoke test has no
    other way to PROVE it exercised the follower rather than getting lucky with the
    load balancer; in production the remote share should sit near (N-1)/N, which
    makes it a free load-balancer monitor.

Out of scope and untouched, per the brief: stream-join-poll-fallback stays (it
remains a fallback until N=2 has baked), the parts / rawPartsCount columns
stay, and the cross-instance abort path is unchanged.

Twelve seeded mutations verified red, including skipping a hole instead of
stopping, dropping the containing-row slice, reporting a read failure as an empty
log, ending a released log cleanly, treating an unreadable status as terminal,
never resetting the poll backoff, a non-idempotent release, removing the linger,
and both joinFailed regressions. Two follower tests were strengthened after
their first versions failed to discriminate.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01SQwbEoZweBEBgsGq9wnqXj

Stacked on the join-context PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SQwbEoZweBEBgsGq9wnqXj

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5d91413a-3bcf-4603-b0db-eb0f7a2523bc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

@2witstudios
2witstudios force-pushed the pu/stream-join-context branch from 84a9991 to c2372ad Compare August 15, 2026 21:50
@2witstudios
2witstudios force-pushed the pu/remote-frame-follower branch from 5e643fe to 50c44bc Compare August 15, 2026 21:50

@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: 5e643fe4da

ℹ️ 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 on lines +164 to +168
.where(and(
eq(aiStreamFrames.messageId, messageId),
gte(aiStreamFrames.fromSeq, containing),
))
.orderBy(asc(aiStreamFrames.fromSeq));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Enforce the tick byte limit in the database query

For a long stream, this query materializes every remaining JSON frame row before the loop at line 201 applies MAX_TICK_BYTES; therefore the advertised 2 MB cap does not bound either the PostgreSQL result or this process's memory. A reconnect while the log approaches its 64 MB durable limit—and especially a fleet-wide reconnect across several streams—can allocate tens of megabytes per follower and cause severe memory pressure. Paginate or otherwise constrain the SQL query itself rather than stopping only after all rows have been fetched.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Correct, and embarrassing given that readFrames documents avoiding exactly this and I reintroduced it one module over — while claiming in a comment that the budget "bounds what gets fetched". It did not. Fixed in the latest push.

readFramesFrom is now two passes, the same shape readFrames uses:

  1. metadatafrom_seq, frame_count, byte_size from the containing row onward, with a LIMIT. Three integers per row, so the result is negligible whatever the log holds. The contiguous prefix and the byte budget are both decided from this.
  2. payloadframes for exactly the rows that survived that decision, bounded by from_seq <= $lastWanted.

The cursor seek stays as it was (max(from_seq) where from_seq <= $X), so the whole thing is still PK-indexed and there is no non-sargable predicate anywhere.

It also turns out to be cheaper than the version it replaces on the common path: a tick that finds nothing new — most of them, on a stream sitting in a long tool call — now stops after the metadata pass and never touches the frames jsonb at all.

The existing cases all passed against the unbounded query, which is the real lesson, so four new ones assert on what was fetched rather than what was returned: payload rows pulled vs. budget, the metadata LIMIT, the <= lastWanted upper bound, and zero payload queries on a quiet tick. Removing the upper bound turns 3 red; dropping the LIMIT turns 1 red.

Comment on lines +220 to +225
finishFollower(messageId, follower, {
aborted: state.aborted,
// `empty` is reported only for a log with no rows AT ALL — never for a failed read (see
// `FrameCursorRead.empty`), so a DB blip cannot masquerade as a released log and send
// every viewer into a needless reload.
truncated: tail.truncated || tail.empty,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Require proof of completeness before ending cleanly

When startFrameLogWriter disables itself after a batch insert failure or exhausting its durable budget, the table retains a nonempty contiguous prefix with no hole marker. A remote follower that already delivered that prefix eventually reaches this terminal branch, where tail.empty and tail.truncated are both false, so it sends a clean completion; because the client has delivered frames, joinFailed also remains false and the final persisted message is never reloaded. A surviving log must not be treated as complete without a durable completion/count marker, or this path should request a reload whenever completeness cannot be proven.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Agreed — a non-empty log is not a complete one, and the three early-stop paths in frame-log-writer all leave a hole-free prefix that read as a clean end. Worse than you describe, in fact: because frames HAD been delivered, joinFailed stayed false too, so nothing reloaded and there was no signal anywhere that the reply was short. Fixed in the latest push.

On the two options you offered — a durable completion marker, or requesting a reload whenever completeness cannot be proven — there turns out to be a third that needs no new schema: the stream already carries its own terminator. pumpSdkStreamToChannel appends every SDK frame verbatim, so a log containing finish (normal end), abort (stopped), or the pumps synthetic error frame (SDK stream threw, after which the pump stops reading) contains everything the generation produced. The follower now tracks whether one has arrived and requires it before ending cleanly; otherwise it ends truncated, which the route turns into reload and the client turns into joinFailed: true.

I did look at raw_parts_count first — it is the comparator materializeInterruptedStream uses for this same question — but the terminal write zeroes it, so it is gone by the time a follower needs it.

Tracked as "have we ever seen a terminator" rather than "is the last frame one": a trailing message-metadata frame would defeat the stricter test, and the two directions are not symmetric — failing to recognise completeness costs one needless reload, while falsely claiming it is the silent truncation you found.

Four new cases (surviving-but-unterminated prefix, abort as proof, error as proof, terminator seen on an earlier tick than the terminal branch). Removing the proof turns 1 red; narrowing the terminator set to finish alone turns 2 red; never remembering the terminator turns 4 red.

@2witstudios

Copy link
Copy Markdown
Owner Author

Pushed addressing both P1 findings — the unbounded payload query and the missing completeness proof.

Worth calling out on the second one: there was a third option beyond the two suggested (durable completion marker / always reload). The stream already carries its own terminator — the pump appends every SDK frame verbatim, so a log containing finish, abort, or the pump's synthetic error frame provably holds the whole generation. That needs no new schema and avoids a needless reload on every clean cross-instance completion.

On the first: the pre-existing cursor tests all passed against the unbounded query, which is the actual lesson. The four new cases assert on what was fetched rather than what was returned.

Local verification: bun run typecheck from the repo root, and 6188 tests across src/lib/ai src/app/api/ai src/hooks src/contexts src/stores. Seven seeded mutations verified red across the two fixes.

2witstudios and others added 2 commits August 15, 2026 17:29
…machine

The join could classify a cross-instance stream (previous leaf) but had no
frames to serve it. Cross-instance delivery was `stream-join-poll-fallback` —
whole-array `parts` snapshot replacements at roughly 1200ms. This tails the
durable frame log instead: incremental frames at ~300ms.

THE ROUTE BODY IS NOT FORKED, and that is the design. A follower produces a real
`StreamChannel`, so `subscribe` / `onFrame` / `onEnd`, the SSE encoding, the 20s
ping, the 5s permission recheck, teardown, and the `overflow` / `resumeFromSeq`
semantics are literally the same code for both sources. The alternative — an
`if (remote)` branch in the route — meant two copies of five behaviours, one of
which nothing local exercises.

- `frame-log-cursor.ts` — `readFramesFrom({messageId, fromSeq})`. TWO CURSORS,
  not the schema docblock's `from_seq + frame_count > $X`: that predicate is an
  expression over two columns, so it is not sargable against the
  `(message_id, from_seq)` PK and would scan the whole log on every tick, per
  follower. Instead: `max(from_seq) WHERE from_seq <= $X` seeks the containing
  row, then `from_seq >= that` range-scans forward, and the overshoot is sliced.
  Contiguity is enforced exactly as `readFrames` does — a row whose `from_seq`
  is not where the walk expected it is a HOLE, and the walk STOPS and reports
  `truncated`. It never skips. Folding across a hole streams a confidently-wrong
  message to a live user who cannot tell.
- `remote-frame-follower.ts` — refcounted per messageId, so co-located tabs share
  one poller rather than making Postgres load scale with viewers. Starts at seq 0
  so any subscriber cursor is servable (a channel starting at one reader's cursor
  answers `overflow` to every reader below it, and `overflow` is a reseed, not a
  resume). Polls at 250ms — matched to the writer's 64-frame / 200ms / 256KB
  flush, since polling faster cannot see fresher data — backing off to 1s after 4
  empty ticks and resetting on any frame. The session status is read ONLY on
  ticks that found nothing. Lingers ~5s past the last reader so a reconnect does
  not cold-start, plus a `STREAM_MAX_LIFETIME_MS` eviction.
- THREE HONEST ANSWERS AT TERMINAL, because frames are deleted on the terminal
  write: log present → serve the rest, then a clean `done`; log released →
  `{done, aborted, reload}`; row gone → the same, never a clean end. A short
  bubble that looks whole is the failure being avoided.
- `reload` is a new wire field, distinct from `resumeFromSeq` and not to be
  collapsed into it: `resumeFromSeq` says "ask again from here", `reload` says
  there is nowhere to resume from. It plumbs `ChannelEnd.truncated` →
  `consumeStreamJoin` → `endSession(joinFailed: true)`, which already triggers
  the durable-message reload.
- `streamSessionRegistry`'s success path ended `joinFailed: false`
  unconditionally. Now `!session.delivered || result.reload`. A join that
  resolved having delivered NOTHING — it opened, the stream was already over —
  is not authoritative whatever its terminal frame said, and consumers were
  keeping a debounced seed snapshot that can be shorter than the finished reply.
- `X-Stream-Join-Source: local | remote | terminal`. An N=2 smoke test has no
  other way to PROVE it exercised the follower rather than getting lucky with the
  load balancer; in production the remote share should sit near (N-1)/N, which
  makes it a free load-balancer monitor.

Out of scope and untouched, per the brief: `stream-join-poll-fallback` stays (it
remains a fallback until N=2 has baked), the `parts` / `rawPartsCount` columns
stay, and the cross-instance abort path is unchanged.

Twelve seeded mutations verified red, including skipping a hole instead of
stopping, dropping the containing-row slice, reporting a read failure as an empty
log, ending a released log cleanly, treating an unreadable status as terminal,
never resetting the poll backoff, a non-idempotent release, removing the linger,
and both `joinFailed` regressions. Two follower tests were strengthened after
their first versions failed to discriminate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQwbEoZweBEBgsGq9wnqXj
…ore ending a stream cleanly

Two P1 review findings on the follower.

1. THE TICK BUDGET BOUNDED NOTHING.

`readFramesFrom` selected every row from the cursor onward and applied
`MAX_TICK_BYTES` while walking the result — so the driver had already
materialized and parsed the entire remaining log, up to the writer's 64 MB
per-stream ceiling, before any JavaScript ceiling could look at it. A reconnect
against a long stream, and especially a fleet-wide reconnect across many
streams, allocated that per follower. `readFrames` documents avoiding exactly
this; this module reintroduced it and then claimed in a comment that it did not.

Now two passes, the same shape `readFrames` uses: metadata only (three integers
per row, `LIMIT`ed) decides the contiguous, budgeted prefix, then the payload
query fetches `frames` for exactly those rows, bounded by `from_seq <=
$lastWanted`.

It is also CHEAPER on the common path than the version it replaces: a tick that
finds nothing new — most of them, on a stream inside a tool call — now stops
after the metadata pass and never touches the `frames` jsonb at all.

2. A SURVIVING LOG IS NOT A COMPLETE ONE.

`frame-log-writer` stops early in three documented ways (its pre-write delete
failing, a batch insert failing, exhausting the durable budget), and each leaves
a valid, contiguous, HOLE-FREE prefix. A follower that delivered such a prefix
and then found the row terminal saw `empty: false` and `truncated: false`, sent
a clean `done`, and — because frames HAD been delivered — left `joinFailed`
false too. The user kept a truncated reply that nothing ever reloaded, with no
signal anywhere that it was short.

There is no length marker to check, and `raw_parts_count` (the comparator
`materializeInterruptedStream` uses for this exact question) is zeroed by the
terminal write. But the stream carries its own terminator: the pump appends
every SDK frame verbatim, so a log containing `finish`, `abort`, or the pump's
synthetic `error` contains everything the generation produced. The follower now
requires one before ending cleanly, and asks for a reload otherwise.

Tracked as "have we ever seen one" rather than "is the last frame one" — a
trailing `message-metadata` would defeat the stricter test, and the two
directions are not symmetric: failing to recognise completeness costs one
needless reload, falsely claiming it is the silent truncation above.

The cursor's existing cases passed against the unbounded query, so four new ones
assert on what was FETCHED rather than what was returned. Seven seeded mutations
verified red across both fixes, including removing the `<= lastWanted` bound,
dropping the metadata LIMIT, removing the completeness proof, and narrowing the
terminator set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQwbEoZweBEBgsGq9wnqXj
@2witstudios
2witstudios force-pushed the pu/remote-frame-follower branch from fa8dbe0 to ff6f770 Compare August 15, 2026 22:31
@2witstudios
2witstudios force-pushed the pu/stream-join-context branch from 6414dd4 to 20552d2 Compare August 15, 2026 22:31
@2witstudios
2witstudios merged commit 5e5325a into pu/stream-join-context Aug 15, 2026
1 check passed
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