Skip to content

fix(reliability): recover a stalled-mid-stream DM beat — never lock the player out (#623 deeper layer) - #746

Merged
100yenadmin merged 1 commit into
mainfrom
fix/newbie-midstream-stall-recovery
Jun 10, 2026
Merged

fix(reliability): recover a stalled-mid-stream DM beat — never lock the player out (#623 deeper layer)#746
100yenadmin merged 1 commit into
mainfrom
fix/newbie-midstream-stall-recovery

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented Jun 10, 2026

Copy link
Copy Markdown
Member

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 both no_give_up and zero_critical for newbie.

Root cause (proven, file:line)

(a) Client — primary. viewer/openworlds/app.jsx notePendingProgress re-arms the full position recovery window (180s later / 240s first) and clears stuck on every streamed /events paragraph. 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, stuck never fires and recovery is deferred to the 12-min PENDING_BACKSTOP_MS, which clears pending to 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's dm_turn (the GUI-sweep driver the newbie ran) invoked claude -p with no timeout, no retry, no fallback — unlike scripts/play.sh's dm_turn. A frozen process hangs forever → the turn never resolves on /chat → the client stall ceiling is the only recovery.

Note: timeout(1) IS a wall-clock from process launch, so it does cover a mid-stream stall — but only where it was wired (play.sh), not in the GUI driver. That asymmetry is (b). The client recovery action already exists (the stuck "Try again" path) — the gap is that stuck never fired in time, not that there's no action.

The fix (additive; engine stays sole writer; #720 dedup / #623 heartbeat / #735 unchanged)

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.sh dm_turn wraps the beat in timeout, uses the narration fallback, and clawdnd_dm_timeout is 3.2-clean. 2/3 RED on origin/main.

Verify

  • viewer/tests/test_recovery_timing.py: 32 passed
  • viewer/tests (full): 483 passed, 6 skipped
  • servers/engine/tests/test_dm_session_remint.py: 24 passed
  • qa/fast_gate.sh: 188 passed
  • bash -n clean under macOS system bash 3.2 on both modified scripts

Confidence & 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

  • Bug Fixes
    • Fixed indefinite hangs during DM streaming by implementing timeout-based recovery mechanisms.
    • Enhanced in-flight turn recovery with a new hard-stop timeout for stuck or stalled turns.
    • Improved fallback logic to ensure turns recover gracefully when streaming is interrupted or stalls mid-progress.

…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).
@100yenadmin 100yenadmin added this to the v1.0.4 milestone Jun 10, 2026
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR fixes #745 by hardening both the backend DM turn driver and the frontend turn-recovery logic against mid-stream stalls. The DM turn now times out per beat and falls back to engine-logged narration; the viewer's live-session hook now arms a hard stuck-backstop ceiling that marks pending as stuck when no progress arrives, independent of retry logic.

Changes

Mid-stream stall recovery: DM timeout + viewer stuck backstop

Layer / File(s) Summary
DM turn bounded timeout implementation
qa/ui_playtest.sh
dm_turn wraps claude -p in a beat-specific timeout computed via clawdnd_dm_timeout, captures streamed JSONL, and returns via clawdnd_dm_narration_or_fallback to ensure the turn-ending /chat line is always produced even on timeout or error.
DM timeout regression tests
servers/engine/tests/test_dm_session_remint.py
Three tests verify that the DM turn uses a bounded timeout, recovers stalled beats via narration fallback, and that the timeout helper returns positive integers for both beat tiers while remaining bash 3.2 compatible.
Viewer stuck backstop constant and contract
viewer/openworlds/app.jsx
Introduces PENDING_STUCK_BACKSTOP_MS (5 minutes) documenting its ordering between adaptive per-position recovery and null backstop; exports stuckBackstopMs in window.__PENDING_TIMING__ for test/devtools visibility.
Viewer stuck backstop timer lifecycle
viewer/openworlds/app.jsx
Manages the stuck-backstop timer: adds stuckBackstopTimer ref, arms it at pending creation so pending transitions to stuck after the ceiling independent of streamed progress, clears it with other timers on resolution, exposes notePendingProgress hook return, and clarifies timer behavior in comments.
Viewer stuck backstop regression tests
viewer/tests/test_recovery_timing.py
Extends test harness with note() helper to simulate progress, adds MidStreamStallTests class validating constant export/ordering, mid-stream freeze recovery as a non-null stuck state, progress does not reset the ceiling, healthy streams do not false-positive, and pre-stream slow opens preserve the first-beat recovery window.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related issues

  • electricsheephq/WorldOS#745: The primary issue this PR addresses; both DM timeout and viewer stuck-backstop changes directly target the mid-stream stall hang failure mode.
  • electricsheephq/WorldOS#406: Overlaps on the useLiveSession pending recovery logic—the PR's notePendingProgress and stuckBackstopMs additions are related to existing backstop/pending contract.
  • electricsheephq/WorldOS#648: The useLiveSession changes (adding notePendingProgress and stuck-backstop) address the pending-state contract that the reported Enter-submit spinner bug touches.

Possibly related PRs

  • electricsheephq/WorldOS#666: Modifies window.__PENDING_TIMING__ logic in viewer/openworlds/app.jsx by adding armGraceMs guard in clearPending; this PR adds a new stuckBackstopMs timing behavior in the same export contract.
  • electricsheephq/WorldOS#360: Introduces the clawdnd_dm_narration_or_fallback helper via qa/lib_beat_driver.sh; this PR rewires qa/ui_playtest.sh's dm_turn to use that helper for turn-ending narration on timeout/error.
  • electricsheephq/WorldOS#567: Adds the clawdnd_dm_timeout helper that this PR uses to bound claude -p per beat in qa/ui_playtest.sh.

Poem

🐰 A timeout wraps the chatty Claude,
And up above, a backstop nods—
When streaming stalls mid-beat, behold,
No more hangs; the turn's controlled! 🎵

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding mid-stream stall recovery for DM beats to prevent player lockout, with clear reference to the related issue context.
Description check ✅ Passed The description is comprehensive and well-structured, covering the bug, root cause, fix, tests, and verification against the template requirements (summary present, licensing checkboxes included, validation checks listed).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
qa/ui_playtest.sh (1)

139-150: 💤 Low value

Shellcheck SC2034 is a false positive — consider silencing it.

The CLAWDND_DM_MODEL variable is consumed by clawdnd_dm_timeout (sourced from lib_beat_driver.sh at 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

📥 Commits

Reviewing files that changed from the base of the PR and between c92a393 and d7f481f.

📒 Files selected for processing (4)
  • qa/ui_playtest.sh
  • servers/engine/tests/test_dm_session_remint.py
  • viewer/openworlds/app.jsx
  • viewer/tests/test_recovery_timing.py

@100yenadmin
100yenadmin merged commit dca46bd into main Jun 10, 2026
16 checks passed
@100yenadmin
100yenadmin deleted the fix/newbie-midstream-stall-recovery branch June 10, 2026 00:14
100yenadmin added a commit that referenced this pull request Jun 10, 2026
… 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>
100yenadmin added a commit that referenced this pull request Jun 10, 2026
…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>
100yenadmin added a commit that referenced this pull request Jun 14, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant