Skip to content

fix(#648): grace window so a just-armed narrating spinner survives a spurious clear - #666

Merged
100yenadmin merged 1 commit into
mainfrom
fix/648-pending-arm-grace
Jun 5, 2026
Merged

fix(#648): grace window so a just-armed narrating spinner survives a spurious clear#666
100yenadmin merged 1 commit into
mainfrom
fix/648-pending-arm-grace

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented Jun 5, 2026

Copy link
Copy Markdown
Member

The bug (intermittent G3-killer)

Adversarial sweep, [MAJOR], dropped sat 9→4: after Enter-submitting a Do, the echo appeared in the chronicle but no spinner, no narration, all buttons still enabled for 3+ min. The DM only narrated after a manual Continue click.

Root cause

pending (the local "DM is narrating…" spinner) is armed by postMove → armPending, then a clearPending fires milliseconds later and wipes it before the DM's first line could land — via one of:

  • the immediate post-arm surface poll;
  • a /chat cursor-reset re-reading the prior (already-resolved) turn's line as a fresh resolution (the per-run reset zeroes chatCursor);
  • a transient campaignId flip tripping the per-run reset effect.

The player perceives "no DM", clicks Continue (which re-arms + surfaces the reply). Being intermittent, it's a key driver of G3 single-run variance and blocks a clean multi-run mean.

Fix

clearPending keeps a freshly-armed, not-yet-streaming turn alive for a short grace (PENDING_ARM_GRACE_MS = 10s):

Guards (Node-JSX harness — transpiles the real app.jsx under a fake clock)

395 viewer tests pass locally.

Addresses #648 — close on a clean-sweep non-repro per the gate discipline.

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Fixed a race condition where narration display states could be unexpectedly cleared by simultaneous poll and reset operations. A grace period now prevents premature state clearing, improving reliability and stability of narration display.
  • Tests

    • Enhanced test coverage to verify narration state behavior across various timing scenarios, including edge cases with concurrent operations.

…spurious clear

Symptom (adversarial sweep, [MAJOR], dropped sat 9→4): after Enter-submitting a Do, the
echo appeared in the chronicle but the DM never responded for 3+ minutes — no spinner, no
narration, no error, all action buttons still enabled. The DM only began narrating after a
manual "Continue" click.

Root cause: `pending` (the local "DM is narrating…" spinner state) is armed by postMove →
armPending, then a clearPending can fire milliseconds later and wipe it before the DM's
first line could possibly land — the immediate post-arm surface poll, a /chat cursor-reset
re-reading the PRIOR (already-resolved) turn's line as a fresh resolution (the per-run reset
zeroes chatCursor), or a transient campaignId flip tripping the per-run reset effect. The
player then perceives "no DM", clicks Continue (which re-arms pending + surfaces the reply).
Intermittent → a key driver of G3 single-run variance + a blocker on a clean multi-run mean.

Fix: clearPending now keeps a FRESHLY-armed, not-yet-streaming turn alive for a short grace
(PENDING_ARM_GRACE_MS = 10s) — far below a real DM beat (~100–150s) so a genuine resolution
is never swallowed, above the 4s poll cycle so the one-shot post-arm clear can't beat it.
Once /events prose streams (`streaming`) the guard lifts, and the 12-min backstop bypasses
it (armPending arms it via setPendingState(null) directly). The "Try again" retry re-arms
via armPending (not clearPending), so the tuned #344/#348/#399 recovery path is untouched.

Guards (Node-JSX harness — transpiles the real app.jsx under a fake clock):
- test_recovery_timing.py: a same-tick clear after arm is a no-op (the spinner survives) AND
  a clear past the grace still resolves the turn (never stuck on). The #344 contract test now
  clears past the grace — its real intent (a clean re-arm) goes through armPending, unchanged.
- test_live_narration_stream.py: the two turn-resolution tests advance the clock past the
  grace before the resolving /chat tick (a real beat is ~120s, not the fake-0s they assumed);
  a new `h.advance` exposes the harness clock.

395 viewer tests pass locally.

Addresses #648 (the move-sink → pending-arm contract). Close on a clean-sweep non-repro per
the gate discipline.
@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds an arm-grace window to prevent a narrating pending turn from being cleared by spurious same-tick clearPending calls. The grace period blocks clearing until the turn has started streaming or the window expires, protecting fresh pending state during the critical arming moment.

Changes

Pending Arm-Grace Protection

Layer / File(s) Summary
Pending arm-grace protection mechanism
viewer/openworlds/app.jsx
New PENDING_ARM_GRACE_MS constant guards freshly armed turns from spurious clearing; clearPending becomes a no-op for not-yet-streaming pending turns within the grace window; window.__PENDING_TIMING__ exports the new timing constant for test/devtools visibility.
Live narration stream test updates
viewer/tests/test_live_narration_stream.py
Test harness gains h.advance(ms) helper to advance sandboxed Date.now(). Two live-session tests are updated to advance 121 seconds, ensuring beat resolution and engine-logged chat reply behavior occur after the grace window completes.
Recovery timing test coverage
viewer/tests/test_recovery_timing.py
Existing clearPending test advances past grace window before clearing. New regression test for #648 verifies that immediate same-tick clearPending does not wipe a freshly armed turn, while post-grace clearing succeeds as expected.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related issues

Possibly related PRs

  • electricsheephq/WorldOS#394: Both modify the useLiveSession pending/recovery flow in viewer/openworlds/app.jsx—main PR adds grace-window no-op to clearPending, while retrieved PR changes the armPending/streaming semantics that determine when clearPending is invoked.
  • electricsheephq/WorldOS#328: Both address the DM narrating/pending lifecycle—main PR guards the armed pending state with a grace window in clearPending, while retrieved PR introduces the UI affordance and clearing behavior for that pending state in screen-table.jsx.
  • electricsheephq/WorldOS#571: Both modify the pending state in viewer/openworlds/app.jsx; main PR's clearPending grace logic depends on the pending.streaming flag that the retrieved PR introduces and manages in notePendingProgress.

Poem

A whisker of grace keeps the pending turn bright,
No spurious clears in a single-tick fight!
The narration flows safe, the spinner stands tall,
While time marches on past the armGrace call. 🐰✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The pull request description comprehensively covers the bug, root cause, fix, and validation, but the licensing/CLA section required by the template is entirely missing. Add the required Licensing/CLA section with the checkboxes from the template (CLA.md confirmation, confidentiality declaration, third-party material attribution).
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title clearly and specifically summarizes the main fix: adding a grace window for an armed narrating spinner to survive spurious clears, which directly addresses the root cause of issue #648.
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.

Actionable comments posted: 3

🤖 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.

Inline comments:
In `@viewer/openworlds/app.jsx`:
- Around line 377-390: clearPending's current grace check (using
pendingRef.current and PENDING_ARM_GRACE_MS) suppresses legitimate fast
resolution clears; change clearPending to accept a boolean (e.g., forceClear or
isResolution) that, when true, bypasses the PENDING_ARM_GRACE_MS gate, and
update callers that perform real turn resolutions (the code path that consumes a
/chat turn or the event consumer that previously called clearPending) to call
clearPending(true); keep existing behavior for spurious clears by leaving
default calls unchanged; ensure symbols touched: clearPending,
pendingRef.current, PENDING_ARM_GRACE_MS, clearTimers, setPendingState and any
callers that currently invoke clearPending (replace with clearPending(true)
where the clear represents an actual resolved turn).

In `@viewer/tests/test_live_narration_stream.py`:
- Around line 244-247: Replace the ambiguous EN DASH character in the comment
near the advance function so Ruff RUF001 no longer flags it: edit the comment
string that contains “–” (the line describing advancing the fake clock before
the advance: (ms) => { NOW += ms || 0; }) and change the EN DASH to a normal
ASCII hyphen-minus '-' (or an explicit ASCII double hyphen) so the comment uses
only ASCII punctuation.

In `@viewer/tests/test_recovery_timing.py`:
- Line 300: Replace the EN DASH character in the docstring snippet containing
"100–150s" with an ASCII hyphen so the text reads "100-150s"; locate the
docstring that contains the substring "100–150s" in the test_recovery_timing
module and update that character to '-' to satisfy Ruff RUF002.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 48510600-8bad-43e7-b8df-583e4fa1d042

📥 Commits

Reviewing files that changed from the base of the PR and between 8329382 and 45e2483.

📒 Files selected for processing (3)
  • viewer/openworlds/app.jsx
  • viewer/tests/test_live_narration_stream.py
  • viewer/tests/test_recovery_timing.py

Comment thread viewer/openworlds/app.jsx
Comment on lines +377 to +390
const clearPending = React.useCallback(() => {
// #648: keep a FRESHLY-armed, not-yet-streaming turn alive through a spurious same-tick clear
// (see PENDING_ARM_GRACE_MS). The real "Try again" retry path re-arms via armPending (which calls
// clearTimers itself), NOT clearPending, so this never blocks a legitimate re-arm; it only stops a
// poll/reset/flip from wiping the narrating spinner before the DM's first line could land. A real
// resolution arrives long past the grace (or after /events streamed), so it still clears normally.
const p = pendingRef.current;
if (p && !p.streaming && typeof p.since === "number"
&& (Date.now() - p.since) < PENDING_ARM_GRACE_MS) {
return;
}
clearTimers();
setPendingState(null);
}, [clearTimers, setPendingState]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Grace-only gating can block legitimate fast turn resolution.

At Line 384-Line 386, every non-streaming clear inside the 10s window is dropped. That also affects real /chat turn-end clears for fast, chat-only turns; if the resolving line is consumed once, pending can remain until recovery/backstop and keep actions gated.

Suggested direction (differentiate stale clear vs real resolution)
-  const clearPending = React.useCallback(() => {
+  const clearPending = React.useCallback((opts = {}) => {
+    const resolvedAtMs = Number.isFinite(opts.resolvedAtMs) ? opts.resolvedAtMs : null;
     const p = pendingRef.current;
-    if (p && !p.streaming && typeof p.since === "number"
-        && (Date.now() - p.since) < PENDING_ARM_GRACE_MS) {
+    const withinGrace = p && !p.streaming && typeof p.since === "number"
+      && (Date.now() - p.since) < PENDING_ARM_GRACE_MS;
+    const resolvesCurrentTurn = p && resolvedAtMs !== null && resolvedAtMs >= p.since;
+    if (withinGrace && !resolvesCurrentTurn) {
       return;
     }
     clearTimers();
     setPendingState(null);
   }, [clearTimers, setPendingState]);
-          if (dmLineArrived) {
+          if (dmLineArrived) {
+            const lastDm = [...items].reverse().find((it) => it.role === "dm");
+            const resolvedAtMs =
+              (lastDm && typeof lastDm.at === "number") ? (lastDm.at * 1000) : null;
             ...
-            clearPending();
+            clearPending({ resolvedAtMs });
           }
🤖 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 `@viewer/openworlds/app.jsx` around lines 377 - 390, clearPending's current
grace check (using pendingRef.current and PENDING_ARM_GRACE_MS) suppresses
legitimate fast resolution clears; change clearPending to accept a boolean
(e.g., forceClear or isResolution) that, when true, bypasses the
PENDING_ARM_GRACE_MS gate, and update callers that perform real turn resolutions
(the code path that consumes a /chat turn or the event consumer that previously
called clearPending) to call clearPending(true); keep existing behavior for
spurious clears by leaving default calls unchanged; ensure symbols touched:
clearPending, pendingRef.current, PENDING_ARM_GRACE_MS, clearTimers,
setPendingState and any callers that currently invoke clearPending (replace with
clearPending(true) where the clear represents an actual resolved turn).

Comment on lines +244 to +247
// #648: advance the fake clock (Date.now reads NOW) so a test can resolve a turn AFTER the
// armPending grace window — a real DM beat lands ~100–150s post-submit, far past the guard, so a
// same-tick resolve is unrealistic (and now intentionally a no-op for the freshly-armed spinner).
advance: (ms) => { NOW += ms || 0; },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Replace ambiguous EN DASH to satisfy Ruff RUF001.

Line 245 includes inside the harness string, which Ruff flags as ambiguous unicode.

Minimal fix
-  // armPending grace window — a real DM beat lands ~100–150s post-submit, far past the guard, so a
+  // armPending grace window - a real DM beat lands ~100-150s post-submit, far past the guard, so a
🧰 Tools
🪛 Ruff (0.15.15)

[warning] 245-245: String contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF001)

🤖 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 `@viewer/tests/test_live_narration_stream.py` around lines 244 - 247, Replace
the ambiguous EN DASH character in the comment near the advance function so Ruff
RUF001 no longer flags it: edit the comment string that contains “–” (the line
describing advancing the fake clock before the advance: (ms) => { NOW += ms ||
0; }) and change the EN DASH to a normal ASCII hyphen-minus '-' (or an explicit
ASCII double hyphen) so the comment uses only ASCII punctuation.

re-reading the prior resolved turn's line as a fresh resolution, or a transient campaignId
flip tripping the per-run reset. That MUST NOT wipe the just-armed narrating spinner (the
adversarial's '[MAJOR] no spinner, buttons enabled, no DM for 3+ min until I clicked
Continue'). The guard is bounded: a REAL resolution (~100–150s later, past the grace) still

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use ASCII hyphen in docstring to avoid Ruff RUF002 warning.

Line 300 uses an EN DASH () that Ruff flags as ambiguous.

Minimal fix
-        Continue'). The guard is bounded: a REAL resolution (~100–150s later, past the grace) still
+        Continue'). The guard is bounded: a REAL resolution (~100-150s later, past the grace) still
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Continue'). The guard is bounded: a REAL resolution (~100150s later, past the grace) still
Continue'). The guard is bounded: a REAL resolution (~100-150s later, past the grace) still
🧰 Tools
🪛 Ruff (0.15.15)

[warning] 300-300: Docstring contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF002)

🤖 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 `@viewer/tests/test_recovery_timing.py` at line 300, Replace the EN DASH
character in the docstring snippet containing "100–150s" with an ASCII hyphen so
the text reads "100-150s"; locate the docstring that contains the substring
"100–150s" in the test_recovery_timing module and update that character to '-'
to satisfy Ruff RUF002.

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