perf(chat): tail-only SSE refetch with content-hash identity to stop render churn #178

Merged
dominik.polakovics merged 3 commits from afk/175 into main 2026-07-18 23:54:04 +02:00

Resolves the SSE-driven refetch/render churn that made the PWA sluggish as the fleet grew: while any agent streamed, the open chat fetched an after-cursor tail PLUS an unconditional latest window per ~1s event, the shell refetched the entire instances list to flip one state dot, and the merge replaced every rendered identity so Solid's reference-keyed <For> rebuilt up to 60 subtrees (markdown parse included) every second.

Wire contract (ADR-0047)

  • Every served message gains content_hash — FNV-64a over the rendered message, stamped in core after scanAndRedact on both serve paths (never in adapters), so the hash always covers the served, masked content and every provider gets it for free.
  • run.messages.changed carries the tick's conversational state and backpatchSeq — the lowest seq whose hash changed vs. the tailer's previous read (per-goroutine baseline, reset on rotation, absent on pure appends/first tick).
  • run.changed carries runID at every single-run emit site (stop, launch, deep-link capture, AFK stop, sweep, startup, pull, exposure redact, title rename); genuinely repo-scoped emits (stop-all, reaper, CR merge, parked) stay bare and keep the conservative refetch.

Client behavior

  • Chat: message ticks debounce (300ms trailing, accumulating the burst's minimum backpatchSeq) into a LIGHT tail-only refetch from after=min(cursor, backpatchSeq-1) — one small fetch covers appends and back-patches, no latest-window request. Route change / rotation / resync / run.changed / send / answer / interrupt keep the FULL protocol. Sibling-run run.changed is filtered by runID.
  • Identity: mergeMessages keeps the previous message object on an equal hash (and the previous array when nothing changed); renderItems is memoized with reconcileRenderItems so unchanged messages, wrappers, and groups keep DOM identity — no teardown of settled subtrees.
  • Rail: run.messages.changed patches the one affected row's state in place from the event payload — zero GET /instances requests for that event type; run.changed/resync still refetch (the self-heal path).

Acceptance criteria → proof

  • One tail fetch per burst, no latest window: RunChat.test.tsx "coalesces a burst into ONE tail request…" (URL-level request counting).
  • Back-patches still land: "reaches a back-patched mutation via after=backpatchSeq-1…" + server TestTailer_backpatchSeq.
  • Identity-stable merge: chatStream/toolGroups unit tests (same element AND array references) + "keeps the rendered DOM node…" (DOM node reference equality across a redelivering refetch).
  • State-carrying events, rail without refetch: TestTailer_derivesStateAndPublishes (envelope carries state) + AppShell.test.tsx (fetch count pinned, dot/badge flip asserted).
  • Sibling run.changed ignored; bare events stay conservative: RunChat + instance/httpapi payload tests.
  • Idle chat issues zero requests: fake-time idle test.
  • Hash-after-redaction ordering pinned by TestReadRedact_hashCoversMaskedContent.

Verification

  • CGO_ENABLED=0 go test ./...: green except the untouched internal/tmuxx real-tmux integration tests, which cannot run in this sandbox (pre-existing environmental). go vet / gofmt clean.
  • web: vitest 861/861 green, tsc --noEmit clean, eslint clean, production npm run build succeeds.
  • Live on-device verification (devtools Network with 2–3 busy agents, per the issue) stays with the maintainer.

Out of scope, per the brief: stream windowing/virtualization (deferred in ADR-0047), server-side query work (#176, #177), push/presence behavior (#160 — untouched, the notify gate still rides the tailer read path).

Closes #175

🤖 Generated with Claude Code

Resolves the SSE-driven refetch/render churn that made the PWA sluggish as the fleet grew: while any agent streamed, the open chat fetched an after-cursor tail PLUS an unconditional latest window per ~1s event, the shell refetched the entire instances list to flip one state dot, and the merge replaced every rendered identity so Solid's reference-keyed `<For>` rebuilt up to 60 subtrees (markdown parse included) every second. ## Wire contract (ADR-0047) - Every served message gains `content_hash` — FNV-64a over the rendered message, stamped in core after `scanAndRedact` on both serve paths (never in adapters), so the hash always covers the served, masked content and every provider gets it for free. - `run.messages.changed` carries the tick's conversational `state` and `backpatchSeq` — the lowest seq whose hash changed vs. the tailer's previous read (per-goroutine baseline, reset on rotation, absent on pure appends/first tick). - `run.changed` carries `runID` at every single-run emit site (stop, launch, deep-link capture, AFK stop, sweep, startup, pull, exposure redact, title rename); genuinely repo-scoped emits (stop-all, reaper, CR merge, parked) stay bare and keep the conservative refetch. ## Client behavior - **Chat**: message ticks debounce (300ms trailing, accumulating the burst's minimum `backpatchSeq`) into a LIGHT tail-only refetch from `after=min(cursor, backpatchSeq-1)` — one small fetch covers appends and back-patches, no latest-window request. Route change / rotation / resync / `run.changed` / send / answer / interrupt keep the FULL protocol. Sibling-run `run.changed` is filtered by `runID`. - **Identity**: `mergeMessages` keeps the previous message object on an equal hash (and the previous array when nothing changed); `renderItems` is memoized with `reconcileRenderItems` so unchanged messages, wrappers, and groups keep DOM identity — no teardown of settled subtrees. - **Rail**: `run.messages.changed` patches the one affected row's state in place from the event payload — zero `GET /instances` requests for that event type; `run.changed`/resync still refetch (the self-heal path). ## Acceptance criteria → proof - One tail fetch per burst, no latest window: `RunChat.test.tsx` "coalesces a burst into ONE tail request…" (URL-level request counting). - Back-patches still land: "reaches a back-patched mutation via after=backpatchSeq-1…" + server `TestTailer_backpatchSeq`. - Identity-stable merge: chatStream/toolGroups unit tests (same element AND array references) + "keeps the rendered DOM node…" (DOM node reference equality across a redelivering refetch). - State-carrying events, rail without refetch: `TestTailer_derivesStateAndPublishes` (envelope carries state) + `AppShell.test.tsx` (fetch count pinned, dot/badge flip asserted). - Sibling `run.changed` ignored; bare events stay conservative: RunChat + instance/httpapi payload tests. - Idle chat issues zero requests: fake-time idle test. - Hash-after-redaction ordering pinned by `TestReadRedact_hashCoversMaskedContent`. ## Verification - `CGO_ENABLED=0 go test ./...`: green except the untouched `internal/tmuxx` real-tmux integration tests, which cannot run in this sandbox (pre-existing environmental). `go vet` / `gofmt` clean. - `web`: vitest 861/861 green, `tsc --noEmit` clean, eslint clean, production `npm run build` succeeds. - Live on-device verification (devtools Network with 2–3 busy agents, per the issue) stays with the maintainer. Out of scope, per the brief: stream windowing/virtualization (deferred in ADR-0047), server-side query work (#176, #177), push/presence behavior (#160 — untouched, the notify gate still rides the tailer read path). Closes #175 🤖 Generated with [Claude Code](https://claude.com/claude-code)
perf(chat): tail-only SSE refetch with content-hash identity to stop render churn
Some checks failed
ci / native (pull_request) Failing after 1m10s
86bfed0777
While any agent streamed, run.messages.changed (~1/s) made the open chat
fire an after-cursor tail fetch PLUS an unconditional latest-window fetch,
made the shell refetch the whole GET /instances list to flip one state
dot, and made the seq-keyed merge (and groupMessages' fresh wrappers)
replace every rendered identity so Solid's reference-keyed <For> rebuilt
up to 60 subtrees per second (issue #175). The server measured healthy;
the cost was all client-side and scaled with active-run count.

Server:
- provider.Message gains content_hash: FNV-64a over the rendered message,
  stamped in core after scanAndRedact on both serve paths (never in
  adapters), so the hash always covers the served, masked content.
- run.messages.changed carries the tick's conversational state and
  backpatchSeq — the lowest seq whose hash changed vs. the tailer's
  previous read (per-goroutine baseline, reset on rotation).
- run.changed names its run (runID, omitempty) at every single-run emit
  site; genuinely repo-scoped emits (stop-all, reaper, CR merge, parked)
  stay bare and keep the conservative refetch.

Web:
- RunChat debounces message ticks (300ms trailing, min-backpatchSeq
  accumulation) into a LIGHT tail-only refetch from
  after=min(cursor, backpatchSeq-1) — no more per-event latest window;
  route change / rotation / resync / run.changed / send / answer /
  interrupt keep the FULL protocol. Sibling-run run.changed is filtered
  by the new runID.
- mergeMessages keeps the previous message object on an equal
  content_hash and returns the previous array when nothing changed;
  renderItems is memoized with reconcileRenderItems so unchanged
  messages, wrappers, and groups keep DOM identity.
- AppShell patches the one affected rail row's state in place from the
  event payload — zero GET /instances requests on message ticks.

ADR-0047 records the protocol and its carve-out from ADR-0005's
envelope-small consequence. An idle open chat now issues no periodic
requests; a streaming chat costs one small tail fetch per burst.

Closes #175

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
style(web): prettier formatting on two issue #175 test files
All checks were successful
ci / native (pull_request) Successful in 6m26s
b1f214e63b
`npm run format:check` is part of the required `ci / native` gate and was
failing on AppShell.test.tsx and toolGroups.test.ts — two hand-wrapped lines
that fit inside the print width. Formatting only, no semantic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(chat): never lose a backpatchSeq announcement — publish, absorb, settle-heal
All checks were successful
ci / native (pull_request) Successful in 6m14s
d82213c73f
backpatchSeq is a delta against a baseline that advances with every
successful read, so a lost announcement was lost forever: the stale
message (a tool chip stuck on "running") only healed on navigation.
Three independent loss channels existed:

- The tailer's publish gate could swallow a backpatch read on a
  sig-only tick: ReadChat races the agent's writer, so a mid-tick
  append can flip an earlier tool_use inside a tick whose stat and
  state legs both saw nothing — and the next tick's publish names
  backpatch 0 against the already-advanced baseline. The gate now
  fires on any non-zero backpatch.
- The event bus drops events for slow subscribers by design. The chat
  now escalates to a FULL refetch when the envelope state leaves
  `working`: one latest-window re-read per turn boundary restores the
  pre-#175 healing coverage at turn cadence instead of tick cadence.
- A FULL refetch landing inside the 300ms debounce window discarded
  the queued seq, and its latest window cannot reach below the newest
  60 messages. The FULL now absorbs the queued seq into its own tail
  start, so even a back-patch deeper than the window survives.

ADR-0047 claimed "a dropped event still costs staleness-until-next-
event, never wrongness" — true for `state` (a level), false for
`backpatchSeq` (a delta). The ADR now states the split and the heals
that bound it.

Follow-up hardening for issue #175.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab!178
No description provided.