web: SSE-driven refetch/render churn makes sends and chat switching sluggish as the fleet grows #175
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/coding-lab#175
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Symptom
With several live agents (7 at diagnosis time), the PWA feels sluggish everywhere: sending a normal message, switching between chats, general navigation. The server is NOT the bottleneck — measured on the live instance: GET messages p50 ~48ms, GET /runs/{id} ~7ms, GET /instances ~26ms, POST reply p50 ~400ms (250ms of which is the deliberate tmux keystroke pacing). No resource pressure on the host (load 0.25, 27GB free, zero PSI). The cost lands on the client and its network connection, which is why server htop looks clean.
Mechanism (code-confirmed)
While any agent streams, its tailer publishes
run.messages.changedabout once per second (1s poll). The SPA reacts to every event:web/src/routes/RunChat.tsxrefetchMessages): fires the after-cursor tail fetch AND an unconditional latest-window fetch (2 sequential requests per event, each up to ~150KB with tool chips at the 2000-char truncate limit).web/src/components/AppShell.tsx): refetches the whole/api/v1/instanceslist per event (250ms trailing debounce) — even though the event can only flip a state dot.web/src/lib/chatStream.tsmergeMessages): the merge is "later window wins per seq" unconditionally, so every refetch replaces the latest ≤60 messages with fresh object identities even when nothing changed. Solid's<For>keys by reference → those 60 subtrees (markdown parse included) are torn down and rebuilt roughly every second while an agent streams.run.changedis repo-scoped, so any sibling run's change triggers a fullrefetchRun+refetchMessagesof the open chat.Net effect: a phone with 2-3 busy agents runs a continuous 2-4 req/s fetch loop plus a 60-subtree DOM rebuild per second; the operator's own POST /reply and route navigations queue behind it. Degradation scales with active-run count and conversation length — which is why it reads as "the lab got slower over time".
Fix direction
refetchMessages: fetch only the after-cursor tail; skip the redundant latest-window request unless a back-patch is actually needed (tool status flip / dialog resolution can be signalled, or fetched at a much lower cadence).run.messages.changedalready knows run + state) instead of refetching the full instance list per event.run.changedchat refetch to events for the run actually open.Verification
Reproduce: open the lab with 2-3 busy agents, devtools Network tab — observe the continuous refetch loop; interaction latency while it runs vs. with agents idle. After the fix the idle-chat request rate should drop to ~0 and a streaming chat should cost one small tail fetch per event burst with no rebuild of unchanged message DOM.
Agent Brief
Category: bug
Summary: Stop the SSE-driven refetch/render churn in the SPA: tail-only message fetches, identity-stable merges backed by a server content hash, state-carrying events for the nav rail, and run-scoped chat refetches.
Current behavior:
See the issue body — the mechanism is code-confirmed and was re-verified at triage time. In short, while any agent streams,
run.messages.changedfires ~1/s and the SPA reacts by (1) fetching both the after-cursor tail AND an unconditional latest window in the open chat, (2) refetching the entire instances list for the nav rail, and (3) merging with "later window wins per seq" semantics that replace the latest ~60 message object identities even when content is unchanged — Solid's reference-keyed<For>then tears down and rebuilds those subtrees every tick. Additionally, the repo-scopedrun.changedevent triggers a full refetch of the open chat even when it concerns a sibling run. Server timings are healthy; the cost is client-side and scales with active-run count and conversation length.Desired behavior:
run.messages.changed, an open chat performs the after-cursor tail fetch only. The unconditional latest-window request per event is removed. Back-patched mutations near the tail (tool status flips, answered/resolved dialogs) must still reach the client — the event payload should carry enough signal for the client to know when a latest-window fetch is actually needed, or an equivalent mechanism with the same effect: steady streaming costs one small tail fetch per event burst.run.messages.changedreactions with a short trailing debounce (~300–500ms), like the rail already does.run.messages.changedSSE payload carries the run's conversational state (the event source already knows run + state). The shell patches the existing instances list in place to flip the state dot — noGET /instancesround trip on this event.run.changed(spawn/stop/outcome transitions) may keep refetching the list.run.changedevent only triggers the open chat's refetch when it concerns the run being viewed. If the wire event doesn't currently identify the run, extend it so the client can filter.Key interfaces:
run.messages.changed,run.changed) — payload gains the run's conversational state and whatever back-patch/run-identity signals the client needs. Keep the events small; they are per-second per streaming agent.mergeMessages/mergeRefetch) — become identity-preserving under equal hashes. These are pure and unit-testable; keep them that way.tool_id— don't regress it.Acceptance criteria:
run.messages.changedcarries the run's state; the nav rail state dot updates with zeroGET /instancesrequests for that event type (test asserting no refetch).run.changedevent for a sibling run does not trigger a message refetch of the open chat.Out of scope:
Verification note for the agent: this sandbox has no browser. Verify via unit/integration tests with request-count assertions (jsdom + mocked fetch/SSE). Live on-device verification (devtools Network tab with 2–3 busy agents, per the issue's Verification section) stays with the maintainer.
Landing audit for PR #178 (head
afk/175, verdict PASS → merging on the maintainer's free-text go-ahead).Verification signal relied on: Forgejo Actions
ci / native— green on the amended headd82213c(run 198, 6m14s), which exercised the full gate including golangci-lint. Not re-run locally beyond that; independently, the full local gate was also green before push:gofmt/go vet/go build -tags ui/go test -tags ui ./...(incl.tmuxx), prettier, eslint, vitest 865/865,tsc+vite build,labctl secret scan. Theci-nixgate did not run — correct, since the PR touches no nix/dependency files.Conventions: Conventional Commits title ✓ ·
Closes #175✓ · ADR-0047 included ✓ · merges cleanly intomain@28de985✓.Review outcome: the original PR (
86bfed0) was correct on the perf mechanics but lost the chat's self-heal —backpatchSeqis a delta on a drop-by-design bus, with three loss channels (publish gate swallowing a sig-only-tick backpatch; bus drops; a FULL refetch discarding the queued seq). All three were fixed in the amendmentd82213c(publish-gate leg + FULL absorbs the queued seq + turn-settle escalation), each pinned by a regression test proven red pre-fix, and ADR-0047 now states the level/delta loss split instead of contradicting it.b1f214ewas a formatting-only fix for the initially-redformat:check.Known non-blockers left open:
providertest.Fake.ReadChatshares itsMessagesbacking array (a-raceflake risk if CI ever adds-race— latent, values converge); rail-dot stale-resolve clobber (cosmetic, self-heals on next event); back-patch deletion undetectable (pre-existing under ADR-0016 semantics).