Agent rail resilient to mount churn (StrictMode / remount) + reconnect-refetch
The Step-5 "infinite thinking" hang (pramaan-spec#1372) was a frame
delivery failure. The broker (pramaan-functions #356, merged) pushes
the only-frame-that-settles-the-UI (turn.completed/run.done)
to the connection frozen at launch. When the browser's WebSocket
churns mid-run — React StrictMode every dev run, a real parent remount
for ~15% of prod runs — that launch connection is dead
(410 Gone), so the reconnected client never sees the
terminal frame and hangs forever. This change kills the churn at the
source on the app side and adopts #356's recovery.
Root cause: the socket lived inside the component
useAtlasRail owned its WebSocket inside
AddMatterWizard. The hook's unmount cleanup closed the
socket and the next launch reopened a new one. So any churn of the
component holding the hook tore the live run down:
- Dev — StrictMode double-invokes mount → cleanup → mount, running the close-socket cleanup on every run.
- Prod — a transient remount of the wizard / Step-5 panel during wizard data-load did the same for ~15% of runs (14/96 over 24h, per the broker trace).
The fix: lift the connection out of the React tree
-
New
src/lib/agents/atlas-rail-store.ts— a module-scoped registryMap<formId, AtlasRailConnection>. Each connection owns the socket, theRailState, the watchdog, and the launch/querylifecycle (all the transport that used to live in the hook). The purereduceFrameinatlas-rail.tsis unchanged — same transport/decision split as before. -
Subscriber refcount + grace window — the hook
subscribes/unsubscribes; a connection with zero subscribers is
disposed only after a
3sgrace. A synchronous StrictMode unmount → remount (and any brief real remount) re-subscribes within the window, so the socket and the live run survive the gap. A genuinely-closed wizard gets no new subscriber and is then torn down. -
use-atlas-rail.tsis now a thin subscriber —useSyncExternalStoreover the connection; it owns no socket. Public API is unchanged (turn / thread / status / retryable / isStreaming / streamingMessageMd / send / retry), soStep5ReviewFormand the wizard are untouched at the call site.
AC#2 — stop the prod remount from orphaning the run
The connection is keyed by formId. For a remount to
reuse the live run, that key must be stable across the
remount — but formId was
useState(crypto.randomUUID()) inside the wizard,
so a wizard remount minted a fresh id and a fresh run, orphaning the
old one.
-
formIdownership moved UP toLiveMattersListPage(the stable list page), regenerated once per wizard-open (openWizard) and threaded throughAddMatterModal→AddMatterWizard(new optionalformIdprop; a local fallback keeps Storybook standalone). - Result: a transient remount of the wizard / Step-5 panel now resolves to the same module-scoped connection (same live run, same socket) instead of churning it. StrictMode preserves the id already; this closes the prod-remount case too.
AC#3 — consume #356's query replay on a genuine drop
-
On a mid-run socket
onclose(status stillrunning/launching), the connection reconnects and sendsqueryfor the in-flightrunId(buildQueryMessage, new pure builder inatlas-rail.ts). The broker replays persisted truth —turn.completed+run.done(succeeded), filteredrun.failed(failed),run.accepted(running) — which are exactly the frame typesreduceFramealready settles on. No AgentCore egress on a refetch. -
No double-render — the broker can deliver the
terminal turn twice (live-connection redelivery and
queryreplay). The connection commits at most oneturn.completedperrun_id(completedRunIds), so the reply renders once. Recovery is also bounded (≤2 replay attempts per disconnect, reset on any healthy frame) so a down broker fails fast into a retryable error instead of looping.
Before → after
| Before | After |
|---|---|
Socket owned by useAtlasRail (in the component) |
Socket owned by a module-scoped AtlasRailConnection |
| StrictMode / remount → cleanup closes socket → relaunch | Remount re-subscribes within a 3s grace → socket + run survive |
formId minted inside the wizard (resets on remount) |
formId owned by the stable list page (stable across remount) |
| Mid-run drop → permanent "disconnected" / infinite thinking | Mid-run drop → reconnect + query replay → settles once |
Acceptance criteria
-
AC#1 — rail survives StrictMode/remount via a module-scoped
connection keyed by
formId; no socket churn per run. met -
AC#2 — the prod remount no longer mints a new run:
formIdlifted to the stable parent so a remount reuses the live connection. met -
AC#3 — a genuine mid-run close reconnects, sends
query, feedsreduceFrame, settles, with per-run terminal dedup (no double-render). met - AC#4 — live Step-5 run incl. a forced mid-run reconnect settles with the final frame. unit-modelled; live staging is the owner's final gate
Verification
-
pnpm lint0 errors (2 pre-existing add-matter warnings on untouched lines). -
pnpm test542 passed — incl. the newatlas-rail-store.test.ts: launch+settle, no-relaunch on same signature, socket survives a brief unsubscribe→resubscribe, dispose only after the grace window,queryreplay settles once, and redelivery+query don't double-render. pnpm buildcompiled.git diff --checkclean.- The StrictMode churn is modelled deterministically by the unsubscribe→resubscribe test (the exact subscription pattern React StrictMode produces). Live staging verification — a real Step-5 run with a forced mid-run socket close — needs Production-Clerk auth + a live broker and is the owner's final gate; the broker half (#356) is already deployed.
Files changed
8 file(s), +746 / -209 lines
| Path | Role | + | - |
|---|---|---|---|
| src/components/add-matter/add-matter-modal.tsx | Component | +3 | -0 |
| src/components/add-matter/add-matter-wizard.tsx | Component | +14 | -1 |
| src/components/matters/live-matters-list-page.tsx | Component | +13 | -2 |
| src/lib/agents/__tests__/atlas-rail-store.test.ts | Library | +267 | -0 |
| src/lib/agents/__tests__/atlas-rail.test.ts | Library | +11 | -0 |
| src/lib/agents/atlas-rail-store.ts | Library | +380 | -0 |
| src/lib/agents/atlas-rail.ts | Library | +20 | -0 |
| src/lib/agents/use-atlas-rail.ts | Library | +38 | -206 |