fix(reliability): recover a stalled-mid-stream DM beat — never lock the player out (#623 deeper layer) - #746
Conversation
…he player out (#623 deeper layer) 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).
📝 WalkthroughWalkthroughThis PR fixes ChangesMid-stream stall recovery: DM timeout + viewer stuck backstop
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
qa/ui_playtest.sh (1)
139-150: 💤 Low valueShellcheck SC2034 is a false positive — consider silencing it.
The
CLAWDND_DM_MODELvariable is consumed byclawdnd_dm_timeout(sourced fromlib_beat_driver.shat line 28), which uses${CLAWDND_DM_MODEL:-}to select the cold-open tier. Shellcheck doesn't trace into sourced functions, so it reports the assignment as unused.Silencing the warning and adding a brief usage note would prevent future confusion:
Proposed clarification
-CLAWDND_DM_MODEL="$DM_MODEL" +# shellcheck disable=SC2034 # read by clawdnd_dm_timeout (sourced from lib_beat_driver.sh) +CLAWDND_DM_MODEL="$DM_MODEL"🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@qa/ui_playtest.sh` around lines 139 - 150, The CLAWDND_DM_MODEL assignment is flagged by ShellCheck SC2034 but is actually used indirectly by clawdnd_dm_timeout (sourced from lib_beat_driver.sh); silence the false positive by adding a ShellCheck directive on the CLAWDND_DM_MODEL line (disable SC2034) and add a one-line comment noting that clawdnd_dm_timeout in lib_beat_driver.sh consumes this variable so future readers/tools don’t remove it; reference clawdnd_dm_timeout and lib_beat_driver.sh in that comment.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@qa/ui_playtest.sh`:
- Around line 139-150: The CLAWDND_DM_MODEL assignment is flagged by ShellCheck
SC2034 but is actually used indirectly by clawdnd_dm_timeout (sourced from
lib_beat_driver.sh); silence the false positive by adding a ShellCheck directive
on the CLAWDND_DM_MODEL line (disable SC2034) and add a one-line comment noting
that clawdnd_dm_timeout in lib_beat_driver.sh consumes this variable so future
readers/tools don’t remove it; reference clawdnd_dm_timeout and
lib_beat_driver.sh in that comment.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 21943c51-518f-45d2-9d69-b3df011e90ce
📒 Files selected for processing (4)
qa/ui_playtest.shservers/engine/tests/test_dm_session_remint.pyviewer/openworlds/app.jsxviewer/tests/test_recovery_timing.py
… re-measure) (#747) Clean 5-canonical rollup: RRI 2.7/10 — but the PRODUCT improved vs rc1: criticals 4->1 (the #735 keystone validated), sat 4.8->5.8, behavioral GREEN, 4/5 personas clean. The RRI drop is two NON-product effects: -1 native_gate (Mac handoff skipped, disk) and -1 no_give_up (newbie mid-stream stall = #745, fix merged in #746 post-sweep). Same ruler sc_5ac7a1d9103c as rc1 (apples-to-apples). Ledger row v1.0.4-rc2-c92a393. Co-authored-by: Eva <arncalso@gmail.com>
…on healthy slow turns (refs #745, follow-up to #746) (#761) The #746 hard stuck ceiling (PENDING_STUCK_BACKSTOP_MS) was a flat 5 min, armed once at submit and not firstBeat-aware — but the system's own HEALTHY turn budgets exceed it: the cold open measures ~300s with a 400-500s deadline (qa/lib_beat_driver.sh clawdnd_dm_timeout, 500s for Opus), and a continuing beat can legitimately run ~400s (scripts/play.sh CLAWDND_BEAT_TIMEOUT=200s + ONE retry). When the ceiling fired mid-flight on a working turn: pendingActive flipped false (action bar re-opened, screen-table.jsx), the "DM seems stuck" toast fired, and retryStuck re-POSTed the move - the SAME intent resolved TWICE once the in-flight beat landed. Fix (minimal, option (a) of the audit): make the ceiling budget-aware by turn position via a pure stuckBackstopMs(firstBeat) selector (mirrors recoveryWindowMs): firstBeat => 9 min (>= the 500s cold-open budget), later beats => 7 min (>= the ~400s timeout+retry budget). Both stay strictly under the 12-min null-backstop, so the #745 ordering holds: position recovery (resettable) < stuck ceiling (hard, recoverable) < null-backstop. Tests (red-first against main): the recovery-timing harness now runs real effects + a scripted /chat poll (ported from test_live_narration_stream.py), so a test can RESOLVE a turn - flipping firstBeat - and exercise the later-beat ceiling branch of the real hook. New: the missing-band first-beat test (healthy stream, resolves ~430s - false-fired at exactly 300s on main), the later-beat ~380s retry-budget test, the budget-aware ordering/selector contract; updated: progress-does-not-defer-the-ceiling at the new 9-min value; retained: trickle-then-freeze still recovers to recoverable stuck (the #745 protection). Co-authored-by: Eva <arncalso@gmail.com>
…icle narration (#869) Two v1.0.4 viewer orphans from the engine-audit Section-4 coverage check (rc1+rc2 persona evidence with no prior issue). Viewer-scope only; the engine stays sole writer — viewer/server.py is the read-only bridge projecting state the engine already wrote. #826 — Navigating away mid-narration corrupts session state + permanently freezes the scene image (rc2 adversarial). A user-initiated nav DURING an in-flight beat (distinct from the #745/#648 stalled-beat trigger). Two legs: * State corruption (double-fire): postMove armed the one-move gate only AFTER the /move POST resolved, leaving a window where a nav-away/nav-back remounted ScreenTable (fresh submittingRef=false, pending=null) and re-opened the bar — a second move could double-fire the one-move-at-a-time lane. Fix: arm OPTIMISTICALLY before the await (the App-level useLiveSession pending state survives the unmount), and on a POST rejection roll it back via a new, surgical abandonPending that bypasses the #648 arm-grace (an authoritative server rejection is not a spurious clear; it only clears the move we armed, never a newer live turn). Preserves the #745/#746 recovery contract and the #399/#406 first-beat window (armPending/notePendingProgress untouched). * Scene-image freeze (dead handle): chrome.jsx Img latched `failed` on the first onError and only ever cleared it on a SCOPE CHANGE — so a #399 fire-and-forget scene that 404s while its art is still being generated stayed frozen on the placeholder forever, even once the same-scope image became servable. Fix: bounded, backed-off, cache-busted retries so the component RECOVERS when the pending art lands (and stops after the budget for a genuinely-missing image). No dead latch. #825 — Chronicle truncates DM narration mid-word at a fixed ceiling (3 personas: rc2 adversarial+narrative, rc1 veteran). The chronicle's leading history band (_session_recent_events) hard-cut every row to text[:1000], slicing a long DM beat mid-word with no ellipsis/expand. Fix: render the FULL beat (_bounded_chronicle_text) — the render region is already a scrollable role="log" and the #752 a11y bound is the ROW cap (CHRONICLE_RENDER_CAP/MAX_LIVE_BEATS), not a per-row char ceiling, so removing the cut does not reintroduce the #752 a11y flood. Only a generous DoS guard remains (64 KB, far above any real beat), and even it cuts on a word boundary. TDD via the JSX behaviour harness (test_recovery_timing.py pattern): viewer/tests/test_nav_chronicle_resilience.py — a nav-away-during-pending / optimistic-arm-survival + authoritative-abandon test, an Img dead-handle-recovery test, and a long-narration no-mid-word-truncation test (7 tests, all green). Full viewer suite 505 passed / 1 skipped; fast_gate Tier-0 195 passed. Source: docs/audits/ENGINE-AUDIT-2026-06-11.md (Section-4 orphans #1 + #5) Refs #826, #825 Co-authored-by: Eva <arncalso@gmail.com>
The bug (the lone v1.0.4-rc2 RRI holdout @c92a393)
In the 5-persona sweep, the newbie persona gave up (the only one). A DM beat 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; the action bar stayed disabled for 15+ min with no recovery path (no retry / refresh / cancel) → give-up. This flips bothno_give_upandzero_criticalfor newbie.Root cause (proven, file:line)
(a) Client — primary.
viewer/openworlds/app.jsxnotePendingProgressre-arms the full position recovery window (180s later / 240s first) and clearsstuckon every streamed/eventsparagraph. A beat that streams several partial paragraphs and then freezes keeps pushing the stuck deadline forward with each partial — so with partials closer than the window,stucknever fires and recovery is deferred to the 12-minPENDING_BACKSTOP_MS, which clearspendingto null (a plain re-enabled bar — no "Try again" affordance, the partial narration stranded). That is the ~12–15-min lockout.#623/#743 interaction — confirmed implicated. The #623/#743 heartbeat + live-progress stream is exactly what feeds
notePendingProgress; the perceived-latency fix masked the genuine-stall detector (per-progress reset).(b) Backend — compounding.
qa/ui_playtest.sh'sdm_turn(the GUI-sweep driver the newbie ran) invokedclaude -pwith notimeout, no retry, no fallback — unlikescripts/play.sh'sdm_turn. A frozen process hangs forever → the turn never resolves on/chat→ the client stall ceiling is the only recovery.The fix (additive; engine stays sole writer; #720 dedup / #623 heartbeat / #735 unchanged)
PENDING_STUCK_BACKSTOP_MS(5 min), armed once inarmPending, anchored to submit, not reset by progress, flippingstuck=true— the recoverable "Try again" affordance, not a silent null clear. Ordering is now: position recovery (resettable — preserves perf(engine): non-blocking generate_image — off the synchronous DM-turn path #399/[reliability] DM beats silently drop / hang >10min with no recovery — sabotages story personas (crit) #623 live-progress) < stuck-backstop (hard, recoverable) < 12-min null-backstop.notePendingProgressis otherwise unchanged, so a healthy streaming turn is never falsely stuck. (A per-progress 90s clamp was tried and rejected — it re-introduces the perf(engine): non-blocking generate_image — off the synchronous DM-turn path #399 false-stuck on a heartbeat-only turn whose real prose lands >90s later.)qa/ui_playtest.sh'sdm_turnnow wrapsclaude -pintimeout(sharedclawdnd_dm_timeout) and routes the result throughclawdnd_dm_narration_or_fallback(same call shape asplay.sh), 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 full window. RED on origin/main.servers/engine/tests/test_dm_session_remint.py(3):ui_playtest.shdm_turnwraps the beat intimeout, uses the narration fallback, andclawdnd_dm_timeoutis 3.2-clean. 2/3 RED on origin/main.Verify
viewer/tests/test_recovery_timing.py: 32 passedviewer/tests(full): 483 passed, 6 skippedservers/engine/tests/test_dm_session_remint.py: 24 passedqa/fast_gate.sh: 188 passedbash -nclean under macOS system bash 3.2 on both modified scriptsConfidence & residual risk
~85%. The node-harness tests prove the timing contract, not end-to-end browser behavior. Only a real 5-persona GUI sweep @ this SHA can confirm the holdout flips (no_give_up + zero_critical). The 5-min ceiling is a deliberate UX judgement (generous enough to never false-stuck a healthy slow cold-open; the player still waits up to 5 min before "Try again" — far better than 12-15 min, tunable). Do not merge before the GUI sweep + adversarial review.
Summary by CodeRabbit