fix(viewer): budget-aware stuck-backstop — stop false-firing 'stuck' on healthy slow turns (refs #745, follow-up to #746) - #761
Conversation
…on healthy slow turns (refs #745, follow-up to #746) 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).
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThis PR updates the live in-flight turn indicator's stuck-recovery timing to be turn-position-aware: first beats (cold opens) use a 9-minute ceiling, later beats use 7 minutes. It replaces a fixed timeout with an adaptive ChangesAdaptive stuck-backstop timing by turn position
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
Comment |
P0 — the #746 stuck-backstop false-fires on healthy slow turns
Refs #745 (the mid-stream-stall lockout); follow-up to the merged #746 (which shipped the ceiling this PR retunes). Audit-verified root cause — sanity-rechecked against the cited lines before editing:
viewer/openworlds/app.jsx:PENDING_STUCK_BACKSTOP_MS = 5 * 60 * 1000(flat 300s), armed once at submit inarmPending, NOTfirstBeat-aware; progress does not reset it (by design, [reliability][P0] Stalled-mid-stream DM beat locks the player out with no recovery (the v1.0.4-rc2 holdout) #745).qa/lib_beat_driver.shclawdnd_dm_timeout— 500s for Opus; verified at lines ~441–447);scripts/play.shCLAWDND_BEAT_TIMEOUT=200s+ ONE retry ⇒ a healthy beat can run ~400s.pendingActiveflips false → the action bar re-opens (screen-table.jsx~614), the "DM seems stuck" toast fires (~796–798), andretryStuckre-POSTs the move (~875+) → the same intent resolves twice once the in-flight beat lands.Fix (audit option (a), minimal)
Budget-aware ceiling by turn position via a pure
stuckBackstopMs(firstBeat)selector (mirrorsrecoveryWindowMs, exported for tests):PENDING_STUCK_BACKSTOP_FIRST_MS = 9 min(540s)PENDING_STUCK_BACKSTOP_MS = 7 min(420s)Ordering invariant preserved per branch: position recovery (180s/240s, resettable) < stuck ceiling (420s/540s, hard, recoverable) < 12-min null-backstop.
firstBeatdetection unchanged (resolvedTurnsRef.current === 0, the #406 semantics).Option (b) (arm the ceiling only on the first streamed paragraph) deliberately NOT implemented: with the ceiling submit-anchored (required to keep it under the null-backstop) and strictly above the recovery windows, arming at submit vs. at first paragraph is observationally identical — a never-streaming turn is flagged
stuckby the adaptive recovery window (180s/240s) long before the ceiling, and a streaming turn arms it anyway. (b) would add arm-state in the progress path for zero behavioral delta, so it fails the "only if it stays small" bar on the value side.Tests (red-first;
viewer/tests/test_recovery_timing.py)Harness upgrade: the recovery-timing harness now runs real effects + a scripted
/chatpoll (ported fromtest_live_narration_stream.py, keeping the deterministic fake-timer queue). Resolving a turn via a real/chatdm line is the only writer ofresolvedTurnsRef, so tests can now exercise the later-beat branch of the real hook — previously unreachable (the old harness's documented limitation).Proven RED on main @ f24a102 (5 failed, 30 passed), then GREEN after the fix:
test_healthy_first_beat_in_the_cold_open_budget_band_never_shows_stuck: healthy first beat streams every 30s, resolves ~430s. RED on main: false-fired at exactly 300s (assertion message captured[300]s).test_healthy_later_beat_resolving_within_the_retry_budget_never_shows_stuck: turn 1 resolved via the live /chat path (firstBeat=falseprecondition asserted), turn 2 streams + resolves ~380s. RED on main.test_multi_partial_trickle_then_freeze_recovers_to_stuck_not_null(recovers to recoverablestuck, never the silent null clear, bounded ≤ the first-beat ceiling) andtest_stuck_backstop_is_not_reset_by_progressrewritten at the new 9-min value (trickle to 510s, freeze, stuck at 545s — progress cannot defer the submit-anchored ceiling).test_stuck_backstop_constants_exported_and_strictly_ordered(per-branch ordering + explicit ≥500s / ≥400s budget floors) andtest_stuck_backstop_selector_both_branches.Verification (honest)
python3 -m pytest viewer/tests/test_recovery_timing.py -q -p no:xdist→ 35 passed (was 5 failed / 30 passed pre-fix — red-first proven).python3 -m pytest viewer/tests -q -p no:xdist→ 486 passed, 6 skipped, 48 subtests passed.bash qa/fast_gate.sh→ PASS (188 passed, deterministic engine tier).Invariants
__PENDING_TIMING__change is additive (newstuckBackstopFirstMskey; existing keys keep meaning).🤖 Generated with Claude Code
Summary by CodeRabbit
Improvements
Tests