From 99fa3def1bced15e1befdda8399879868a69d95d Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Tue, 21 Jul 2026 22:19:19 -0700 Subject: [PATCH] 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. --- tests/test_interactive_pane_js.py | 168 +++++++++++- .../console/static/coordinator/coordinator.js | 9 + turnstone/shared_static/interactive.js | 243 +++++++++++++++--- 3 files changed, 382 insertions(+), 38 deletions(-) diff --git a/tests/test_interactive_pane_js.py b/tests/test_interactive_pane_js.py index 21ff3b6d..4acac280 100644 --- a/tests/test_interactive_pane_js.py +++ b/tests/test_interactive_pane_js.py @@ -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)" ) diff --git a/turnstone/console/static/coordinator/coordinator.js b/turnstone/console/static/coordinator/coordinator.js index f161248b..240ea259 100644 --- a/turnstone/console/static/coordinator/coordinator.js +++ b/turnstone/console/static/coordinator/coordinator.js @@ -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); diff --git a/turnstone/shared_static/interactive.js b/turnstone/shared_static/interactive.js index 3b5780ec..fc166020 100644 --- a/turnstone/shared_static/interactive.js +++ b/turnstone/shared_static/interactive.js @@ -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