mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
fix(#894): re-derive the render gate on DOM-live signals; drop the busy conflation
Review round 5 step-back (fix-era critical): the r4 gate's busy term conflated 'a turn is executing' with 'this DOM holds live turn state'. _editAndResend flips busy BEFORE its POST and /rewind emits only clear_ui (no state_change), so the 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. The seam was re-derived once against the caller x state matrix; the gate reads DOM-live signals only, split by scope: - UNIVERSAL: dispatch seq (refetchSeq — overlapping fetches resolve last-DISPATCH-wins; an older snapshot landing late can neither double-render nor clear the latch over newer truth) and the content refs (skipping always beats stranding a ref; seeded callers null theirs before fetching, so it never blocks them). - SEEDLESS-ONLY (keyed on the seedCursor arg): the .conv-batch--running DOM marker for the tool phase (NOT activeBatch — that is the pending-APPROVAL tracker, set only for opts.pending batches; ruled at-site), coordSend's busySource === 'optimistic' flavor (the one busy that marks un-committed DOM), and stream-OPENness (CONNECTING keeps the handle with a frozen cursor and a pending replay; handle-existence was not liveness — also applied to the retry's fire guard). Seedless-only because the SEEDED resync renders over these deliberately: after a node dies mid-batch the --running class is dead residue no result will ever strip, and the resync's render IS the recovery — a universal term wedged the coord-restart scenario outright (family-run find; the r5 finders missed the seeded-path interaction). Backstop comment corrected (r5): fresh/truncated SSE replays DO carry a synthetic state_change (replay_ok does not) — a latched pane pays one refetch per reconnect, bounded by reconnect jitter/backoff and #884's server single-flight; heal-caused triggers remain structurally impossible. Caller fire-time ref guards demoted to the efficiency layer at-site. Contract test re-pins the gate: term presence in comment-stripped CODE, universal-vs-seedless placement, wipe between failure guard and latch-clear, no plain busy. The edit-resend commit gap under a second actor's clear_ui is accepted at-site (re-appears at settle heal). G4's honesty note names the tool-phase branch it behaviorally detects; G5 wording replay_ok-precise. Full coord family (C + G1-G5) green; 136 pins green; 5 static mutants + the G4 behavioral control caught.
This commit is contained in:
+58
-54
@@ -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"""<!doctype html>
|
||||
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"""<!doctype html>
|
||||
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"""<!doctype html>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user