fix(interactive): preserve the pane on a failed /history refetch (#890)

Port the coordinator's #882 G3 guard-before-wipe: the wipe + streaming-
ref reset live in replayHistory, reached only on a successful fetch.

- clear_ui no longer pre-wipes the transcript; a failed refetch during
  a rewind/retry/resume replay keeps stale-but-real content instead of
  blanking the highest-traffic pane on a live stream (/history
  failures cluster in exactly the restart windows that emit clear_ui).
- _refetchHistory's failure branch is a DOM/ref/repair-intent no-op:
  no empty-state hint below stale content (the old resync-route wart),
  no streaming-ref reset (which orphaned a mid-jitter turn's bubble on
  the resync route); the truncation record stays armed for the
  connect-chokepoint retry; only the quiesce releases.
- _loadHistoryThenConnect resets streaming refs on a ws SWITCH only --
  the old ws's refs otherwise survive a failed fetch into the new ws's
  stream; a same-ws reload keeps them so the reconnect resumes the
  mid-jitter bubble instead of orphaning it.
- The factory connect() empty-state pre-seed is now the sole producer
  of the failed-first-paint placeholder -- documented load-bearing.

The edit-and-resend dispatch, cross-ws supersession, and repair-intent
lifecycle are unchanged; a failed fetch keeps the resend firing (the
rewind already committed server-side), mirroring coord.

Pinned by test_interactive_refetch_failure_preserves_the_pane (the
mirror of coord's test_coordinator_refetch_failure_preserves_the_pane)
plus the re-pointed quiesce/agent-tracking pin.
This commit is contained in:
Patrick Buckley
2026-07-21 22:19:19 -07:00
parent 7f74e9594e
commit 99fa3def1b
3 changed files with 382 additions and 38 deletions
+161 -7
View File
@@ -292,7 +292,7 @@ def test_rebuild_quiesces_live_events_and_releases_agent_tracking() -> None:
)
assert "!this.currentAssistantEl && !this.currentReasoningEl" in body
replay = body.index("replayHistory(messages) {")
seg = body[replay : replay + 1600]
seg = body[replay : replay + 2600]
for line in (
"this._resetStreamingRefs();",
"this._clearAgentTracking();",
@@ -319,12 +319,166 @@ def test_rebuild_quiesces_live_events_and_releases_agent_tracking() -> None:
# idle edge) instead of dropping it — skipping left the lost-event gap
# unrepaired for the rest of the session.
assert "this._pendingTruncatedResync = true;" in body
# The refetch FAILURE branch resets streaming refs too — it never reaches
# replayHistory, and stale refs there streamed the retried generation's
# first segment into a detached bubble.
fail = body.index("Failure path never reaches replayHistory")
assert "this._resetStreamingRefs();" in body[fail : fail + 700], (
"the refetch failure branch must reset streaming refs"
# The refetch FAILURE branch is a DOM/ref no-op (#890): the full
# guard-before-wipe contract — failure branch, clear_ui, the
# resumability-gated reload reset, and the affordance gates — is
# pinned in test_interactive_refetch_failure_preserves_the_pane.
def test_interactive_refetch_failure_preserves_the_pane() -> None:
"""A FAILED /history fetch must leave the transcript, streaming refs,
and repair-intent state untouched on EVERY re-render route (#890 —
the interactive mirror of coord's #882 G3 guard-before-wipe, pinned
there by test_coordinator_refetch_failure_preserves_the_pane). The
wipe + resets live in replayHistory, reached only on success:
- the clear_ui case must NOT pre-wipe (pre-#890 a failed fetch
during a rewind blanked the highest-traffic pane on a live
stream);
- the _refetchHistory failure branch must be empty except for the
quiesce release (no showEmptyState — the stray hint below stale
content was the resync-route wart; no ref reset);
- the failed-first-paint placeholder survives ONLY via the factory
connect() pre-seed, which must stay ahead of the load call;
- _loadHistoryThenConnect must reset streaming refs UNLESS a
truncation resync is armed (the armed route's cursor replay
resumes the mid-jitter bubble; every non-resumable flavor —
ws switch, first paint, idle edge, unarmed re-auth reload —
resets, or a stale ref would concatenate the next turn into the
old bubble);
- the row-level mutating affordances (rewind / edit /
edit-and-resend) must gate on the _historyStale latch alongside
busy — the latch spans clear_ui arrival through the next
SUCCESSFUL render, covering the fetch window AND the failed-fetch
aftermath (a quiesce-based gate reopened on the failure exit and
let a second rewind over-rewind off the stale DOM);
- the latch heals TRANSPORT-FREE: one turn-free bounded retry from
the clear_ui .then, and a quiesced same-token refetch at organic
idle edges as the double-failure backstop. Neither may touch
the stream — a reload's fresh reconnect draws the server's
synthetic state_change:idle back into the backstop's own trigger
(the round-5 zero-backoff reconnect storm).
"""
body = _INTERACTIVE.read_text(encoding="utf-8")
# clear_ui: no pre-wipe before the fetch, and the quiesce must be
# armed BEFORE the fetch (queued live events land in the rebuilt —
# or, on failure, the stale-but-real — pane, never the void).
cl = body.index('case "clear_ui":')
cl_seg = body[cl : body.index("break;", cl)]
assert "this._refetchHistory(this.wsId, token)" in cl_seg
assert "this.messagesEl.replaceChildren();" not in cl_seg, (
"clear_ui must not pre-wipe the transcript (#890)"
)
assert "this._resetStreamingRefs();" not in cl_seg, (
"clear_ui must not pre-reset streaming refs (#890)"
)
assert cl_seg.index("this._beginReplayQuiesce(token);") < cl_seg.index(
"this._refetchHistory("
), "clear_ui must arm the quiesce BEFORE the fetch"
# Failure branch: quiesce release only.
fail = body.index("Failed fetch = DOM + ref + repair-intent no-op")
fail_seg = body[fail : fail + 1100]
assert "this._endReplayQuiesce(token);" in fail_seg
assert "this.showEmptyState();" not in fail_seg, (
"a failed fetch must not append an empty-state hint (#890)"
)
assert "this._resetStreamingRefs();" not in fail_seg
# The failure branch must RESOLVE, never throw/reject: the clear_ui
# .then dispatches the queued edit-and-resend after a failed fetch
# too (the rewind already committed server-side) — a throw here
# would route to .catch and strand the resend.
assert "throw " not in fail_seg
assert "Promise.reject" not in fail_seg
# Success path still owns the wipe + resets.
replay = body.index("replayHistory(messages) {")
replay_seg = body[replay : replay + 2600]
assert "this.messagesEl.replaceChildren();" in replay_seg
assert "this._resetStreamingRefs();" in replay_seg
# Factory pre-seed ahead of the load — the failed-first-paint
# placeholder's only remaining producer.
conn = body.index("Load-bearing pre-seed (#890)")
conn_seg = body[conn : conn + 600]
seed = conn_seg.index("pane.showEmptyState();")
load = conn_seg.index("pane._loadHistoryThenConnect(wsId);")
assert seed < load, "connect() must seed the empty-state BEFORE the fetch"
# Resumability-gated ref reset in _loadHistoryThenConnect: refs
# survive a reload only when an armed truncation cursor lets the
# reconnect resume into them; every other flavor (ws switch,
# unarmed re-auth reload) resets.
lh = body.index("_loadHistoryThenConnect(wsId) {")
lh_seg = body[lh : body.index("async _refetchHistory(", lh)]
assert "if (this._truncatedFromCursor == null) this._resetStreamingRefs();" in lh_seg, (
"reload must reset streaming refs unless a truncation resync is armed (#890)"
)
# The mutating row affordances gate on the staleness latch alongside
# busy — scoped per method so a gate migrating off one affordance
# cannot hide inside a file-wide occurrence count.
for sig in (
"_rewindToMessage(msgEl) {",
"_startEdit(msgEl, originalText) {",
"_editAndResend(msgEl, newText) {",
):
m = body.index(sig)
assert "if (this.busy || this._historyStale) return;" in body[m : m + 400], (
f"missing busy || _historyStale gate in {sig!r} (#890)"
)
# Latch lifecycle: set at clear_ui arrival BEFORE the quiesce is
# armed (so no event can interleave between restructure-signal and
# gate-close); cleared ONLY in replayHistory's render, which also
# cancels the pending failure retry; the retry is scheduled from
# the clear_ui .then (bounded by construction — a retry's own
# failure cannot re-schedule) and fire-time-gated turn-free; the
# idle edge backstops a double failure via else-if BEHIND the
# truncated branch (whose own render heals the latch too).
assert cl_seg.index("this._historyStale = true;") < cl_seg.index(
"this._beginReplayQuiesce(token);"
), "the staleness latch must be set before the quiesce is armed"
assert "this._historyStale = false;" in replay_seg, (
"replayHistory must clear the staleness latch (its only clear site)"
)
assert "clearTimeout(this._staleRetryTimer);" in replay_seg
assert "this._staleRetryTimer = setTimeout(" in cl_seg
assert "!this.currentAssistantEl &&" in cl_seg, (
"the clear_ui failure retry must be turn-free-gated"
)
assert "} else if (this._historyStale && !this._replayQueue) {" in body, (
"the idle edge must backstop the latch behind the truncated branch, "
"skipping edges with a quiesced fetch already in flight"
)
# The backstop must be TRANSPORT-FREE: a quiesced same-token refetch,
# never _loadHistoryThenConnect — the reload's fresh reconnect draws
# the server's synthetic state_change:idle back into this branch's
# own trigger (the round-5 storm).
backstop = body.index("} else if (this._historyStale && !this._replayQueue) {")
backstop_seg = body[backstop : backstop + 2200]
assert "this._refetchHistory(this.wsId, staleToken);" in backstop_seg, (
"the staleness backstop must heal via a quiesced REST refetch"
)
assert "this._loadHistoryThenConnect(" not in backstop_seg, (
"the staleness backstop must never touch the transport (#890 r5)"
)
# The retry yields to an in-flight quiesce (no same-token stomp).
retry = cl_seg.index("this._staleRetryTimer = setTimeout(")
assert "!this._replayQueue &&" in cl_seg[retry : retry + 700], (
"the clear_ui retry must yield to an in-flight quiesced fetch"
)
# Terminal teardown must cancel the failure retry: destroy() bumps
# no token and clears no latch, so an armed timer would otherwise
# pass its fire-time guards ~2s post-destroy and replayHistory into
# the detached pane. (disconnectSSE deliberately does NOT cancel
# it — transport-only reconnects keep the pending heal intent.)
dest = body.index("destroy() {")
dest_seg = body[dest : dest + 1600]
assert "clearTimeout(pane._staleRetryTimer);" in dest_seg, (
"destroy() must cancel the clear_ui failure retry (#890)"
)
@@ -5291,6 +5291,15 @@ function createCoordinatorPane(root, wsId, opts) {
}
function _rewindToMessage(msgEl) {
// KNOWN GAP (#894, do not re-derive): from clear_ui arrival until
// the next SUCCESSFUL refetchHistory render — the fetch window AND
// the failed-fetch aftermath — the stale transcript is visible
// with busy false, so this DOM count can over-rewind. Port
// interactive.js's #890 shape: a transcript-staleness latch set at
// clear_ui, cleared only by the full render, gating the mutating
// affordances (a plain refetch-in-flight flag is NOT enough — it
// reopens on the failed exit, the bug interactive hit). The
// retry leg needs a quiesce-free variant here.
if (busy) return;
const userMsgs = messagesEl.querySelectorAll(".msg.user");
const idx = Array.prototype.indexOf.call(userMsgs, msgEl);
+212 -31
View File
@@ -267,6 +267,40 @@ class Pane {
// resync no longer quiesces: it tears the stream down first, so no
// live events exist during its rebuild.)
this._replayQueue = null;
// Transcript-staleness latch (#890): TRUE = the visible transcript
// no longer matches the server's conversation STRUCTURE. Set at
// clear_ui arrival (the server just restructured) and on a ws
// reassignment (near-vacuous under one-Pane-per-ws, kept
// defensively); cleared in exactly ONE place — replayHistory's
// full render, the moment the DOM matches the server again. NOT
// tied to the quiesce: the quiesce tracks event buffering and
// releases on a FAILED fetch too, which left a window where a
// rewind/edit computed turn counts off the stale DOM (over-rewind).
// Row-level MUTATING affordances (rewind / edit / edit-and-resend)
// gate on this alongside ``busy``; a new mutating affordance must
// adopt the same gate. Route-L reloads deliberately do NOT set
// it: first paint renders into an empty pane (rewind early-returns
// on idx < 0), the truncated resync self-heals via its armed
// cursor, and the onLogin re-auth reload is count-preserving for
// rewind purposes except a server-side advance during the outage —
// where a stale count UNDER-rewinds (non-destructive) rather than
// over-rewinds. Accepted residual, ruled: after a DOUBLE fetch
// failure the latch persists and rewind/edit stay closed until the
// next idle-edge heal (any turn end) or reload — safe-closed
// strictly beats destructive-open, and a reconnect snapshot must
// NOT clear it (a snapshot heals only the in-flight turn, never
// the committed structure the latch tracks).
this._historyStale = false;
// Single bounded retry for a failed clear_ui refetch (transient
// 500s cluster in restart windows). Scheduled ONCE per clear_ui
// from its .then when the latch survived the fetch; fire-time
// guards: token still current, latch still set, and TURN-FREE
// (never replaceChildren a live bubble — the same rule the
// truncated resync's deferred branch follows; a busy pane defers
// to the idle-edge heal instead). Superseded by any newer load
// (token bump) and cancelled in replayHistory's supersession
// block.
this._staleRetryTimer = null;
// Hot-path caches — all invalidated by _clearAgentTracking/replayHistory.
// _nearBottom mirrors the scroller position via a passive scroll listener
// (no per-token geometry reads); the two Maps make per-event row/stream
@@ -1649,6 +1683,38 @@ class Pane {
// same-ws reload keeps it armed until a successful full render
// (replayHistory clears it) so a failed fetch can retry below.
if (this.wsId !== wsId) this._truncatedFromCursor = null;
// A reassignment also invalidates the staleness latch's referent —
// whatever transcript is visible belongs to the OLD ws, so any
// DOM-derived count is wrong until the new ws renders. Set, not
// clear: replayHistory's render releases it. (Near-vacuous under
// one-Pane-per-ws; kept defensively with the other wsId!==wsId
// branches.)
if (this.wsId !== wsId) this._historyStale = true;
// Streaming-ref reset, gated on RESUMABILITY rather than ws
// identity: refs may survive a reload only when the reconnect can
// resume into them — same ws AND an armed truncation cursor (the
// ring replay continues the mid-jitter bubble; resetting there
// orphaned it beside a duplicate). Every other flavor resets: a
// ws SWITCH (the drop above just nulled the cursor — old-ws refs
// must not survive into the new ws's stream), a first paint or
// idle-edge reload (refs already null; no-op), and an UNARMED
// same-ws reload (the onLogin re-auth fan-out): its reconnect is
// cursorless, a turn that completed during the outage sends no
// resuming snapshot, and a stale non-null ref would concatenate
// the NEXT turn's content into the old bubble at its old DOM
// position.
//
// KNOWN RESIDUAL (ruled, do not re-derive): the armed-case
// preservation adds one route into the pre-existing cross-client
// in_progress_snapshot stale-ref secondary — a turn that starts
// streaming during the resync jitter leaves these refs non-null,
// and a reconnect snapshot reflecting a NEWER turn reuses the
// stale bubble (transient; heals at the next idle-edge resync).
// Deferred to the both-clients snapshot sweep: resetting armed
// refs here kills the mid-jitter continuity contract, and
// patching the snapshot handler in ONE client diverges parity
// (coordinator.js's handler is identical).
if (this._truncatedFromCursor == null) this._resetStreamingRefs();
this._replayQueue = null;
this._clearAgentTracking();
this._pendingTruncatedResync = false;
@@ -1740,14 +1806,19 @@ class Pane {
this._endReplayQuiesce(token);
}
} else {
// Failure path never reaches replayHistory — reset the streaming refs
// here too, or the flushed backlog and resumed live events would paint
// into the subtree clear_ui already wiped. It also leaves
// _truncatedFromCursor SET (only replayHistory clears it), which is
// what arms the connect chokepoint's retry for a failed truncated
// resync.
this._resetStreamingRefs();
this.showEmptyState();
// Failed fetch = DOM + ref + repair-intent no-op (#890, the G3
// guard-before-wipe ported from coordinator.js refetchHistory).
// The prior transcript stays on screen: no wipe ever ran (see
// the clear_ui case), and appending an empty-state hint below
// real content was the old resync-route wart. The streaming
// refs stay untouched — null per the turn-free-at-clear_ui
// invariant, or validly attached on the resync route, where
// resetting them orphaned a mid-jitter turn's bubble and let
// the reconnect snapshot build a duplicate beside it.
// _truncatedFromCursor stays SET (only replayHistory clears
// it), which is what arms the connect chokepoint's retry for a
// failed truncated resync. Only the quiesce must release, or
// a queued clear_ui backlog would wedge the pane.
this._endReplayQuiesce(token);
}
}
@@ -2008,6 +2079,36 @@ class Pane {
this._pendingTruncatedResync = false;
this._recordTruncatedGap();
this._loadHistoryThenConnect(this.wsId);
} else if (this._historyStale && !this._replayQueue) {
// Staleness-latch backstop (#890): a clear_ui refetch and
// its one bounded retry both failed, so the transcript
// still doesn't match the server and rewind/edit are
// latch-closed. The turn just settled — refetch now.
//
// TRANSPORT-FREE BY DESIGN (ruled, do not "upgrade" this
// to _loadHistoryThenConnect): the heal must never touch
// the stream. A reload's fresh reconnect draws the
// server's synthetic state_change:idle, which lands back
// in THIS branch — with /history down that was a
// zero-backoff disconnect/refetch/reconnect storm against
// a recovering node (the round-5 critical). A quiesced
// REST refetch emits zero SSE events, so the trigger edge
// is only ever organic (turn-settle-driven, no server
// heartbeats) and the loop is structurally impossible.
// Stream death has its own owners (EventSource native
// auto-reconnect, the host recovery beat, the truncated
// resync) — the old reload's reconnect was redundant.
// The !_replayQueue guard skips the edge when a quiesced
// fetch is already in flight. The else keeps the
// truncated branch's reload from doubling up (its render
// clears the latch too). On another failure the latch
// persists and the next ORGANIC settle retries. Accepted
// liveness lag: if /history recovers while the pane sits
// idle untouched, rewind/edit stay closed until the next
// organic settle — strictly safer than the storm.
const staleToken = this._historyLoadToken;
this._beginReplayQuiesce(staleToken);
this._refetchHistory(this.wsId, staleToken);
}
// Only steal focus if this is the active pane and no approval pending.
if (this._host.isFocused(this) && !this.pendingApproval) {
@@ -2226,24 +2327,79 @@ class Pane {
case "clear_ui": {
// Conversation was structurally reset (rewind / retry / resume /
// open / fork). Empty the pane for immediate feedback, then
// re-render from REST and dispatch any queued edit-and-resend
// once the (possibly truncated) history lands. The resend keys
// off this signal rather than an inline history SSE event. Capture
// the load token AND the ws identity: a ws SWITCH mid-flight
// discards both the re-render and the resend (no cross-ws send),
// but a SAME-ws supersession — the jittered truncated resync
// bumps _historyLoadToken through _loadHistoryThenConnect — must
// still fire the resend: the superseding load renders the same
// post-rewind history, and silently dropping the queued edit
// stranded the composer busy with a message that never sent.
// open / fork). Re-render from REST and dispatch any queued
// edit-and-resend once the (possibly truncated) history lands.
// The resend keys off this signal rather than an inline history
// SSE event.
//
// No pre-wipe (#890, the guard-before-wipe shape coord shipped
// in #882's G3): the wipe + streaming-ref reset live in
// replayHistory, reached only on a SUCCESSFUL fetch — so a
// failed /history keeps the prior transcript on screen.
// Stale-but-real beats a blank pane on a live stream, and
// /history failures cluster in exactly the restart windows
// that emit clear_ui replays. The quiesce stays armed across
// the fetch either way: queued live events replay into the
// rebuilt pane on success, or onto the stale-but-real one on
// failure — safe because clear_ui emitters are turn-free at
// emission (see the _resyncTimer field comment), so the
// surviving streaming refs are null and replayed events build
// fresh rows below the stale transcript. The stale rows stay
// VISIBLE but their mutating affordances gate on the
// _historyStale latch (set below, cleared only by a successful
// replayHistory render — see the field comment), so a turn
// count computed off the stale DOM can't reach the server
// during the fetch OR after a failed one. A failed fetch
// schedules one turn-free retry (the .then below); the
// idle-edge heal backstops a double failure. Coord's cosmetic
// [data-busy] grey-out stays a deferred parity item (#890 PR).
//
// Capture the load token AND the ws identity: a ws SWITCH
// mid-flight discards both the re-render and the resend (no
// cross-ws send), but a SAME-ws supersession — the jittered
// truncated resync bumps _historyLoadToken through
// _loadHistoryThenConnect — must still fire the resend: the
// superseding load renders the same post-rewind history, and
// silently dropping the queued edit stranded the composer busy
// with a message that never sent. On a FAILED fetch the
// resend still fires (the rewind already committed
// server-side); its bubble lands on the stale transcript and
// the rewound truth arrives with the next successful refetch —
// coord's same ruling.
const token = this._historyLoadToken;
const editWs = this.wsId;
this._historyStale = true;
this._beginReplayQuiesce(token);
this.messagesEl.replaceChildren();
this._resetStreamingRefs();
this._refetchHistory(this.wsId, token)
.then(() => {
// Failed fetch (the latch survived — only replayHistory
// clears it): schedule the ONE bounded retry. Scheduled
// here rather than in the failure branch so a retry's own
// failure cannot re-schedule (bounded by construction);
// fire-time guards make it a no-op once superseded or
// while a turn streams (the idle-edge heal owns the busy
// case).
if (this._historyStale && token === this._historyLoadToken) {
if (this._staleRetryTimer) clearTimeout(this._staleRetryTimer);
this._staleRetryTimer = setTimeout(() => {
this._staleRetryTimer = null;
if (
token === this._historyLoadToken &&
this._historyStale &&
!this._replayQueue &&
!this.busy &&
!this.currentAssistantEl &&
!this.currentReasoningEl
) {
// !_replayQueue: yield to an in-flight quiesced
// fetch (the idle-edge backstop shares this token)
// instead of stomping its queue for a same-token
// double-render.
this._beginReplayQuiesce(token);
this._refetchHistory(this.wsId, token);
}
}, 2000);
}
if (token !== this._historyLoadToken && this.wsId !== editWs) {
// Cross-ws supersession: release the latch + busy so the
// composer recovers; the edit belongs to a pane state that
@@ -2836,7 +2992,7 @@ class Pane {
}
_rewindToMessage(msgEl) {
if (this.busy) return;
if (this.busy || this._historyStale) return;
// Count how many user messages come at or after this one. Bare
// ``.msg.user`` is intentional: system-nudge markers carry that
// class and the server's _find_turn_boundaries counts them as
@@ -2849,7 +3005,7 @@ class Pane {
}
_startEdit(msgEl, originalText) {
if (this.busy) return;
if (this.busy || this._historyStale) return;
// Save current child nodes for cancel restoration
const savedNodes = [];
while (msgEl.firstChild) {
@@ -2911,7 +3067,7 @@ class Pane {
}
_editAndResend(msgEl, newText) {
if (this.busy) return;
if (this.busy || this._historyStale) return;
// Count turns to rewind (from this message onward). Bare
// ``.msg.user`` matches the server's turn semantics — see
// _rewindToMessage.
@@ -2970,13 +3126,15 @@ class Pane {
}
_resetStreamingRefs() {
// Null every ref that can point into a wiped subtree, so the next event
// creates fresh targets instead of writing invisibly into detached
// nodes. Called wherever the transcript DOM is (or is about to be)
// replaced — replayHistory, the clear_ui immediate wipe, and the
// refetch-FAILURE path (which shows the empty state without ever
// reaching replayHistory; leaving refs stale there made the retried
// generation's whole first segment stream into a detached bubble).
// Null every ref that can point into a wiped or non-resumable
// subtree, so the next event creates fresh targets instead of
// writing invisibly into detached nodes (or into a superseded
// bubble). Callers (#890 shape): replayHistory's full render (the
// subtree was just replaced) and _loadHistoryThenConnect's
// non-resumable reloads (no armed truncation cursor — the
// reconnect cannot resume into the surviving refs). The clear_ui
// pre-wipe and the refetch-failure call are GONE: a failed fetch
// preserves the pane, and preserved DOM keeps valid refs.
this.currentAssistantEl = null;
this.currentAssistantBodyEl = null;
this.currentReasoningEl = null;
@@ -3015,6 +3173,14 @@ class Pane {
clearTimeout(this._resyncTimer);
this._resyncTimer = null;
}
// The render also restores structural truth — clear the staleness
// latch (its ONLY clear site) and cancel any pending clear_ui
// failure retry, which exists to produce exactly this render.
this._historyStale = false;
if (this._staleRetryTimer) {
clearTimeout(this._staleRetryTimer);
this._staleRetryTimer = null;
}
// The rebuild just orphaned any in-flight streaming targets — reset them,
// and release the agent-card/orphan maps whose entries now point at
// replaced subtrees (detached-DOM retention).
@@ -4884,6 +5050,11 @@ function createInteractivePane(root, wsId, opts) {
active = true;
if (!connected) {
connected = true;
// Load-bearing pre-seed (#890): _refetchHistory's failure path
// no longer appends an empty-state, so a FAILED first paint
// shows this placeholder only because it is seeded here,
// before the fetch. Removing it would leave a genuinely blank
// pane when the first /history fails.
pane.showEmptyState();
pane._loadHistoryThenConnect(wsId);
}
@@ -4914,6 +5085,16 @@ function createInteractivePane(root, wsId, opts) {
clearTimeout(recoverTimer);
recoverTimer = null;
}
// The clear_ui failure retry survives everything EXCEPT a token
// bump — and destroy() bumps nothing, so an armed retry would
// fire ~2s post-destroy, pass its guards (latch still set, refs
// null), and replayHistory into the detached pane. Cancel it
// here, terminal-only: disconnectSSE must NOT cancel it —
// transport-only reconnects keep the pending heal intent.
if (pane._staleRetryTimer) {
clearTimeout(pane._staleRetryTimer);
pane._staleRetryTimer = null;
}
pane.disconnectSSE();
// The document-level visibilitychange listener holds a strong ref
// to the pane — leaving it registered would both leak the pane and