From d7f481fa8978e78b91ff5a0e8cbdba5e4891fd49 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 10 Jun 2026 07:09:48 +0700 Subject: [PATCH] =?UTF-8?q?fix(reliability):=20recover=20a=20stalled-mid-s?= =?UTF-8?q?tream=20DM=20beat=20=E2=80=94=20never=20lock=20the=20player=20o?= =?UTF-8?q?ut=20(#623=20deeper=20layer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lone v1.0.4-rc2 RRI holdout @c92a393: the newbie persona hit a DM beat that streamed partial prose via /events ("You give the sergeant your own name… The charcoal touches the paper… That's the arithmetic o—") then FROZE mid-generation, and the action bar stayed disabled for 15+ min with no recovery path → gave up. Root cause (client, primary): viewer/openworlds/app.jsx `notePendingProgress` re-arms the FULL position recovery window (180s/240s) AND clears `stuck` on EVERY streamed paragraph. A multi-paragraph trickle that then freezes keeps pushing the stuck deadline forward, so `stuck` never fires; recovery is deferred to the 12-min PENDING_BACKSTOP_MS, which clears pending to NULL (a plain re-enabled bar, no "Try again", the partial narration stranded) — the ~12-15-min lockout. The #623/#743 heartbeat/live-progress stream is exactly what feeds notePendingProgress, so the perceived-latency fix masked the genuine-stall detector. Root cause (backend, compounding): qa/ui_playtest.sh's dm_turn (the GUI-sweep driver) ran `claude -p` with NO timeout/retry/fallback — unlike scripts/play.sh — so a frozen process hung forever and the turn never resolved on /chat, leaving the client stall ceiling as the only recovery. Fix (additive; engine stays sole writer; #720/#623/#735 untouched): - Client: a HARD stuck-backstop (PENDING_STUCK_BACKSTOP_MS, 5 min) armed once in armPending, anchored to submit, NOT reset by progress, flipping `stuck=true` (the recoverable "Try again" affordance — NOT a null clear). Ordering: position recovery (resettable, preserves #399/#623 live-progress) < stuck-backstop (hard, recoverable) < 12-min null-backstop. notePendingProgress is otherwise UNCHANGED, so a healthy streaming turn is never falsely stuck. (Rejected a per-progress 90s clamp — it would re-introduce the #399 false-stuck on a heartbeat-only turn.) - Backend: ui_playtest.sh dm_turn now wraps `claude -p` in `timeout` (shared clawdnd_dm_timeout) + routes through clawdnd_dm_narration_or_fallback so a killed beat still resolves on /chat. Bash 3.2-clean. Tests (real red→green guards): - viewer/tests/test_recovery_timing.py::MidStreamStallTests (4): trickle-then-freeze recovers to STUCK (not null) within ~5 min; the ceiling is not reset by progress; a healthy resolving turn never trips it; the pre-stream slow open keeps its window. - servers/engine/tests/test_dm_session_remint.py (3): ui_playtest dm_turn wraps the beat in timeout, uses the narration fallback, and clawdnd_dm_timeout is 3.2-clean. Verify: recovery-timing 32 passed; remint 24 passed; viewer suite 483 passed/6 skipped; qa/fast_gate.sh 188 passed; bash -n clean (3.2). --- qa/ui_playtest.sh | 24 +++- .../engine/tests/test_dm_session_remint.py | 49 +++++++ viewer/openworlds/app.jsx | 45 +++++- viewer/tests/test_recovery_timing.py | 129 ++++++++++++++++++ 4 files changed, 242 insertions(+), 5 deletions(-) diff --git a/qa/ui_playtest.sh b/qa/ui_playtest.sh index 7b6e3bbe..1d9206a1 100755 --- a/qa/ui_playtest.sh +++ b/qa/ui_playtest.sh @@ -136,15 +136,33 @@ echo "[uipt] viewer ready." DSID="$(python3 -c 'import uuid;print(uuid.uuid4())')" DM_BRIEF="$(cat "$ROOT/qa/play_dm_duo.txt")" chatlog() { python3 -c 'import json,sys;open(sys.argv[1],"a").write(json.dumps({"role":sys.argv[2],"text":sys.argv[3]})+"\n")' "$CHAT" "$1" "$2"; } +# #745 (the newbie mid-stream-stall give-up): the GUI-sweep DM driver MUST bound every beat exactly +# like scripts/play.sh's dm_turn — previously this helper ran `claude -p` with NO `timeout`, NO retry, +# and NO fallback, so a DM turn that streamed partial prose via /events and then FROZE mid-generation +# hung FOREVER: dm_turn never returned → `chatlog dm` (the turn-END /chat line) never fired → the turn +# never RESOLVED on the client, leaving the player on the (now-fixed-but-slower) client stall path with +# no backend recovery at all. Here we (1) wall-clock the beat with `timeout` (tiered off the cold-open +# `first` signal via the shared clawdnd_dm_timeout; a frozen process is KILLED at the deadline so the +# turn returns), and (2) if the killed/failed beat left empty result text, stitch the engine-logged +# narration tail as a fallback reply (clawdnd_dm_narration_or_fallback) so `chatlog dm` always writes a +# real turn-END line → the client's pending clears + the bar re-enables. CLAWDND_DM_MODEL lets the +# timeout helper pick the opus cold-open tier. Bash 3.2-safe (timeout(1) from coreutils; ${arr[@]+…}). +CLAWDND_DM_MODEL="$DM_MODEL" dm_turn() { - local first="$1" msg="$2" out resume=() + local first="$1" msg="$2" out resume=() beat_timeout rc [ "$first" = "0" ] && resume=(--resume "$DSID") || resume=(--session-id "$DSID") + beat_timeout="$(clawdnd_dm_timeout "$first")" out="$RUNDIR/dm/turn.$(date +%s%N).jsonl" - claude -p "$msg" "${resume[@]}" --plugin-dir "$ROOT" --mcp-config "$DM_CFG" --strict-mcp-config \ + timeout "$beat_timeout" \ + claude -p "$msg" "${resume[@]}" --plugin-dir "$ROOT" --mcp-config "$DM_CFG" --strict-mcp-config \ --model "$DM_MODEL" --permission-mode bypassPermissions --max-budget-usd "$DM_BUDGET" \ --output-format stream-json --verbose > "$out" 2>> "$RUNDIR/dm/dm.err" + rc=$? + [ "$rc" -ne 0 ] && echo "[uipt] DM turn rc=$rc (timeout=${beat_timeout}s) — relying on engine-logged narration fallback" >&2 cat "$out" >> "$COMBINED" - jq -rs 'map(select(.type=="result"))[-1].result // ""' "$out" 2>/dev/null + # If the beat was killed/failed with no final result text, recover the engine-logged narration tail so + # the turn STILL resolves on /chat (never an indefinite hang). A healthy beat returns its result verbatim. + clawdnd_dm_narration_or_fallback "$(jq -rs 'map(select(.type=="result"))[-1].result // ""' "$out" 2>/dev/null)" "$STATE_DIR" } # --- DM opens the scene so a LIVE, playable game exists (the launcher's Chronicles diff --git a/servers/engine/tests/test_dm_session_remint.py b/servers/engine/tests/test_dm_session_remint.py index a909b5a5..b9be4f07 100644 --- a/servers/engine/tests/test_dm_session_remint.py +++ b/servers/engine/tests/test_dm_session_remint.py @@ -362,3 +362,52 @@ def test_play_sh_emits_the_heartbeat_before_each_dm_turn(): assert play.count("clawdnd_emit_progress_heartbeat") >= 2, ( "play.sh must emit the heartbeat on the cold-open AND per-beat paths" ) + + +# --- #745: the GUI-sweep DM driver must BOUND every beat (the newbie mid-stream-stall give-up) ---- +# The lone v1.0.4-rc2 RRI holdout @c92a393 (newbie) hit a DM beat that STREAMED partial prose and then +# FROZE mid-generation. qa/ui_playtest.sh's dm_turn ran `claude -p` with NO `timeout`, NO retry, and NO +# fallback — unlike scripts/play.sh's dm_turn — so the frozen process hung forever: dm_turn never +# returned -> `chatlog dm` (the turn-END /chat line) never fired -> the turn never RESOLVED on the +# client, so the backend offered NO recovery (the client-side stall ceiling, #745 app.jsx, then had to +# carry the whole burden). These guard that the GUI driver now wall-clocks the beat and falls back to +# the engine-logged narration tail so a stalled beat always resolves on /chat. + +def test_ui_playtest_dm_turn_wraps_claude_in_a_bounded_timeout(): + """The GUI-sweep dm_turn must wrap `claude -p` in `timeout` (tiered via the shared clawdnd_dm_timeout) + so a frozen beat is KILLED at a deadline and dm_turn returns — never an indefinite hang.""" + src = _src("qa/ui_playtest.sh") + # the shared, tiered deadline helper is resolved… + assert 'clawdnd_dm_timeout "$first"' in src, "ui_playtest.sh must resolve the per-beat deadline via the shared helper" + # …and the claude invocation is wrapped in `timeout "$beat_timeout"` (the line continuation puts the + # `claude -p` on the following line, so assert both tokens are present near each other). + assert 'timeout "$beat_timeout"' in src, "ui_playtest.sh dm_turn must wall-clock `claude -p` with `timeout`" + # a bare, unbounded `claude -p \\` (no timeout on the same logical line) must NOT remain. + assert "\n claude -p " not in src, "ui_playtest.sh must not invoke `claude -p` unbounded (no timeout wrapper)" + + +def test_ui_playtest_dm_turn_falls_back_to_engine_narration_on_a_stalled_beat(): + """A killed/empty beat must still RESOLVE on /chat: dm_turn routes its result through the shared + clawdnd_dm_narration_or_fallback so the engine-logged narration tail becomes the turn-END line.""" + src = _src("qa/ui_playtest.sh") + assert "clawdnd_dm_narration_or_fallback" in src, ( + "ui_playtest.sh dm_turn must recover via the shared narration fallback so a stalled beat still resolves" + ) + + +def test_ui_playtest_dm_timeout_is_bash32_clean_and_sourced(): + """The driver sources the shared lib and clawdnd_dm_timeout resolves a positive integer under the + macOS system bash 3.2 — proving the new wiring is 3.2-clean and actually reachable from this script.""" + lib = ROOT / "qa" / "lib_beat_driver.sh" + script = ( + f'set -u; . "{lib}"\n' + # cold-open tier (first=1) and continuing tier (first=0) must both yield a positive integer. + 'CLAWDND_DM_MODEL=opus; co="$(clawdnd_dm_timeout 1)"; bt="$(clawdnd_dm_timeout 0)"\n' + 'echo "co=$co bt=$bt"\n' + 'case "$co" in (*[!0-9]*|"") echo BAD_CO; exit 1;; esac\n' + 'case "$bt" in (*[!0-9]*|"") echo BAD_BT; exit 1;; esac\n' + '[ "$co" -gt 0 ] && [ "$bt" -gt 0 ] && echo OK\n' + ) + r = _bash(script) + assert r.returncode == 0, (r.stdout, r.stderr) + assert "OK" in r.stdout, (r.stdout, r.stderr) diff --git a/viewer/openworlds/app.jsx b/viewer/openworlds/app.jsx index 41e32afe..834ced18 100644 --- a/viewer/openworlds/app.jsx +++ b/viewer/openworlds/app.jsx @@ -228,6 +228,22 @@ window.neutralizeMarkup = window.neutralizeMarkup || function neutralizeMarkup(r const PENDING_RECOVERY_MS = 180 * 1000; // #399: later-beat stall window (worst-case DM turns run ~90–120s; was 90s/#342). const PENDING_RECOVERY_FIRST_MS = 4 * 60 * 1000; // #348: first-beat (Act-opening) window — fits the multi-minute cold open. const PENDING_BACKSTOP_MS = 12 * 60 * 1000; // …with the original hard backstop as a final net. +// #745 (the newbie mid-stream-stall give-up): a HARD stuck ceiling from submit that flags `stuck` (the +// recoverable "Try again" affordance) and — unlike the per-progress recovery timer — is NOT reset by +// streamed progress. Root cause it fixes: notePendingProgress re-arms the FULL recovery window (180s/240s) +// AND clears `stuck` on EVERY streamed /events paragraph. So a beat that streams several partial paragraphs +// ("You give the sergeant your own name… The charcoal touches the paper… That's the arithmetic o—") and +// then FREEZES mid-generation keeps pushing the stuck deadline forward with each partial; with partials +// (typeof window.sanitizeNarration === "function" ? window.sanitizeNarration(txt) : (txt || "")); @@ -378,6 +395,10 @@ function useLiveSession(state) { const clearTimers = React.useCallback(() => { clearRecoveryTimer(); if (backstopTimer.current) { window.clearTimeout(backstopTimer.current); backstopTimer.current = null; } + // #745: the hard stuck-backstop is disarmed alongside the others — a real resolution (clearPending → + // clearTimers) or a retry re-arm (armPending → clearTimers) must cancel it so it can't fire on a turn + // that already resolved/re-armed. + if (stuckBackstopTimer.current) { window.clearTimeout(stuckBackstopTimer.current); stuckBackstopTimer.current = null; } }, [clearRecoveryTimer]); // #393: a ref mirror of `pending` so a poll callback (whose effect deps deliberately EXCLUDE @@ -428,6 +449,16 @@ function useLiveSession(state) { recoveryTimer.current = window.setTimeout(() => { setPendingState((p) => (p ? { ...p, stuck: true } : p)); }, recoveryMs); + // #745: a HARD stuck ceiling from submit. Unlike recoveryTimer (re-armed by every streamed paragraph + // in notePendingProgress), this is armed ONCE here and progress does NOT reset it — so a beat that + // streams a partial trickle and then FREEZES mid-stream still surfaces the recoverable `stuck` "Try + // again" affordance within a bounded time, instead of the trickle deferring recovery to the 12-min + // null-backstop (which strands the partial narration behind a plain re-enabled bar). It is generous + // enough that a healthy turn always RESOLVES (clearPending → clearTimers) first, so it never trips a + // slow-but-alive beat. Fires only when nothing has resolved by its deadline. + stuckBackstopTimer.current = window.setTimeout(() => { + setPendingState((p) => (p ? { ...p, stuck: true } : p)); + }, PENDING_STUCK_BACKSTOP_MS); backstopTimer.current = window.setTimeout(() => setPendingState(null), PENDING_BACKSTOP_MS); }, [clearTimers, setPendingState]); @@ -450,6 +481,11 @@ function useLiveSession(state) { // long-but-healthy streaming turn keeps resetting 'stuck' (it's plainly alive) while the // absolute cap still fires at its original deadline. clearRecoveryTimer(); + // The per-progress recovery timer re-arms to the FULL position window on each streamed paragraph — + // a long-but-healthy streaming turn (prose landing every few seconds) is plainly alive, so it must + // NOT be falsely flagged stuck. The mid-stream-FREEZE case (a trickle that pushes this window forward + // forever) is caught instead by the #745 stuck-backstop armed ONCE in armPending — a hard ceiling + // from submit that progress does NOT reset (so it can't be deferred by a trickle). See armPending. const recoveryMs = recoveryWindowMs(Boolean(p.firstBeat)); recoveryTimer.current = window.setTimeout(() => { setPendingState((q) => (q ? { ...q, stuck: true } : q)); @@ -681,7 +717,11 @@ function useLiveSession(state) { return () => { cancelled = true; stop(); document.removeEventListener("visibilitychange", onVisibility); }; }, [campaignId, source, runId, notePendingProgress, claimNarration]); - return { chatBeats, log, pending, armPending, clearPending, recordPlayerEcho }; + // #745: expose notePendingProgress so the live-progress signal is part of the hook's public surface + // (consistent with armPending/clearPending; the /events poll calls the same ref). Purely additive — + // existing consumers destructure named fields, so nothing breaks; it also makes the mid-stream stall + // ceiling unit-testable without reaching into the hook's internals. + return { chatBeats, log, pending, armPending, clearPending, recordPlayerEcho, notePendingProgress }; } window.useLiveSession = useLiveSession; // #348: expose the recovery-timing contract for tests (and devtools introspection). Purely @@ -691,7 +731,8 @@ window.__PENDING_TIMING__ = { recoveryMs: PENDING_RECOVERY_MS, recoveryFirstMs: PENDING_RECOVERY_FIRST_MS, backstopMs: PENDING_BACKSTOP_MS, - armGraceMs: PENDING_ARM_GRACE_MS, // #648: the just-armed-turn protection window + armGraceMs: PENDING_ARM_GRACE_MS, // #648: the just-armed-turn protection window + stuckBackstopMs: PENDING_STUCK_BACKSTOP_MS, // #745: hard stuck ceiling from submit (progress does NOT reset it) }; // #402: expose the live-tail bound for tests/devtools introspection (purely additive — the hook // closes over the consts directly; nothing in the running app reads these off window). diff --git a/viewer/tests/test_recovery_timing.py b/viewer/tests/test_recovery_timing.py index 5c5f8489..cec3c97d 100644 --- a/viewer/tests/test_recovery_timing.py +++ b/viewer/tests/test_recovery_timing.py @@ -158,6 +158,9 @@ pending: () => api.pending, arm: (text) => api.armPending(text || 'do something'), clear: () => api.clearPending(), + // #745: drive the live-progress signal exactly as the /events poll does (a streamed paragraph + // landed for the in-flight turn), so the mid-stream stall ceiling is exercised against the real code. + note: () => api.notePendingProgress(), recoveryWindowMs: (firstBeat) => win.recoveryWindowMs(firstBeat), constants: () => win.__PENDING_TIMING__, }; @@ -323,6 +326,132 @@ def test_armpending_survives_a_same_tick_clear_then_resolves(self): self.assertTrue(out["resolves_later"], "the protected turn still resolves on the real (post-grace) clear") +@unittest.skipIf(shutil.which("node") is None, "node is required to transpile + run the JSX hook") +class MidStreamStallTests(_BabelHarness): + """#745 — the newbie mid-stream-stall give-up (the lone v1.0.4-rc2 RRI holdout @c92a393). + + A DM beat that STREAMS partial prose via /events and then FREEZES mid-generation must STILL recover + to the recoverable `stuck` 'Try again' affordance within a BOUNDED time — independent of how many + partial paragraphs landed. Before #745, `notePendingProgress` re-armed the FULL position window + (180s/240s) AND cleared `stuck` on every streamed paragraph, so a multi-paragraph trickle that then + froze kept pushing the deadline forward; recovery was deferred to the silent 12-min null-backstop + (clears pending to null → a plain re-enabled bar, no 'Try again', the partial narration stranded) — + the ~12–15-min lockout the newbie gave up on. + + The fix is a HARD stuck-backstop armed once in armPending that progress does NOT reset + (PENDING_STUCK_BACKSTOP_MS, ~5 min from submit). A trickle can no longer defer recovery past it, and + it resolves to the SAME recoverable `stuck` state (NOT a null clear). It is deliberately generous so + a long-but-HEALTHY streaming turn (which RESOLVES on /chat → clearPending cancels every timer) never + trips it — preserving the #348/#399/#623 live-progress behavior. These tests drive the REAL hook + (armPending + notePendingProgress + a fake clock) so they track the shipped behavior. + """ + + def test_stuck_backstop_constant_exported_and_strictly_ordered(self): + c = self._run("h.constants()") + self.assertIn("stuckBackstopMs", c) + # The hard stuck ceiling sits STRICTLY between the (resettable) position windows and the 12-min + # null-backstop: position recovery < stuck-backstop < null-backstop. So a frozen turn always + # surfaces the recoverable `stuck` affordance BEFORE the last-resort null clear. + self.assertLess(c["recoveryMs"], c["stuckBackstopMs"]) + self.assertLess(c["recoveryFirstMs"], c["stuckBackstopMs"]) + self.assertLess(c["stuckBackstopMs"], c["backstopMs"]) + self.assertEqual(c["stuckBackstopMs"], 5 * 60 * 1000) + + def test_multi_partial_trickle_then_freeze_recovers_to_stuck_not_null(self): + """The literal newbie scenario: SEVERAL paragraphs stream ('You give the sergeant your own + name… The charcoal touches the paper… That's the arithmetic o—') then it freezes mid-word. Each + partial used to reset the full window AND clear `stuck`, deferring recovery to the 12-min + null-backstop. Now the hard stuck-backstop (progress does NOT reset it) surfaces the recoverable + `stuck` 'Try again' affordance — bounded, and as `stuck` (pending non-null), not a silent clear.""" + out = self._run( + "h.arm('give my own name');" + # eight partials, 30s apart (a plausibly-alive trickle) → 240s of streaming. Under the OLD code + # `stuck` never fired during this (every partial reset the full window AND cleared stuck), and + # recovery waited for the 12-min null-backstop. Capture that the trickle stays alive… + "for (var i = 0; i < 8; i++) { h.advance(30 * 1000); h.note(); }" + "var duringTrickle = h.pending();" + # …then the final paragraph FREEZES. Walk forward; capture when `stuck` first fires and the + # state at that moment (must be the recoverable stuck, NOT a null clear). + "var t = 240 * 1000; var stuckAt = null; var nulledFirst = false;" + "while (t < 11 * 60 * 1000) { h.advance(5 * 1000); t += 5 * 1000;" + " var q = h.pending(); if (q === null) { nulledFirst = (stuckAt === null); break; }" + " if (q && q.stuck) { stuckAt = t; break; } }" + "({ alive_during_trickle: !!(duringTrickle && !duringTrickle.stuck && duringTrickle.streaming)," + " stuck_fired: stuckAt !== null, stuck_at_ms_from_submit: stuckAt," + " nulled_before_stuck: nulledFirst, still_pending_at_stuck: !!(h.pending()) })" + ) + self.assertTrue(out["alive_during_trickle"], + "a flowing trickle stays narrating (not stuck) — the live-progress feel is preserved") + self.assertTrue(out["stuck_fired"], + "#745: a trickle-then-freeze MUST recover to `stuck`, not vanish via the 12-min null-backstop") + self.assertFalse(out["nulled_before_stuck"], + "recovery must surface the RECOVERABLE `stuck` affordance, not a silent null clear") + # The give-up was a ~12–15-min lockout. The hard ceiling bounds total stall from submit well under + # that — it fires by PENDING_STUCK_BACKSTOP_MS regardless of how many partials trickled in. + self.assertIsNotNone(out["stuck_at_ms_from_submit"]) + self.assertLessEqual(out["stuck_at_ms_from_submit"], 5 * 60 * 1000 + 5 * 1000, + "the hard stuck-backstop must fire by ~5 min from submit, not the 12-min null-backstop") + self.assertTrue(out["still_pending_at_stuck"], + "recovery surfaces the `stuck` 'Try again' affordance (pending stays non-null)") + + def test_stuck_backstop_is_not_reset_by_progress(self): + """The crux: streamed progress re-arms the per-progress recovery timer but must NOT push the hard + stuck-backstop forward. So even a long, frequent trickle is bounded by the submit-anchored ceiling.""" + out = self._run( + "h.arm('do');" + # frequent partials right up to just before the 5-min ceiling — each resets the position timer + # (proving the turn looks 'alive' to the per-progress path) but must NOT move the hard ceiling. + "for (var i = 0; i < 9; i++) { h.advance(30 * 1000); h.note(); }" # 270s of streaming + "var at270 = h.pending();" + # cross the 5-min submit ceiling with NO further progress → the hard backstop fires `stuck`. + "h.advance(35 * 1000);" # 305s from submit, past the 300s ceiling + "var at305 = h.pending();" + "({ alive_at_270s: !!(at270 && !at270.stuck), stuck_at_305s: !!(at305 && at305.stuck) })" + ) + self.assertTrue(out["alive_at_270s"], + "frequent progress keeps the turn narrating up to the ceiling (per-progress timer reset)") + self.assertTrue(out["stuck_at_305s"], + "#745: the hard stuck-backstop is anchored to submit — progress must NOT defer it") + + def test_healthy_streaming_turn_that_resolves_never_trips_the_stuck_backstop(self): + """A long-but-HEALTHY streaming turn RESOLVES on /chat (clearPending) before the hard ceiling, which + cancels every timer — so the stuck-backstop never false-positives on a slow-but-alive beat (the + #348/#399/#623 live-progress contract is preserved).""" + out = self._run( + "h.arm('open the scene');" + # 4 minutes of healthy streaming (well past the 240s first-beat window, UNDER the 5-min ceiling), + # then the turn resolves on /chat (clearPending) — exactly what a real completed beat does. + "for (var i = 0; i < 8; i++) { h.advance(30 * 1000); h.note(); }" # 240s + "h.advance(h.constants().armGraceMs + 1000);" + "h.clear();" # the turn RESOLVED on /chat + "var resolved = h.pending();" + # advance far past the 5-min stuck-backstop AND the 12-min null-backstop — a resolved turn must + # not resurrect any stuck/pending state (the timers were cancelled by clearPending). + "h.advance(13 * 60 * 1000);" + "var afterAll = h.pending();" + "({ resolved_null: resolved === null, no_resurrect: afterAll === null })" + ) + self.assertTrue(out["resolved_null"], "a resolved healthy turn clears pending") + self.assertTrue(out["no_resurrect"], + "a resolved turn must NOT later trip the stuck-backstop (clearPending cancelled it)") + + def test_pre_stream_slow_open_still_uses_the_full_window(self): + """A slow cold-open that streams NOTHING yet keeps the generous first-beat window — the #348/#399 + false-stuck guard. The stuck-backstop is a HARD additional ceiling, not a replacement: it does not + clip the legit pre-stream think (which is well under 5 min) to anything shorter.""" + out = self._run( + "h.arm('open the scene');" + # 120s with NO streamed paragraph — neither the 240s first-beat window nor the 5-min ceiling + # has elapsed, so the turn is still narrating (no false stuck). + "h.advance(120 * 1000);" + "var p = h.pending();" + "({ stuck_at_120s_no_stream: !!(p && p.stuck), narrating: !!(p && !p.stuck) })" + ) + self.assertFalse(out["stuck_at_120s_no_stream"], + "the pre-stream slow open must keep the full window — no false stuck at 120s") + self.assertTrue(out["narrating"], "still narrating at 120s when nothing has streamed yet") + + @unittest.skipIf(shutil.which("node") is None, "node is required to transpile + run the JSX gate") class PlayGateLockoutTests(_BabelHarness): """LOCKOUT P0 (detach-locks-the-action-bar) — the CLIENT play-gate contract.