Skip to content

Keep the per-thread realtime channel alive across thread-row echoes - #548

Merged
sysread merged 4 commits into
mainfrom
claude/stoic-keller-rgcyh2
Sep 12, 2026
Merged

sysread merged 4 commits into
mainfrom
claude/stoic-keller-rgcyh2

Conversation

@sysread

@sysread sysread commented Sep 11, 2026

Copy link
Copy Markdown
Owner

SYNOPSIS

Fix the edited replacement message never appearing after a stop-then-edit on a new conversation's first message. The per-thread realtime channel was dying from subscription churn, and the replacement row had no backstop behind its echo. Two siblings in the same family ride along: the per-user relays churned on auth events, and the agent-runs Broadcast channel was torn down under a concurrent consumer.

PURPOSE

Send the first message of a new conversation, hit Stop, choose Edit, resend. Currently the old message fades out and the edited one never shows up until a reload or a thread switch. Bad because the transcript lies: the DB has the edited row and the view has nothing above the reply.

DESCRIPTION

How it behaves today. The destructive edit does not insert the new user row from the browser. The commit RPC inserts it server-side and returns only the assistant row, so nothing on the stream or the send path appends it: its live delivery is the realtime INSERT echo on the per-thread messages channel. Nothing stands behind that echo, and it races the reply's END hydration to the tail of the transcript, so even a delivered echo can land the edited row below its reply.

That channel is opened by an effect keyed on the active thread. To skip drafts, the effect read the thread list directly (findThread), which made it a dependent of every thread-row change: the commit's updated_at bump, auto-title, topics tagging, priming-payload writes. Each change re-ran the effect, which tore the channel down and re-created it under the same topic. realtime-js hands back the still-leaving channel for a repeated topic, and subscribe() on a channel that is not closed returns without joining. Result: the re-created subscription was a no-op and the thread had no live echo stream.

A fresh conversation's first turn is the busiest window for those echoes, which is why this surfaced there first. The normal send never notices: the user row is appended locally and the reply arrives on the stream. The per-user relays (threads, wiki, memories, recipes, grocery, logs, mints) have the same exposure from a different key: their effects read the Session object, which is reassigned on every auth event, so each auth event re-created every per-user channel inside the same leave-ack window. And the agent-runs Broadcast helper opened a channel per consumer on a topic that cannot vary, so two overlapping manual runs (wiki librarian + a memory strip) shared one channel and the first run's teardown deafened the survivor until its inactivity timeout.

What this PR changes.

  • the messages effect now keys on a memoized draft boolean (activeThreadIsDraft, a $derived), so thread-list churn recomputes a boolean that does not change and the effect stays put
  • the per-user effects (relays + offline cache) key on a memoized user-id string (sessionUserId) instead of the Session object, so each channel is created once per signed-in user
  • every postgres_changes helper opens a unique topic per subscription (uniqueTopic), so a fast resubscribe can never collide w/ a leaving channel (the server-side filter, not the topic, scopes the stream)
  • Broadcast helpers (logs, samskara mints, agent runs) keep their stable topics: the topic is the address the edge functions publish to, so a suffix would detach them from their publisher
  • the agent-runs helper shares one refcounted channel per user: events fan out to every live consumer, the channel closes when the last one leaves, and a consumer arriving mid-leave waits for it to settle
  • after an edit commit, runExchange re-fetches the transcript (reconcileTranscript, shared w/ the claim-release reconcile) once the old range has faded out, merging through the position sort - lands the row if the echo drops and makes the live order deterministic
  • the reconnect settle path re-checks the active thread after its fetch so a mid-fetch thread switch cannot paint the old transcript under the new one
  • unit tests pin the fresh-topic contract for all nine postgres_changes helpers, the stable-topic contract for Broadcast helpers, and the shared agent-runs channel
  • docs: gotchas in chat.md and user-message-editing.md, the exchange ledger's second mergeMessagesById caller, a primitive-projection rule for resource-owning effects in frontend-organization.md, the topic rule per channel flavor in the realtime preamble, the shared channel in wiki.md / memory.md; QA walkthrough gains step 8 (stop-then-edit the first message of a new conversation)

Why that fixes it. The channel is only re-created on an actual thread switch, draft materialization, or user change, and even a same-key re-create gets a fresh channel. The edited row's INSERT echo lands on a live subscription, and the post-commit re-fetch puts it above its reply whether or not the echo arrived.

Notes:

  • the topic change is deliberate and safe for postgres_changes channels; it is deliberately NOT applied to Broadcast channels (see above)
  • the re-fetch runs immediately and after the fade-out on purpose: the row is committed before it runs, and the fetch omits the deleted rows, so swapping it in earlier would skip their animation
  • the second-thoughts verdict also rides an UPDATE echo but already has a delayed re-fetch backstop (scheduleVerdictBackfill); the delay is deliberate, since the verdict does not exist yet when the turn commits
  • reconcileTranscript has a known snapshot window on the claim-release path (a row committed during the fetch round-trip drops out until the next reconcile); it is named in the docblock, and merging the live array in is not the fix because the messages channel has no DELETE subscription
  • browser-verified: step 8 of the edit QA walkthrough ran against the local stack at 405e9fb and passed (edited message lands above the reply without a reload; same after reload; follow-up send keeps the order; DB positions as expected). Results row logged on the branch. Gate green throughout.

@sysread sysread left a comment

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.

Landru is thinking on behalf of Jeff:

Full review of the channel-churn fix. Both mechanisms check out as necessary and correctly shaped, and the stop-then-edit chain holds at every link (draft -> materialize -> stop -> edit -> echo -> transcript). Five findings inline and one PR-body item below; the good parts first.

Highlights:

  • The boolean projection is the load-bearing shape (src/screens/Chat.svelte:1881). The obvious fix, reading currentThread.isDraft in the effect, silently fails: currentThread is object-valued, deriveds compare with strict ===, and row patches produce new references, so the effect would churn again in the same failure window. Projecting to a boolean is the non-obvious correct move and the comment names the mechanism.
  • The topic comment preempts the predictable objection (src/lib/supabase/realtime.ts:91). unique topics might fork or leak the server-side subscription is exactly what a skimming reviewer would object to; the comment answers it with the full collision mechanics.
  • QA step 8 teaches the failure mode, not just the symptom (docs/qa/use-cases/chat-edit-user-message.md:52). The busiest-echo-window rationale means a future failure points at the realtime channel instead of the edit RPC, and the NOT RUN results row is honest about the pending manual pass.
  • The test owns exactly our half of the contract (tests/realtime-subscribe.test.ts). Topic uniqueness is what our code controls; realtime-js registry behavior is delegated to the QA walkthrough as a documented coverage split, and the docblock names the hazard and the user-visible stake.

PR body: the trailing _Generated by Claude Code_ footer violates the repo rule of no AI attribution in commits or PRs (PRs 543 and 546 carry none). Drop it from the body.

Comment thread docs/dev/chat.md Outdated
Comment thread src/lib/supabase/realtime.ts Outdated
Comment thread src/lib/supabase/realtime.ts Outdated
Comment thread src/screens/Chat.svelte Outdated
Comment thread docs/dev/user-message-editing.md Outdated

@sysread sysread left a comment

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.

Landru is thinking on behalf of Jeff:

Two pre-existing items from the same review, added at Jeff's direction: fix both in this PR rather than deferring them. They share one mechanism - a post-edit-commit re-fetch of the thread's messages, merged through the position sort, backstops the missing replacement row and makes the live ordering deterministic in one move.

Comment thread src/screens/Chat.svelte Outdated
Comment thread docs/qa/use-cases/chat-edit-user-message.md Outdated
Editing the first message of a new conversation after a stop made the
old message fade out and the edited replacement never appear until a
reload or a thread switch. The replacement row is inserted server-side
by the commit RPC and reaches the open transcript only through the
realtime INSERT echo, so a dead per-thread channel shows up exactly
there - the normal send path appends the user row locally and takes
the reply off the stream, which is why it never noticed.

The channel died from churn. The messages-subscription effect read
the thread list to check for a draft, which made it re-run on every
thread-row echo (the commit's updated_at bump, auto-title, topics,
priming-payload writes), tearing the channel down and re-creating it
each time. realtime-js returns the still-leaving channel when a new
one is requested under the same topic, and subscribe() on a channel
that is not closed returns without joining, so the re-created
subscription was a no-op and the thread lost its echo stream. A fresh
conversation's first turn is the busiest window for those echoes,
which is why this surfaced there first.

The effect now keys on a memoized draft boolean so thread-list churn
no longer re-runs it, and subscribeToMessages suffixes its topic per
subscription so a same-thread resubscribe can never collide with a
leaving channel. A unit test pins the unique-topic contract; the QA
walkthrough gains the stop-then-edit-first-message step.
Review follow-ups on the channel-churn fix.

The replacement user row a destructive edit inserts had no path into
the live view except its realtime INSERT echo, and that echo raced
the reply's END hydration to the tail of the transcript, so even with
a live channel the edited message could land below its reply until a
reload sorted it. runExchange now re-fetches the transcript after the
old range fades out and merges it through the position sort, the same
shape as the second-thoughts verdict backfill. That lands the row when
the echo drops and makes the live order deterministic. The re-fetch is
a named helper shared with the claim-release reconcile, which did the
same thing inline.

The per-user realtime effects keyed on the Session object, which is
reassigned on every auth event (initial session, token refresh, the
mount-time getSession resolve), so each auth event re-created every
per-user channel inside the same leave-ack window that killed the
messages channel. They now key on a memoized user-id string. Every
postgres_changes helper also opens a unique topic per subscription;
the Broadcast helpers cannot, because their topic is the address the
edge functions publish to, so they rely on the memoized key alone.

Doc fixes: the verdict has a re-fetch backstop and is no longer cited
as echo-only; the topic counter sits above the helper docblock; the
effect comment names currentThread as an unsafe read alongside the raw
list; the editing gotcha links chat.md instead of naming it.
@sysread
sysread force-pushed the claude/stoic-keller-rgcyh2 branch from 69f6ffe to 9e478a4 Compare September 11, 2026 21:36

@sysread sysread left a comment

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.

Landru is thinking on behalf of Jeff:

Round-2 review of 9e478a4. The fix commit's mechanics all check out - the two-part channel fix, the sessionUserId conversion, and the reconcileTranscript backstop verify end to end, including the auth-js and realtime-js internals they rest on. What follows is text-level drift the fix left behind, plus two pre-existing holes in the same families, pulled into scope like last round. Nine findings inline; one here that has no diff line to anchor to.

Highlights:

  • The uniqueTopic docblock names the invariant that makes the suffix safe for postgres_changes and silently destructive for Broadcast, with the publisher topics enumerated (src/lib/supabase/realtime.ts:49).
  • The test is two-sided: fresh topics asserted for the postgres helpers AND the stable topic pinned for the Broadcast helper. Asserting the deliberate non-change as a contract is what stops a future generalization from detaching the publishers.
  • Dedup over addition: the claim-release inline copy collapsed into the shared reconcileTranscript instead of a second copy appearing next to it.
  • The sessionUserId comment pre-refutes the just use session false read at the site (src/screens/Chat.svelte:1970).
  • The module preamble's now-false no state claim was corrected rather than left to rot (src/lib/supabase/realtime.ts:20).

Mentorship notes (informational, not fix requests):

  • The Broadcast-vs-postgres topic rule - a Broadcast topic is the publisher's address; a postgres_changes topic is arbitrary because the server-side filter scopes the stream - lives only in uniqueTopic's docblock. One sentence in the module preamble's Two channel flavors paragraph puts it where a future Broadcast helper author will actually look.
  • The value-projection-for-effect-keys pattern now has two instances (activeThreadIsDraft, sessionUserId). Naming the rule once in docs/dev/frontend-organization.md - effects that own a teardown-able resource key on primitive projections, because deriveds compare with strict equality - shortens the next ten-line comment to one.
  • scheduleVerdictBackfill's 20s delay next to reconcileTranscript's immediate fetch is deliberate: the verdict is written seconds after the turn commits, so an immediate re-fetch would miss it. A one-clause cross-reference at either site records the timing analysis where the next reader will need it.

No-anchor finding: LOW - doc ledger. mergeMessagesById now has a second non-selectThread caller from the chat side (the post-edit-commit reconcileTranscript in runExchange), but the Interactions entry in docs/dev/exchange.md:398-401 still names only the post-claim-release effect. The repo rule is that a new cross-feature call site updates the affected doc's Interactions section in the same PR. One line closes it.

Comment thread tests/realtime-subscribe.test.ts Outdated
Comment thread src/lib/supabase/realtime.ts
Comment thread src/screens/Chat.svelte Outdated
Comment thread src/screens/Chat.svelte Outdated
Comment thread tests/realtime-subscribe.test.ts
Comment thread src/screens/Chat.svelte
Comment thread docs/qa/use-cases/chat-edit-user-message.md
Comment thread src/screens/Chat.svelte
Comment thread src/screens/Chat.svelte
claude and others added 2 commits September 11, 2026 23:40
Round-2 review follow-ups.

The agent-runs Broadcast helper opened a channel per consumer on the
one topic that cannot be made unique (it is the address the edge
functions publish to). Two overlapping manual runs in one tab - the
wiki librarian strip and a memory strip have independent busy guards -
therefore got the same channel back from realtime-js, and the first
run's teardown removed it out from under the survivor, which went deaf
and timed out on its inactivity backstop while the server-side run
completed. The helper now keeps one refcounted channel per user: events
fan out to every live consumer, the channel closes when the last one
leaves, and a consumer arriving during the leave waits for it to settle
so it never joins a leaving channel.

Two more members of the churn family: the offline-cache effect now keys
on the memoized user id like the relays, and the reconnect settle path
re-checks the active thread after its fetch so a mid-fetch thread switch
cannot paint the old transcript under the new one.

Text drift from the previous commit: three sites still described the
replacement row as echo-only, which the post-commit re-fetch made false;
the reconcile helper's docblock now names the snapshot window a row can
fall into on the claim-release path and why merging the live array in
is not the fix; the verdict backfill and the reconcile cross-reference
each other's timing. The test pins every postgres_changes helper's
fresh-topic contract, not two representatives, plus the shared
agent-runs channel. Docs: the exchange ledger names the second
mergeMessagesById caller, frontend-organization.md states the
primitive-projection rule for resource-owning effects, the realtime
preamble states the topic rule per channel flavor, the wiki and memory
docs note the shared channel, and the QA log records the step-8
expansion.
The destructive-edit use-case's step 8 ran in a browser for the first
time, against the local dev stack at 405e9fb: fresh conversation,
first message, Stop mid-stream, destructive edit, resend. The edited
row rendered directly above the new reply without a reload (the
reported symptom did not reproduce), a reload held the same two rows,
and a follow-up send kept the full order live. Steps 1-2 expectations
validated along the way; steps 3-6 not re-run (PASS at 59ea99c and
743ed1d).
@sysread

sysread commented Sep 12, 2026

Copy link
Copy Markdown
Owner Author

Landru is thinking on behalf of Jeff:

Step 8 of the destructive-edit use-case ran in a browser for the first time, against the local dev stack at 405e9fb: fresh conversation, first message sent, Stop clicked mid-stream, destructive edit, resend.

PASS on the reported symptom and both mechanisms under test:

  • Without a reload, the old message and the stopped partial faded out and the edited message rendered directly above the completed reply - the reported bug (edited row missing until reload) did not reproduce.
  • A reload showed the same two rows in the same order; a follow-up send kept the full order live (edited, reply, new message, new reply).
  • DB: replacement row at pos 1 with the edited text, reply at pos 3 (created before the replacement insert, as the use-case expects), follow-up rows at pos 4/5, old range deleted.

Steps 1-2 expectations validated along the way (dropdown items, composer pre-population, regen-target outline). Steps 3-6 not re-run this pass (PASS at 59ea99c / 743ed1d). The results row is appended to the use-case log and pushed to this branch (53f3351).

@sysread

sysread commented Sep 12, 2026

Copy link
Copy Markdown
Owner Author

Landru is thinking on behalf of Jeff:

Approving on Jeff's behalf - GitHub will not let the PR author file a formal APPROVE review on their own pull request, so this comment is the approval of record.

All 16 review-thread findings across the three review rounds are fixed and verified in the code, every thread is resolved with a reply, and step 8 of the destructive-edit use-case passed in a browser for the first time (QA comment and results row on this PR). Merge-ready.

@sysread
sysread merged commit d5acfe1 into main Sep 12, 2026
1 check passed
@sysread
sysread deleted the claude/stoic-keller-rgcyh2 branch September 12, 2026 00:34
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.

2 participants