fix(reliability): cold-open retry resumes the minted campaign, not a second one (#719) - #726
Conversation
…second one (#719) The DEFAULT cold-open prompt (scripts/play.sh) instructs start_world + 'if it returns existing_campaigns, start fresh'. When attempt-1 times out (rc=124) AFTER already minting+seeding a campaign, play.sh's one-shot retry re-runs that same prompt -> the DM mints a SECOND, party-less campaign. The viewer auto-follows the newer empty orphan -> party-wipe + a frozen/input-locked screen. This is the adversarially-verified engine root shared by two RRI criticals (hero-bind party-wipe + input-lock); PR #717/#718 mitigated the read-side + added the spinner, but the orphan was still CREATED. Fix (additive, invariant-safe; engine stays sole writer): new shared helper clawdnd_coldopen_retry_msg (qa/lib_beat_driver.sh) returns a RESUME directive (get_state on the existing id, DO NOT start_world, seat a canon PC only if the party is empty) for a DEFAULT cold-open RETRY (first=1, no authored hero) where attempt-1 left a live campaign; otherwise it echoes the base message UNCHANGED (continuing beat / authored hero / fresh first attempt with nothing to resume). Wired into dm_turn's retry in play.sh. Byte-identical on every path except the bug; read-only (asks the engine for the live save) and only on the slow retry path. 3.2-clean (printf, no heredoc-in-$()). The engine seam already exists + is tested (start_world(resume=) -> test_start_world_resume_continues_instead_of_orphaning, test_content.py). Tests: 5 new deterministic shell-helper tests in test_dm_session_remint.py (resume on the bug path; unchanged for no-prior-campaign / continuing-beat / authored-hero; play.sh wiring anti-drift). 15/15 in-file + fast_gate 188 green.
|
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 (3)
📝 WalkthroughWalkthroughThis PR prevents campaign double-minting during cold-open beat retries by detecting existing campaign state and resuming it instead of re-seeding. A new helper function conditions the retry message based on retry type, existing campaigns, and authored hero presence; ChangesCold-open retry campaign resumption
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 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 |
…dup to /events Adversarial review (data-confirmed on real VM runs): the CLAUDE DM frequently logs the opening/beat narration to the engine session log DURING its turn. record_dm_reply's UNCONDITIONAL re-log would then put the prose in the log TWICE -> a SECOND /events row (the viewer keys /events by line-index seq, not text) -> the duplicate is RELOCATED (/events-vs-/events), not fixed. log_engine_narration now appends ONLY when the prose is not already in the recent session-log narration (whitespace-normalized substring, covering single-blob AND per-paragraph logging shapes), but STILL returns success so record_dm_reply stamps engine_logged. Net: the prose lives in the engine log EXACTLY ONCE (for /events + recap/memory) and the redundant /chat blob is dropped -> rendered once. The CODEX DM (which does not self-log) still gets the canonical append. Tests (+3): does-not-double-log-when-already-logged, idempotent-across-per-paragraph (real newlines via $'...'), appends-canonical-when-absent. 12/12 in-file + remint 15 + fast_gate 188 + client contract 3 green. Rebased onto main (incl #719/#726).
… kill the duplicate opening (#720) (#727) * fix(reliability): stamp engine_logged on DM opening/beat chat rows to kill the duplicate opening (#720) The cold-open opening narration rendered TWICE in the OpenWorlds chronicle (vm2-newbie/narrative/veteran sweep): the opening prose lands in two viewer-read sources — the engine per-session log (per-paragraph, fed to /events) AND chat.jsonl (the whole opening as one blob, written by the play wrappers' `chatlog` with no flag). The client's mid-session de-dup (eventsStreamedThisTurnRef) does not guard the cold-open blob (the opening is already complete pre-mount), so it shows up twice. The codex DM path (scripts/play_codex_dm.sh) already solves this with three pieces: a 3-arg `chatlog` (optional extra-JSON merged into the row), a `log_engine_narration` helper, and a `record_dm_reply` helper that stamps {"engine_logged":true} on the chat row IFF the prose was also logged to the engine session log. The client already honors that marker (viewer/openworlds/app.jsx: `if (it.engine_logged === true) return null;`). This ports that PROVEN idiom into the two CLAUDE-DM viewer-backed wrappers. Both play.sh and play_party.sh already source qa/lib_beat_driver.sh, so the three helpers live ONCE in that shared lib (DRY, mirroring the shared clawdnd_dm_remint_session_on_retry) and both wrappers call them: - qa/lib_beat_driver.sh: add chatlog (3-arg) + log_engine_narration + record_dm_reply (read $CHAT/$STATE_DIR/$ROOT from the caller, as the codex versions read $CHAT/$RUN_DIR/$ROOT). On engine-log SUCCESS -> flagged row; on FAILURE -> unflagged row (byte-identical to today; the client's eventsStreamedThisTurnRef backstop still applies). The flag is NEVER stamped unconditionally (a legit /chat-only beat must still render). - scripts/play.sh: drop the one-line chatlog (now shared); MOVE the CAMPAIGN_ID resolution to BEFORE the opening write (record_dm_reply needs the id; the campaign already exists post cold-open dm_turn); route the opening + per-move DM writes through record_dm_reply. - scripts/play_party.sh: drop the one-line chatlog; route the opening, reseat, after-intros, and per-beat DM writes through record_dm_reply. Player/companion rows are left as plain chatlog calls. Wrapper-only (the viewer side is already done + tested). bash 3.2 safe: the heredoc-bearing helpers are only ever called directly, never inside $(...). Tests: new servers/engine/tests/test_dm_reply_engine_logged.py (9 tests, behavioral + static anti-drift) green; the existing remint anti-drift test (10) and the client-side engine_logged de-dup + regression tests (3) stay green; fast_gate Tier-0 (188) passes. * fix(#720): make log_engine_narration IDEMPOTENT — don't relocate the dup to /events Adversarial review (data-confirmed on real VM runs): the CLAUDE DM frequently logs the opening/beat narration to the engine session log DURING its turn. record_dm_reply's UNCONDITIONAL re-log would then put the prose in the log TWICE -> a SECOND /events row (the viewer keys /events by line-index seq, not text) -> the duplicate is RELOCATED (/events-vs-/events), not fixed. log_engine_narration now appends ONLY when the prose is not already in the recent session-log narration (whitespace-normalized substring, covering single-blob AND per-paragraph logging shapes), but STILL returns success so record_dm_reply stamps engine_logged. Net: the prose lives in the engine log EXACTLY ONCE (for /events + recap/memory) and the redundant /chat blob is dropped -> rendered once. The CODEX DM (which does not self-log) still gets the canonical append. Tests (+3): does-not-double-log-when-already-logged, idempotent-across-per-paragraph (real newlines via $'...'), appends-canonical-when-absent. 12/12 in-file + remint 15 + fast_gate 188 + client contract 3 green. Rebased onto main (incl #719/#726). --------- Co-authored-by: Eva <arncalso@gmail.com>
Closes #719.
The bug (adversarially verified, RRI 2026-06-09)
The shared engine root of two RRI criticals — the hero-bind party-wipe and the input-lock/no-spinner. The DEFAULT cold-open prompt (
scripts/play.sh) saysstart_world("<world>")+ "if it returns existing_campaigns, start fresh". When attempt-1 times out (timeout(1)rc=124, >400s) after already minting+seeding a campaign, play.sh's one-shot retry re-runs that identical prompt → the DM mints a SECOND, party-less campaign. The viewer auto-follows the newer empty orphan → "wiped" party + a frozen, input-locked screen.PR #717 mitigated the read-side (viewer demotes party-less orphans) and #718 added the cold-open spinner — but the orphan was still created (a wasted cold-open + a data-loss footgun). This is the engine-side prevention.
The fix (additive, invariant-safe — engine stays sole writer)
New shared helper
clawdnd_coldopen_retry_msg(qa/lib_beat_driver.sh) returns a RESUME directive —get_state(<id>)first, DO NOTstart_world, seat a canon PC only if the party is empty (never invent), then open the 2nd-person scene — when and only when this is a DEFAULT cold-open RETRY (first=1, no authored hero) and attempt-1 left a live campaign (clawdnd_live_campaign_id). Otherwise it echoes the base message UNCHANGED:first=0(continuing beat) — never a cold-open re-seed risk;HERO_CAMPset — the authored-hero opener already opens on the existing campaign;Wired into
dm_turn's retry inplay.sh. Byte-identical on every path except the one bug. Read-only (asks the engine for the live save) and only on the slow retry path. bash-3.2-clean (printf, no heredoc-in-$()— the documented macOS-system-bash footgun).The engine seam this relies on already exists + is tested:
start_world(resume=)→test_start_world_resume_continues_instead_of_orphaning(test_content.py).Tests (deterministic, no LLM/network/engine)
5 new shell-helper tests in
test_dm_session_remint.py(the pytest-via-/bin/bashpattern):DO NOT call start_world+get_state, canon-PC-if-empty; NOT the fresh cold-open prompt);✅ 15/15 in-file · ✅
fast_gate.sh188 engine + seat-path · ✅bash -nclean on both scripts.Moves which RRI gates
zero_critical(the party-wipe + input-lock criticals at their root) → towardno_give_up+cross_persona_sat4.4→7. Verify on the next VM part-B sweep.Summary by CodeRabbit
Bug Fixes
Tests