fix(openworlds): adaptive 'stuck' recovery so a slow Act-opening isn't pre-empted (Closes #348) - #351
Conversation
…t pre-empted (Closes #348) The #324 narrative persona hit "The Dungeon Master seems stuck — No reply came back in time" with NO narration at a cliffhanger: the #342 recovery fired at a fixed 90s from submit, but the DM's legit cold-open (Act-opening) can take SEVERAL minutes (a blind newbie run saw 5–8 min and succeed). The 90s wall-clock false-fired 'stuck' mid-opening → the still-coming narration + momentum were lost. Finding (STEP 1): the DM beat lands ALL-AT-ONCE — no streaming/partial/heartbeat. Both the duo runner (qa/run_duo.sh) and the human runner (qa/play_human.sh) capture the DM turn via `claude -p --output-format stream-json > out`, then extract only the final `result` text AFTER the turn completes and append ONE {"role":"dm",...} line to <run>.chat.jsonl. The /chat poll therefore returns zero new items for the entire turn, then the complete beat — there is NO in-flight progress to reset on. So the fix is a THRESHOLD-RAISE, not progress-aware (a progress-aware reset would require a wire change, which is out of scope). Fix (viewer-only, no engine/wire change): - The recovery 'stuck' window is now turn-position-aware (recoveryWindowMs): • FIRST beat of a session (cold-open): 4 min (PENDING_RECOVERY_FIRST_MS) • LATER beats (~35–60s norm): 90s (PENDING_RECOVERY_MS, unchanged) "first beat?" = no DM narration has arrived this session yet (dmBeatCountRef === 0, already reset to 0 on every run change). - The 12-min hard backstop (#338) is UNCHANGED — a turn that blows even the first-beat window still gets force-cleared. - The "DM is narrating…" affordance is now HONEST: the first beat reads "Setting the opening scene — the first beat of a session can take a few minutes"; later beats keep "up to a minute". (#336 animation/elapsed/reduced-motion fully intact.) Preserved invariants: - #340 — the /chat poll still clearPending()s on ANY narration beat, so a beat that lands after 'stuck' still renders into the log + clears the indicator (untouched path). - #344 — armPending/clearPending signatures + retryStuck/lastMoveRef unchanged; timers disarm cleanly so 'Try again' re-arms. - #336 — DmNarratingBeat dots/elapsed/reduced-motion preserved; firstBeat defaults falsy (older pending objects show the original copy) — additive-by-default. Tests: viewer/tests/test_recovery_timing.py transpiles the REAL app.jsx with the bundled Babel and drives useLiveSession under Node with a deterministic clock + fake timers (mirrors test_sanitize_narration.py). Proves: first beat is NOT stuck at 91s (the bug) yet still recovers after 4 min; later-beat window stays 90s; the 12-min backstop still force-clears; clearPending disarms timers. axe = 0 across all 17 screens; both JSX files transpile.
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughThe PR extends the DM narration "stuck" recovery behavior to use adaptive timeouts: first-beat turns get a longer window (4 minutes) before flagging as stuck, while subsequent beats use the original 90-second window. The pending state now tracks ChangesAdaptive First-Beat Recovery Timing
🎯 3 (Moderate) | ⏱️ ~25 minutes 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 |
Closes #348.
The bug
The #324 narrative persona (nar1) hit "The Dungeon Master seems stuck — No reply came back in time" with NO narration produced, at a dramatic cliffhanger. The #342/#344 recovery fired mechanically (good), but the DM's legitimate Act-opening can take several minutes (an earlier blind-newbie run saw the cold-open take 5–8 min and succeed). The recovery's fixed 90s wall-clock from submit therefore false-fired 'stuck' mid-opening → the still-coming narration + the momentum were lost.
STEP 1 finding — the beat arrives ALL-AT-ONCE (no streaming)
/chatcarries no streaming / partial chunks / heartbeat. Both runner paths confirm it:qa/run_duo.sh(lines 95–101) andqa/play_human.sh(lines 42–44, 60, 89) capture the DM turn viaclaude -p … --output-format stream-json > out, then extract only the finalresulttext after the turn completes (jq -rs 'map(select(.type=="result"))[-1].result') and append one{"role":"dm","text":…}line to<run>.chat.jsonl.viewer/server.py_read_chat(line 5153) + the/chatroute (line 5956) tail that file line-by-line. So the poll returns zero new items for the whole turn, then the one complete beat.There is no in-flight progress to reset on → a progress-aware timer is impossible without a wire change (out of scope). So the fix is a threshold-raise.
STEP 2 chosen approach — adaptive (turn-position-aware) threshold, viewer-only
viewer/openworlds/app.jsxuseLiveSession— the recovery 'stuck' window is now chosen byrecoveryWindowMs(firstBeat):PENDING_RECOVERY_FIRST_MSPENDING_RECOVERY_MSdmBeatCountRef === 0, already reset to 0 on every run change).screen-table.jsxDmNarratingBeat): the first beat reads "Setting the opening scene — the first beat of a session can take a few minutes"; later beats keep "up to a minute".Preserved invariants
/chatpoll stillclearPending()s on any narration beat, so a beat that lands after 'stuck' still renders into the log + clears the indicator. That path is untouched; the change only affects when the stuck flag is set.armPending/clearPendingsignatures +retryStuck/lastMoveRefunchanged; timers disarm cleanly so 'Try again' re-arms (test asserts no stuck-flag resurrection after clear).DmNarratingBeatdots / elapsed counter / reduced-motion fully intact;firstBeatdefaults falsy so an older pending object shows the original copy (additive-by-default).Evidence
viewer/tests/test_recovery_timing.py— transpiles the realapp.jsxwith the bundled Babel and drivesuseLiveSessionunder Node with a deterministic clock + fake timers (mirrorstest_sanitize_narration.py). 6/6 pass:firstBeat:true);clearPendingdisarms timers (clean [playtest][P1] VALIDATE #343 completeness — veteran hit dead 'Try again' button + Table nav still blocked from Map/Journal #344 retry re-arm).viewer/tests/test_sanitize_narration.py+test_openworlds_static.py— 26 passed (no regression on the shared files).qa/ui_audit_health.sh --quick --axe→ axe = 0 violations across all 17 screens; all structural checks PASS (only WARN is the gitignored_privateimages dir absent on a clean checkout). Both JSX files transpile.Scope: viewer-only; worktree off
origin/main; no engine / wire-contract change (engine stays sole writer;/chat+/move+ the chat-log contract untouched). No run-artifacts committed.Summary by CodeRabbit
Bug Fixes
Tests