Files
turnstone/scripts
Patrick Buckley 14c246a569 fix(coord): port the truncated-recovery design from interactive (#882)
replay_truncated is now a dead-stream signal, mirroring the converged
interactive.js machinery:

- loadHistoryThenReconnect: tear the transport down first, drop the live
  cursor, refetch /history with cursor adoption, reconnect in .finally.
  The old in-place refetch discarded the /history cursor while /history
  trims the trailing in-flight turn whenever it returns one — a mid-run
  truncation wiped the executing turn with no redelivery and later tool
  results orphaned into top-level bubbles.  Both consumption sites
  (immediate branch and idle-edge deferred consumer) route through it.
  Dropping the cursor before the fetch is load-bearing, not just parity:
  a post-restart heal on an idle ws gets no /history cursor, and
  re-presenting the frozen pre-restart cursor against the reseeded empty
  ring draws replay_truncated forever — an envelope→resync loop that
  parks the pane in degraded cooldown cycles (caught by the new
  browser-level scenario, invisible to source-pattern tests).
- truncatedFromCursor: the truncation-time cursor, recorded keep-oldest
  at the envelope and cleared only by a successful full render; the
  connect chokepoint presents it over the live cursor so every manual
  reconnect re-draws the envelope and the repair survives any teardown
  interleaving (hide/show, degraded cooldown, CLOSED retry, failed
  fetch).
- churn ladder: truncated resyncs feed the same rolling window as
  overflow closes via the extracted recordChurnAndMaybeTrip(); a trip
  skips the resync (the degraded wake re-arms via the chokepoint).
- herd jitter: resyncs start behind a 0..TRUNCATED_RESYNC_JITTER_MS
  spread; one pending resync at a time; the fire path nulls its handle
  before loading; closeStreamTransport owns cancellation.
- sidebar refresh: while a truncation gap is on record the gap machinery
  owns recovery outright — the envelope refreshes once per NEW gap, one
  heal-time refresh covers the retry window, and onopen's no-cursor /
  long-gap arm stands down — so a failed-resync retry loop cannot
  stampede /children + /tasks un-jittered once per reconnect through
  either path.
- a failed /history refetch no longer blanks the pane (wipe + tracking
  resets sit below the !hist guard); a successful full render supersedes
  ALL pending repair intent in one place (gap record, deferred latch,
  pending resync timer) so a heal can never strand a phantom resync.

Behavioral coverage: scripts/recovery_e2e.py gains --scenario
coord-restart — the REAL coordinator pane (chrome, cookie auth,
EventSource, connect chokepoint, resync, churn limiter) mounted against
the interactive recovery node (/coord-static + /coord-recovery), driven
through hide → node restart → show over CDP, asserting the envelope is
drawn, the hidden-window turns heal, the stream re-opens, and the pane
converges.  Revised the two tests that pinned the in-place shape, added
the coordinator mirror of interactive's fresh-connect/churn-limit pins
(keep-oldest record, chokepoint consult, clear-on-render, shared churn
step, trip-skip, jitter scheduler, cancellation site, cursor drop,
per-gap sidebar dedup).
2026-07-21 15:05:23 -07:00
..