diff --git a/scripts/recovery_e2e.py b/scripts/recovery_e2e.py index 87563dc2..8b006e73 100644 --- a/scripts/recovery_e2e.py +++ b/scripts/recovery_e2e.py @@ -133,10 +133,10 @@ Scenario G5 (coord-hidden-retry, #894 r4): the retry's stream-liveness fire guard. A retry armed before close-on-hide must NOT fetch while the transport is down (a seedless render past the frozen ``lastEventId`` double-renders on the show-edge replay): ``history_requests`` UNCHANGED -across the hidden fire window (``hidden0``). A quiet reconnect delivers -no state_change edge, so post-show the latch stays closed (the accepted -liveness-lag residual) until the runner drives an ORGANIC settle with a -plain send; that idle edge fires the TRANSPORT-FREE backstop on the live +across the hidden fire window (``hidden0``). A replay_ok reconnect +carries no synthetic state_change (only fresh/truncated replays do), so +post-show the latch stays closed (the accepted liveness-lag residual) +until the runner drives an ORGANIC settle with a plain send; that idle edge fires the TRANSPORT-FREE backstop on the live stream (exactly ONE new SSE open across show + heal — the user-driven reconnect; the heal adds zero). Stamps ``RECOVERY-READY-COORDHIDDENRETRY-hidden0-heal1``. @@ -958,6 +958,42 @@ COORD_PAGE_HTML = r""" userRows; }; + // G3 — the TRANSPORT-FREE idle-edge backstop. Mirrors + // __verifyStaleBackstop; the storm assertion is sseDelta === 0 (the + // heal opened ZERO EventSource connections — a reconnecting backstop + // bumps events_requests once per reconnect and self-triggers). The + // latch-cleared proof is the reopen POST (posts 2), not a field read: + // coord's latch is closure-private. + window.__verifyCoordStaleBackstop = function ( + healed, + sseDelta, + histDelta, + gatedPosts, + posts, + ) { + const userRows = _coordUserRows(); + const ok = + healed && + sseDelta === 0 && + histDelta === 1 && + gatedPosts === 1 && + posts === 2; + document.title = ok + ? "RECOVERY-READY-COORDSTALEBACKSTOP-heal1-sse0" + : "RECOVERY-FAILED-COORDSTALEBACKSTOP-heal" + + (healed ? 1 : 0) + + "-sse" + + sseDelta + + "-hist" + + histDelta + + "-gated" + + gatedPosts + + "-posts" + + posts + + "-rows" + + userRows; + }; + // G4 — the render-time gate (#894 r4): a turn that STARTS during a // heal's in-flight /history must SURVIVE its resolution (the gate // skips the wipe; pre-gate code detached the live turn into a @@ -1022,42 +1058,6 @@ COORD_PAGE_HTML = r""" userRows; }; - // G3 — the TRANSPORT-FREE idle-edge backstop. Mirrors - // __verifyStaleBackstop; the storm assertion is sseDelta === 0 (the - // heal opened ZERO EventSource connections — a reconnecting backstop - // bumps events_requests once per reconnect and self-triggers). The - // latch-cleared proof is the reopen POST (posts 2), not a field read: - // coord's latch is closure-private. - window.__verifyCoordStaleBackstop = function ( - healed, - sseDelta, - histDelta, - gatedPosts, - posts, - ) { - const userRows = _coordUserRows(); - const ok = - healed && - sseDelta === 0 && - histDelta === 1 && - gatedPosts === 1 && - posts === 2; - document.title = ok - ? "RECOVERY-READY-COORDSTALEBACKSTOP-heal1-sse0" - : "RECOVERY-FAILED-COORDSTALEBACKSTOP-heal" + - (healed ? 1 : 0) + - "-sse" + - sseDelta + - "-hist" + - histDelta + - "-gated" + - gatedPosts + - "-posts" + - posts + - "-rows" + - userRows; - }; - window.__verifyCoordRestart = function () { // Same contract as Scenario B, read off the coordinator's public // chrome + the transport wrapper (idle is asserted SERVER-side by @@ -1092,7 +1092,6 @@ COORD_PAGE_HTML = r""" """ - # --------------------------------------------------------------------------- # Minimal dependency-free CDP client (WebSocket over a raw socket). # --------------------------------------------------------------------------- @@ -2808,17 +2807,19 @@ def run_coord_heal_midturn(chrome: str) -> str: seeds two/three gone. Storm proof: zero SSE opens across the whole episode; exactly TWO /history fetches (the skipped one + the heal). - Detector honesty: ``history_delta == 2`` is the DISCRIMINATING bit. + Detector honesty: ``history_delta == 2`` is the DISCRIMINATING bit, + and the skip it witnesses rides the ``.conv-batch--running`` DOM + marker — turn 5 is in its TOOL phase at resolution (content refs + null), so this scenario is the behavioral detector for the gate's + tool-phase branch (the content-ref branch and the liveness statement + carry structural pins; G5 covers the hidden-retry liveness path). Gate-stripped code renders the held fetch early, which CLEARS the latch, kills the backstop refire, and stamps ``hist1`` (plus downstream posts/rows drift). The ``mid1`` bit alone cannot - discriminate here: the early render repaints all three user rows from - the snapshot (the rewind pre-dates turn 4, so /history already - carries turns 4 and 5's user rows), and turn 5's tool phase has null - content refs — its per-token loss surface is the TOOL rows, which - this scenario does not fingerprint. On gated code ``mid1`` asserts - the stronger continuous-visibility claim (nothing wiped at any - point).""" + discriminate: the early render repaints all three user rows from the + snapshot (the rewind pre-dates turn 4, so /history already carries + turns 4 and 5's user rows). On gated code ``mid1`` asserts the + stronger continuous-visibility claim (nothing wiped at any point).""" from tests._sse_recovery_server import final_text_script, parallel_bash_script paced5 = parallel_bash_script({"g4": "for i in $(seq 1 50); do echo g4-$i; sleep 0.05; done"}) @@ -2939,8 +2940,9 @@ def run_coord_hidden_retry(chrome: str) -> str: (hiddenDelta 0); on show, the reconnect's synthetic idle re-fires the TRANSPORT-FREE backstop, which heals on the live stream. - A quiet reconnect delivers NO state_change edge, so the latch stays - closed after __show until the next ORGANIC settle — exactly the + A replay_ok reconnect (frozen cursor, nothing lost) carries no + synthetic state_change — only fresh/truncated replays do — so the + latch stays closed after __show until the next ORGANIC settle — exactly the accepted-residual ruling (heals ride organic edges; no timer may shortcut the lag). The runner drives that settle with a plain send (sends are never latch-gated), whose turn-settle idle edge fires the @@ -2987,9 +2989,11 @@ def run_coord_hidden_retry(chrome: str) -> str: # (the hidden fetch lands) and hiddenDelta stamps 1. _poll_until(lambda: node.history_requests != hidden_baseline, 3.5, 0.1) hidden_delta = node.history_requests - hidden_baseline - # Show: the reconnect presents the frozen cursor. A quiet - # reconnect delivers NO state_change edge (the latch-closed lag is - # the accepted residual), so wait for the reconnect itself, then + # Show: the reconnect presents the frozen cursor and replays + # replay_ok (nothing lost), which carries NO synthetic + # state_change (only fresh/truncated replays do — the + # latch-closed lag is the accepted residual). Wait for the + # reconnect itself, then # drive an ORGANIC settle with a plain send — its idle edge fires # the TRANSPORT-FREE backstop on the live stream and the heal # renders the rewound (ONE) + sent (a second) transcript. diff --git a/tests/test_coordinator_page.py b/tests/test_coordinator_page.py index 1c904afc..2214c79e 100644 --- a/tests/test_coordinator_page.py +++ b/tests/test_coordinator_page.py @@ -600,6 +600,13 @@ def test_coordinator_history_stale_latch_contract(): 6. destroy() cancels staleRetryTimer (terminal-only) while closeStreamTransport does NOT (transport redials keep the heal intent alive). + 7. The render-time gate (r5-derived): refetchHistory re-checks + DOM-live-state (content refs / the --running batch marker / the optimistic-send + flavor of busy — never plain busy, the r5 critical), dispatch + currency (seq), and seedless stream-OPENness AFTER the await and + BEFORE the wipe — and the latch-clear sits below every skip + point, so a skipped render can never reopen the affordances over + a stale DOM. """ from pathlib import Path @@ -704,21 +711,26 @@ def test_coordinator_history_stale_latch_contract(): "(mirrors interactive's !_replayQueue pin)." ) assert "visHandler" in retry_fire, ( - "the retry's fire guard must carry the teardown sentinel — it is " - "the SOLE protection for the coordCloseSession path, which nulls " - "visHandler but does not cancel the timer." + "the retry's fire guard must carry the teardown sentinel for the " + "coordCloseSession path, which nulls visHandler but does not " + "cancel the timer (evtSource is also null there post-suspend, " + "but the sentinel is the durable term)." ) assert "!currentAssistantEl" in retry_fire and "!currentReasoningEl" in retry_fire, ( - "the retry's ref guards are LOAD-BEARING (coord's refetchHistory " - "does not reset streaming refs) — must not be deleted to match " - "interactive's quiesce-protected shape." + "the retry's ref guards skip a pointless fetch whose payload the " + "render-time gate would discard (the chokepoint carries the " + "correctness; these are the efficiency layer — keep them)." ) - assert "visHandler &&\n evtSource\n ) {" in body, ( - "the retry's fire guard must require a live stream (evtSource): " - "close-on-hide keeps this timer armed by design, and a seedless " - "heal on a dead stream renders past the frozen cursor (replay " - "double-render on the show edge). Exact-tail pin so a comment " - "mention cannot satisfy it; re-anchor if the guard reflows." + assert ( + "visHandler &&\n evtSource &&\n" + " evtSource.readyState === EventSource.OPEN\n" + " ) {" in body + ), ( + "the retry's fire guard must require an OPEN stream — not handle " + "existence: CONNECTING keeps the handle with a frozen cursor and " + "a pending replay, and a seedless heal then double-renders when " + "the replay lands. Exact-tail pin so a comment mention cannot " + "satisfy it; re-anchor if the guard reflows." ) assert "if (staleRetryTimer) clearTimeout(staleRetryTimer);" in body, ( "re-arming on a newer clear_ui must cancel the pending timer " @@ -748,24 +760,6 @@ def test_coordinator_history_stale_latch_contract(): "the very window the yield guards protect." ) - # 7. Render-time gate: the post-await re-checks must sit between the - # failure guard and the wipe. The await is a real window — a turn - # can START mid-fetch (refs re-check; a wipe then would detach the - # live bubble and lose the turn into the dangling ref), and a - # seedless render must not paint past a frozen cursor when the - # stream died mid-fetch (hide/suspend) or the show-edge replay - # double-renders. Caller-side guards cannot see across the await; - # only this chokepoint can. - hist_guard = body.index("if (!hist) return;", fetch_start) - ref_gate = body.index("if (currentAssistantEl || currentReasoningEl) return;", fetch_start) - live_gate = body.index("if (!seedCursor && (busy || !evtSource)) return;", fetch_start) - wipe = body.index("messagesEl.replaceChildren();", fetch_start) - assert hist_guard < ref_gate < wipe and hist_guard < live_gate < wipe, ( - "refetchHistory must re-check the live-turn refs AND seedless " - "stream-liveness AFTER the await and BEFORE the wipe — the " - "render-time correctness carrier for every caller." - ) - # 6. Teardown: terminal cancel in destroy(); NOT in closeStreamTransport. destroy_slice = body[body.index("function destroy()") :] destroy_slice = destroy_slice[: destroy_slice.index("\n function ")] @@ -780,6 +774,72 @@ def test_coordinator_history_stale_latch_contract(): "redials keep the pending heal intent (terminal-only cancel)." ) + # 7. Render-time gate (r5-derived, seedless-scoped after the + # coord-restart family find): the post-await re-checks sit between + # the failure guard and the wipe, so the latch-clear (below the + # wipe) sits below every skip point by construction. Universal + # terms: dispatch currency (seq) and the content refs (skipping + # always beats stranding a ref; seeded callers null theirs before + # fetching, so it never blocks them). SEEDLESS-only terms — + # keyed on the seedCursor ARG: the .conv-batch--running marker + # (on a live stream it means results are streaming into those + # rows; on the SEEDED resync it can be a dead turn's residue and + # the render IS the recovery — a universal term wedged + # coord-restart outright), the optimistic-send busySource flavor, + # and stream-OPENness (CONNECTING keeps the handle with a frozen + # cursor and a pending replay). Plain ``busy`` must not appear: + # it means a turn is EXECUTING, not that this DOM holds live + # state (the r5 critical). + hist_guard = body.index("if (!hist) return;", fetch_start) + wipe = body.index("messagesEl.replaceChildren();", fetch_start) + latch_clear = body.index("historyStale = false;", fetch_start) + assert hist_guard < wipe < latch_clear, ( + "the wipe must sit between the failure guard and the latch-clear " + "— every gate skip above the wipe then leaves the latch set." + ) + gate_code = "\n".join( + line for line in body[hist_guard:wipe].splitlines() if not line.lstrip().startswith("//") + ) + for term, why in ( + ("if (seq !== refetchSeq) return;", "dispatch-currency (seq)"), + ( + "if (currentAssistantEl || currentReasoningEl) return;", + "universal content-ref", + ), + ("!seedCursor &&", "seedless scoping"), + ('busySource === "optimistic"', "optimistic-row"), + ('messagesEl.querySelector(".conv-batch--running")', "executing-batch"), + ("evtSource.readyState !== EventSource.OPEN", "stream-OPENness"), + ): + assert term in gate_code, ( + f"the render-time gate must carry the {why} term (in CODE, not a comment)." + ) + seedless_at = gate_code.index("!seedCursor &&") + assert gate_code.index("if (seq !== refetchSeq) return;") < seedless_at, ( + "seq currency must be checked before the seedless group." + ) + assert gate_code.index("if (currentAssistantEl || currentReasoningEl) return;") < seedless_at, ( + "the universal ref check must precede the seedless group." + ) + for term in ( + 'busySource === "optimistic"', + ".conv-batch--running", + "evtSource.readyState !== EventSource.OPEN", + ): + assert seedless_at < gate_code.index(term), ( + f"{term} must live INSIDE the !seedCursor group — the seeded " + "resync renders over a dead --running batch / an optimistic " + "row deliberately (blocking it wedges the coord-restart " + "recovery)." + ) + assert not re.search(r"\bbusy\b(?!Source)", gate_code), ( + "plain busy must not gate the render — busy means a turn is " + "EXECUTING, not that this DOM holds live state (the r5 critical: " + "_editAndResend flips busy before its POST and /rewind emits no " + "state_change, so a busy term skips the truncation render the " + "rewind exists to produce)." + ) + def test_coordinator_js_early_paints_pending_tool_calls(): """The coord chat frontend must render a committed tool call on diff --git a/turnstone/console/static/coordinator/coordinator.js b/turnstone/console/static/coordinator/coordinator.js index d26084b2..c8ea87be 100644 --- a/turnstone/console/static/coordinator/coordinator.js +++ b/turnstone/console/static/coordinator/coordinator.js @@ -669,6 +669,14 @@ function createCoordinatorPane(root, wsId, opts) { // ``if (!hist)`` is synchronous, so no timer or SSE handler can observe // the counter mid-render. let refetchesInFlight = 0; + // Monotonic refetchHistory dispatch stamp. Each call captures + // ``++refetchSeq`` before its await; the render-time gate discards any + // payload whose stamp is no longer current, so overlapping fetches + // (reachable via another actor's clear_ui racing a heal) resolve + // last-DISPATCH-wins deterministically instead of last-RESPONSE-wins — + // an older snapshot landing late can neither double-render nor clear + // the staleness latch over a newer truth. + let refetchSeq = 0; // The cursor position a replay_truncated envelope was received AT — i.e. // "a gap of lost events exists BELOW this cursor". Keep-oldest: set only // when null (repeated envelopes for the same unrepaired gap must not @@ -3532,31 +3540,41 @@ function createCoordinatorPane(root, wsId, opts) { // with /history down that is a zero-backoff disconnect/refetch/ // reconnect storm against a recovering node (interactive.js's // round-5 critical), self-sustaining until /history recovers. - // A plain REST refetch emits zero SSE events, so the trigger - // edge is only ever organic (turn-settle driven; the server - // pushes no state_change heartbeats) and the loop is - // structurally impossible. Stream death has its own owners - // (EventSource native auto-reconnect, the truncated resync in - // the branch above — whose heal legitimately IS a reconnect: - // its stream is dead, this arm's is alive) and the else-if - // keeps the truncated branch's reload from doubling up (its - // render clears this latch too). + // A plain REST refetch emits zero SSE events, so this heal + // never CAUSES its own trigger and the self-loop is + // structurally impossible. Precision on the trigger edges + // (r5): turn settles are the main one, but fresh/truncated + // SSE replays also carry a synthetic state_change (replay_ok + // does not) — so a latched pane pays one refetch per + // reconnect too. That edge is bounded by the reconnect + // machinery's own jitter/backoff, is user/transport-driven + // rather than heal-driven, and the restart-herd /history load + // is bounded server-side by #884's single-flight — ruled + // acceptable; do NOT bolt a client-side limiter onto this arm + // (and never a transport-touching one). Stream death has its + // own owners (EventSource native auto-reconnect, the + // truncated resync in the branch above — whose heal + // legitimately IS a reconnect: its stream is dead, this arm's + // is alive) and the else-if keeps the truncated branch's + // reload from doubling up (its render clears this latch too). // // The ref guards are a DELIBERATE divergence from the // interactive template (which omits them on its backstop): // interactive's replayHistory resets the streaming refs on // every render, coord's refetchHistory does not — and this arm - // also serves state_change:error edges, where no stream_end ran - // finishAssistantStream and a live-bubble ref can survive. - // Firing then would replaceChildren the bubble out from under - // the dangling ref; skipping defers the heal to the next - // organic settle (that turn's stream_end nulls the refs). - // Correctness never rides this arm — the busy||historyStale - // gate carries it; heals are liveness only. Accepted liveness - // lag (ruled): if /history recovers while the pane sits idle - // untouched, rewind/edit stay closed until the next organic - // settle — strictly safer than the storm. Do not add ANY - // transport-touching timer to shorten it. + // also serves state_change:error edges, where no stream_end + // ran finishAssistantStream and a live-bubble ref can + // survive. Post-r5 the render-time gate in refetchHistory + // carries the wipe-safety correctness; these fire-time guards + // are the efficiency layer — they skip a fetch whose payload + // the gate would discard (a latched pane draining a K-deep + // send queue still pays one discarded fetch per settle edge + // it loses the race on; ruled acceptable — per-pane cost + // rides organic edges only and #884 coalesces the herd). + // Accepted liveness lag (ruled): if /history recovers while + // the pane sits idle untouched, rewind/edit stay closed until + // the next organic settle — strictly safer than the storm. + // Do not add ANY transport-touching timer to shorten it. // // Fire-and-forget, seedless: the stream is alive so lastEventId // must not rewind; no composer state rides this heal (that is @@ -3678,11 +3696,12 @@ function createCoordinatorPane(root, wsId, opts) { // in flight (!refetchesInFlight — yield instead of stomping it // with a same-snapshot double render), mid-turn (!busy — the // idle-edge backstop owns the busy case, e.g. the edit-resend - // dispatched below), a live bubble (the ref guards are - // LOAD-BEARING: refetchHistory does not reset streaming refs, - // so a wipe would strand a dangling ref), or torn down - // (visHandler — destroy() also cancels this timer outright, - // but coordCloseSession only nulls visHandler). + // dispatched below), a live bubble (the ref guards skip a + // fetch whose payload refetchHistory's render-time gate would + // discard — the gate carries the wipe-safety correctness, + // these are the efficiency layer), or torn down (visHandler — + // destroy() also cancels this timer outright, but + // coordCloseSession only nulls visHandler). // // The ARM is visHandler-gated too: this .then can settle AFTER // a teardown (destroy/close-session during the in-flight @@ -3703,17 +3722,20 @@ function createCoordinatorPane(root, wsId, opts) { !currentAssistantEl && !currentReasoningEl && visHandler && - evtSource + evtSource && + evtSource.readyState === EventSource.OPEN ) { - // evtSource: a seedless heal must not render past a - // frozen cursor (close-on-hide keeps this timer armed by - // design, so the fire can land with the transport down) - // — skip; the latch survives and the show-edge - // reconnect's synthetic idle hands the heal to the - // backstop. The backstop itself needs no such term: it - // runs inside SSE dispatch, so its stream is live by + // Stream must be OPEN, not merely present: close-on-hide + // keeps this timer armed by design (the fire can land + // with the transport down), and a CONNECTING source has + // a frozen cursor with a pending replay — a seedless + // fetch then would render past it (double-render when + // the replay lands). Skip instead; the latch survives + // and the next organic settle re-fires the backstop. + // The backstop itself needs no such term: it runs + // inside SSE dispatch, so its stream is live by // construction. refetchHistory's render-time gate - // re-checks both invariants across the await window. + // re-checks every invariant across the await window. // Fire-and-forget, seedless (live stream — lastEventId // must not rewind); a render throw stays loud, as on the // backstop. @@ -5751,7 +5773,9 @@ function createCoordinatorPane(root, wsId, opts) { // in-flight fetch (see refetchesInFlight's decl). Only the fetch // needs bracketing: the render below runs synchronously after the // await, so no timer or SSE handler can observe the counter - // mid-render. + // mid-render. The seq stamp makes overlapping dispatches resolve + // last-dispatch-wins at the render-time gate. + const seq = ++refetchSeq; refetchesInFlight++; try { hist = await getJSON( @@ -5775,41 +5799,77 @@ function createCoordinatorPane(root, wsId, opts) { // refetch.) Success ordering is unchanged: the wipe always ran // after the await, never as immediate feedback. if (!hist) return; - // RENDER-TIME gate (#894 r4): the await above is a real window — pane - // state can change between a caller's fire-time checks and this - // render, and only THIS site can see across it (chokepoint, not - // per-caller guards). Two invariants must hold at the wipe itself: + // RENDER-TIME gate (#894 r4, re-derived r5): the await above is a + // real window — pane state can change between a caller's fire-time + // checks and this render, and only THIS site can see across it + // (chokepoint, not per-caller guards). The gate reads DOM-LIVE-STATE + // signals, never plain ``busy`` — that distinction is the r5 lesson: + // ``busy`` means "a turn is executing", not "this DOM holds live turn + // state", and the two diverge exactly where it hurt. _editAndResend + // flips busy BEFORE its POST and /rewind emits only clear_ui (no + // state_change), so a busy term skipped the truncation render the + // rewind exists to produce and appended the resent bubble onto the + // PRE-rewind transcript; /retry's regenerated turn likewise raced its + // own clear_ui refetch. A skipped render must only ever mean "the + // wipe would destroy live DOM" or "the paint would outrun the + // cursor": // - // 1. No live turn mid-stream. A turn can START during the fetch (the - // server drains queued sends at exactly the idle edges the - // backstop rides; another operator on a shared coordinator can - // send any time; coordSend paints an optimistic user row). This - // render does not reset the streaming refs, so replaceChildren - // would detach the live bubble — every remaining token renders - // into the dangling ref (invisible), and the optimistic user row - // is destroyed with nothing to repaint it. Skip instead: if the - // staleness latch is set it stays set (clear is below), the gate - // stays closed, and the turn's own settle re-fires the backstop. - // 2. Seedless renders need an IDLE pane on a LIVE stream. busy: the - // ref check above only sees the CONTENT phase — a turn in its tool - // phase has null content refs but live tool rows, and the wipe + - // toolRows.clear() below would orphan them mid-stream identically. - // evtSource: a seedless render must not advance the DOM past a - // frozen lastEventId (hide/suspend can land mid-fetch) — the - // show-edge reconnect replays from the frozen cursor and every - // turn this render already painted would render twice. Seeded - // callers (init, loadHistoryThenReconnect) own their reconnect - // flow — they adopt hist.cursor below, and the truncated resync - // legitimately rebuilds MID-turn — so both requirements key on the - // seedCursor ARG, not caller identity. On skip the latch - // survives; the next organic settle (or the show-edge reconnect's - // synthetic idle) re-fires the backstop. + // - seq (universal): a newer dispatch supersedes this payload + // (last-DISPATCH-wins; see refetchSeq's decl). + // - content refs (universal): a live bubble ref means the stream is + // mid-CONTENT — wiping detaches the node every remaining token + // renders into. Universal because skipping always beats + // stranding a ref, and the seeded callers null their refs before + // fetching anyway (loadHistoryThenReconnect explicitly; init + // pre-connect), so this never blocks them. + // - SEEDLESS-only, because the seeded flows deliberately render + // over/instead-of these states (keying on the seedCursor ARG, not + // caller identity): + // . .conv-batch--running — an executing tool batch + // (_setBatchRunning / _unsetBatchRunningIfAllResults strip the + // class only when every row has its result). NOT activeBatch — + // that is the pending-APPROVAL tracker (set only for + // opts.pending batches; the r5 re-derivation initially made + // exactly that mistake). On a seedless caller the stream is + // live, so the class means results are still streaming into + // those rows — wiping orphans them. On the SEEDED resync the + // marker can be a DEAD turn's residue (node died mid-batch; no + // result will ever strip the class) and the render IS the + // recovery — the replay adopts hist.cursor and rebuilds any + // genuinely live turn. Blocking that render wedged the + // coord-restart recovery outright (r5 family-run find). + // . busySource === "optimistic" — coordSend's pre-POST flip, the + // one busy flavor that marks un-committed DOM (an optimistic + // row the snapshot may not carry; see setBusy's contract). The + // seeded resync renders through it: dead-stream recovery + // outranks a transient row, matching pre-#894 behavior. The + // clear_ui .then's edit-resend paints under plain "server" busy + // instead; its narrower wipe window (a second actor's clear_ui + // inside the resend's commit gap) is accepted — the resent + // message re-appears at its turn's settle heal, and pre-#894 + // behavior there was an over-rewind. + // . stream-OPENness — CONNECTING is NOT live (native + // auto-reconnect keeps the handle with a frozen lastEventId and + // a pending replay; painting past the cursor double-renders + // when that replay lands; hide/suspend null the handle + // outright). The seeded flows own their reconnect and adopt + // hist.cursor below. // - // The callers' fire-time ref/stream guards remain as the efficiency - // layer (skip the pointless fetch); THIS gate is the correctness - // carrier. + // On every skip the staleness latch survives (clear is below), the + // affordance gate stays closed, and the next organic settle re-fires + // the backstop. The callers' fire-time guards remain as the + // efficiency layer (skip the pointless fetch); THIS gate is the + // correctness carrier. + if (seq !== refetchSeq) return; if (currentAssistantEl || currentReasoningEl) return; - if (!seedCursor && (busy || !evtSource)) return; + if ( + !seedCursor && + (busySource === "optimistic" || + messagesEl.querySelector(".conv-batch--running") || + !evtSource || + evtSource.readyState !== EventSource.OPEN) + ) + return; messagesEl.replaceChildren(); // A full committed-history render repairs any recorded truncation gap — // whether this render came from the truncated resync itself or from an