diff --git a/qa/assert_behavioral.py b/qa/assert_behavioral.py index 2e4d508d..44c14a8c 100644 --- a/qa/assert_behavioral.py +++ b/qa/assert_behavioral.py @@ -520,9 +520,30 @@ def _has_spells(c: dict) -> bool: f"(advance_time / travel_to(advance_time=True) / long_rest)") locs = state.get("locations", {}) or {} visited = sum(1 for l in locs.values() if isinstance(l, dict) and l.get("visited")) - chk("party_traveled", visited >= 2, + # IN-PLACE PROGRESSION EXCEPTION (#623 false-cap): a multi-beat arc that genuinely + # RESOLVED in a single location (e.g. a 9-beat tavern negotiation) is a SUCCESS, not a + # frozen stall — but the bare `visited >= 2` rule false-REDs it (RED-capping every lens + # ≤ 2.5). Distinguish the two with signals already in scope: a COMPLETE single-scene + # drama advanced the clock AND resolved its arc; a FROZEN opening did neither. The AND + # keeps a frozen stall RED (day==1/morning ⇒ clock_advanced False, no completed quest ⇒ + # arc_resolved False — it fails ≥2 conjuncts). Deliberately NOT broadened to clock-only + # or beats-only. + clock_advanced = day > 1 or (tod not in ("", "morning")) + # arc_resolved requires an ACTUAL completed quest in the snapshot — NOT the status-blind + # quest_resolved tool-count (coverage_from_tool_counts counts set_quest_status(...,"active" + # /"failed") too, which would let a FROZEN DM game this exception with one cheap call: + # advance_time + set_quest_status(status="active") on a dead scene. Adversarial-verified. + arc_resolved = any( + isinstance(q, dict) and q.get("status") == "completed" for q in quest_iter) + SINGLE_SCENE_MIN_BEATS = 8 # strictly above MIN_BEATS(6): a real arc, not a smoke test + in_place_progression = (visited >= 1 and clock_advanced and arc_resolved + and session_beats >= SINGLE_SCENE_MIN_BEATS) + chk("party_traveled", visited >= 2 or in_place_progression, f"visited {visited}/{len(locs)} location(s) after {session_beats} beats — the party never " - f"left the opening scene (travel_to / add_location make_current=True)") + f"left the opening scene (travel_to / add_location make_current=True); " + f"in-place-progression exception NOT met " + f"(clock_advanced={clock_advanced} arc_resolved={arc_resolved} " + f"beats>={SINGLE_SCENE_MIN_BEATS}? {session_beats >= SINGLE_SCENE_MIN_BEATS})") # WARN (the metric is softer): did the world gain/engage faces, or just sit in the seed? npcs_met = sum(1 for c in chars.values() if isinstance(c, dict) and c.get("kind") == "npc" and c.get("met")) diff --git a/qa/dm_beat_mark.py b/qa/dm_beat_mark.py index 8f1a9ab4..919c2e3f 100644 --- a/qa/dm_beat_mark.py +++ b/qa/dm_beat_mark.py @@ -118,11 +118,18 @@ def _is_new_prose(row): return True -def cmd_mark(state_dir, mark_file): +def cmd_mark(state_dir, mark_file, first=None): snap = _snapshot_path(state_dir) log_path = _session_log_path(snap) if snap else "" lines = _line_count(log_path) if log_path and os.path.isfile(log_path) else 0 payload = {"session": os.path.abspath(log_path) if log_path else "", "lines": lines} + # FIX 2(a) (#623): record the beat's first/cold-open signal so cmd_check can tell a TRUE + # cold open (first=1: no prior session legitimately existed) from a CONTINUING beat whose + # mark came back EMPTY (first=0: a mark-write bug — the baseline was lost, so any "later" + # prose the #357 fallback recovers is the PREVIOUS beat's, recycled). Only "0"/"1" are + # recorded; anything else (or absent) leaves the legacy fail-open behavior in cmd_check. + if first in ("0", "1"): + payload["first"] = first with open(mark_file, "w", encoding="utf-8") as f: json.dump(payload, f) return 0 @@ -134,6 +141,7 @@ def cmd_check(state_dir, mark_file): mark = json.load(f) marked_session = str(mark.get("session") or "") marked_lines = int(mark.get("lines") or 0) + marked_first = str(mark.get("first") or "") except Exception: return 0 # unreadable mark -> fail OPEN (assume genuine; legacy behavior) snap = _snapshot_path(state_dir) @@ -142,6 +150,15 @@ def cmd_check(state_dir, mark_file): cur = _session_log_path(snap) if not cur or not os.path.isfile(cur): return 1 + # FIX 2(a) (#623): an EMPTY marked_session on a CONTINUING beat (first=0) is a mark-write + # bug — no baseline was captured, so scanning from line 0 would match the PREVIOUS beat's + # prose as "new" and stamp a recycled (dead) beat fallback_recovered:true. Force-fail (1 = + # NOT genuine) here. We require BOTH an empty mark AND the recorded first=0 signal so a TRUE + # first-prose-then-die cold open (first=1, where no session legitimately existed at mark + # time) is NOT wrongly failed — it keeps the legacy scan-from-0 path below. A mark WITHOUT a + # recorded first signal (legacy/external callers) also keeps the legacy fail-open path. + if not marked_session and cur and marked_first == "0": + return 1 # A DIFFERENT session file than the marked one (the beat started a new session, or no # session existed at mark time) means every row in it is new — scan from line 0. skip = marked_lines if os.path.abspath(cur) == marked_session else 0 @@ -166,10 +183,14 @@ def cmd_check(state_dir, mark_file): def main(argv): if len(argv) < 4 or argv[1] not in ("mark", "check"): - print("usage: dm_beat_mark.py mark|check ", file=sys.stderr) + print("usage: dm_beat_mark.py mark|check [first]", file=sys.stderr) return 0 # never fail a beat over a usage error try: - return (cmd_mark if argv[1] == "mark" else cmd_check)(argv[2], argv[3]) + if argv[1] == "mark": + # FIX 2(a) (#623): optional 5th arg = the beat's first/cold-open signal ("1"|"0"). + first = argv[4] if len(argv) > 4 else None + return cmd_mark(argv[2], argv[3], first) + return cmd_check(argv[2], argv[3]) except Exception: return 0 # any internal failure fails OPEN diff --git a/qa/lib_beat_driver.sh b/qa/lib_beat_driver.sh index 9021fbea..a426d2db 100644 --- a/qa/lib_beat_driver.sh +++ b/qa/lib_beat_driver.sh @@ -433,8 +433,15 @@ clawdnd_dm_final_text() { # beat's. Best-effort (never fails a beat); standalone python — no heredoc-in-$() (bash 3.2). clawdnd_dm_prebeat_mark() { local state_dir="$1" + # FIX 2(a) (#623): pass the beat's first/cold-open signal ("1"=cold open, "0"=continuing) so + # dm_beat_mark.py can force-fail a CONTINUING beat whose mark came back empty (a mark-write bug + # that else recycles the previous beat's prose). Default "1" (treat as cold open / fail-open) + # when the caller did not pass it — never tightens an unknown caller's behavior. + local first="${2:-1}" local mark_py="${CLAWDND_LIB_DIR:-$(dirname "${BASH_SOURCE[0]}")}/dm_beat_mark.py" - python3 "$mark_py" mark "$state_dir" "$state_dir/.dm_prebeat_mark" 2>/dev/null || true + # Drop the 2>/dev/null swallow (FIX 2(a)): an empty/failed mark must surface on stderr so a + # mark-write bug is visible in the beat log instead of silently producing a recycled beat. + python3 "$mark_py" mark "$state_dir" "$state_dir/.dm_prebeat_mark" "$first" || true return 0 } diff --git a/qa/release_readiness.py b/qa/release_readiness.py index fd5c8dbd..02653d85 100644 --- a/qa/release_readiness.py +++ b/qa/release_readiness.py @@ -817,6 +817,13 @@ def main() -> int: "run_build_sha": rj.get("build_sha") or "", "part_b_result": (rj.get("part_b") or {}).get("persona_loop") or "n/a", "part_b_score_pass": bool((rj.get("part_b") or {}).get("score_pass")), + # FIX 1 (#623 false-cap): a NON-quota player_rc!=0 CRASH is INCONCLUSIVE evidence + # (re-measure), not a product-quality fail. ui_playtest_app.sh stamps this true on + # such a crash. We EXCLUDE these personas from score_pass_failed_personas / the + # cross_persona_sat quality gate and instead surface them as an evidence gap (RED-cap + # as harness_contaminated). The discriminator is the PROCESS exit, never the score — + # a played low-score run exits rc=0, harness_error=False, and stays a clean quality RED. + "part_b_harness_error": bool((rj.get("part_b") or {}).get("harness_error")), "part_a_result": (rj.get("part_a") or {}).get("result") or "n/a", "part_a_failure_bucket": (rj.get("part_a") or {}).get("failure_bucket") or "", "part_a_failure_detail": (rj.get("part_a") or {}).get("failure_detail") or "", @@ -835,7 +842,13 @@ def main() -> int: avg_sat = sum(sats) / len(sats) if sats else 0.0 any_gave_up = any(p["gave_up"] for p in persona_scores) any_completed = any(p["completed_intro_flow"] for p in persona_scores) - score_pass_failed_personas = [str(p["persona"]) for p in persona_scores if not p.get("part_b_score_pass")] + # FIX 1 (#623 false-cap): a persona whose player process CRASHED (part_b_harness_error) is + # INCONCLUSIVE, not a quality fail — exclude it from the score_pass quality gate and route it + # through the evidence_gaps/harness_contaminated machinery below (RED-cap as a re-measure). + # A played low-score run has harness_error=False and STILL fails the gate (a clean quality RED). + harness_error_personas = [p for p in persona_scores if p.get("part_b_harness_error")] + score_pass_failed_personas = [str(p["persona"]) for p in persona_scores + if not p.get("part_b_score_pass") and not p.get("part_b_harness_error")] score_pass_complete = bool(persona_scores) and not score_pass_failed_personas total_critical = sum(p["critical"] for p in persona_scores) total_console_errors = sum(p["console_errors"] for p in persona_scores) @@ -947,7 +960,23 @@ def main() -> int: "missing": f"{h['run']}/{h.get('missing') or 'score.json'}", "detail": f"persona={h.get('persona') or 'unknown'} detail={h.get('detail') or ''} part_a={h.get('part_a') or 'n/a'} part_b={h.get('part_b') or 'n/a'} {buckets}".strip(), }) - failed_part_b = [p for p in persona_scores if p.get("part_b_result") != "PASS"] + # FIX 1 (#623 false-cap): a persona whose player process CRASHED (non-quota player_rc!=0) is + # INCONCLUSIVE — surface it as a cross_persona_sat EVIDENCE GAP (re-measure), exactly like a + # harness_failure, so it RED-caps as harness_contaminated rather than a quality FAIL. It is + # already excluded from score_pass_failed_personas (the quality gate) above. + for p in harness_error_personas: + evidence_gaps.append({ + "gate": "cross_persona_sat", + "missing": f"{p['run']}/run.json part_b.harness_error", + "detail": f"persona={p.get('persona') or 'unknown'} part_b player process crashed (non-quota player_rc!=0) " + f"— INCONCLUSIVE, re-measure (not a quality fail) " + f"failure_bucket={p.get('part_b_failure_bucket') or ''} failure_detail={p.get('part_b_failure_detail') or ''}".strip(), + }) + # A crashed (harness_error) persona's part_b is "FAIL", but it is INCONCLUSIVE, not a dropped + # product arc — exclude it from the arc_completed product-failure attribution (it's already a + # cross_persona_sat evidence gap above). Genuine non-PASS played runs still flow through here. + failed_part_b = [p for p in persona_scores + if p.get("part_b_result") != "PASS" and not p.get("part_b_harness_error")] for p in failed_part_b: bucket = p.get("part_b_failure_bucket") or "" detail = p.get("part_b_failure_detail") or "" @@ -1242,6 +1271,9 @@ def main() -> int: "arc_completed": any_completed, "cross_persona_satisfaction": round(avg_sat, 1), "score_pass_failed_personas": score_pass_failed_personas, + # FIX 1 (#623 false-cap): personas reclassified as INCONCLUSIVE (player-process crash, + # non-quota) — excluded from score_pass_failed_personas, surfaced as evidence gaps. + "harness_error_personas": [str(p["persona"]) for p in harness_error_personas], "any_gave_up": any_gave_up, "total_critical_bugs": total_critical, "total_console_errors": total_console_errors, diff --git a/qa/test_assert_behavioral.py b/qa/test_assert_behavioral.py index 07c39ddb..9ca4cb06 100644 --- a/qa/test_assert_behavioral.py +++ b/qa/test_assert_behavioral.py @@ -661,3 +661,90 @@ def test_structural_completeness_silent_in_combat_sprint(tmp_path): rc, out = _run_gate(tmp_path, events, state, env=env) assert rc == 0, out assert "structural_completeness" not in out + + +# ── FIX 4: party_traveled in-place-progression exception (#623 false-cap) ───────── +# A multi-beat arc that RESOLVED in a single location (clock advanced + quest completed, +# beats >= 8) is a SUCCESS — it must PASS party_traveled. A frozen opening (day 1/morning, +# no resolved quest) must STILL fail (RED). The AND keeps the frozen stall red. + +def _single_scene_state(*, day, tod=None, quest_completed, visited_count=1): + """A single-location final state (visited_count locations visited). `day`/`tod` drive + clock_advanced; `quest_completed` drives arc_resolved. No companion → the structural + floor (>=10 beats + companion) stays silent so we isolate party_traveled.""" + locs = {"loc_a": {"visited": True}} + if visited_count >= 2: + locs["loc_b"] = {"visited": True} + state = { + "leveling_mode": "milestone", + "day": day, + "party": ["pc1"], + "locations": locs, + "characters": {"pc1": {"name": "Dal", "kind": "player", "location_id": "loc_a"}}, + } + if tod is not None: + state["time_of_day"] = tod + if quest_completed: + state["quests"] = {"q1": {"title": "Tavern Negotiation", "status": "completed", + "objectives": ["strike the bargain"], + "completed_objectives": ["strike the bargain"]}} + else: + state["quests"] = {"q1": {"title": "Tavern Negotiation", "status": "active", + "objectives": ["strike the bargain"], + "completed_objectives": []}} + return state + + +def test_party_traveled_passes_single_scene_arc_that_progressed_in_place(tmp_path): + # visited=1, but day advanced (day 2) AND the quest completed AND beats>=8 → the in-place + # progression exception fires → party_traveled PASSES (a resolved one-location drama). + events = _dm_text_turns(9) # session_beats=9 (>= SINGLE_SCENE_MIN_BEATS 8) + state = _single_scene_state(day=2, quest_completed=True, visited_count=1) + rc, out = _run_gate(tmp_path, events, state) + assert "[PASS] party_traveled" in out, out + assert "[FAIL] party_traveled" not in out, out + assert rc == 0, out # clock advanced too, so world_advanced_time also passes + + +def test_party_traveled_still_red_on_frozen_run(tmp_path): + # visited=1, day==1/morning (clock never moved), no completed quest, beats>=8 → the + # exception's AND fails (clock_advanced False, arc_resolved False) → party_traveled RED. + events = _dm_text_turns(9) + state = _single_scene_state(day=1, tod="morning", quest_completed=False, visited_count=1) + rc, out = _run_gate(tmp_path, events, state) + assert "[FAIL] party_traveled" in out, out + assert rc == 1, out + + +def test_party_traveled_still_red_when_clock_advanced_but_arc_unresolved(tmp_path): + # Guard against broadening to clock-only: day advanced but NO completed quest → the AND + # still fails (arc_resolved False) → party_traveled stays RED. + events = _dm_text_turns(9) + state = _single_scene_state(day=3, quest_completed=False, visited_count=1) + rc, out = _run_gate(tmp_path, events, state) + assert "[FAIL] party_traveled" in out, out + assert rc == 1, out + + +def test_party_traveled_red_despite_status_blind_quest_tool_count(tmp_path): + # GATE-WEAKENING REGRESSION (adversarial-verified): a FROZEN single scene where the DM + # advanced the clock AND called set_quest_status — the OLD status-blind quest_resolved + # tool-count would have flipped arc_resolved True and let this DEAD scene PASS via the + # in-place exception. arc_resolved now requires a snapshot quest at status=="completed"; + # the quest stays "active" (quest_completed=False) → arc_resolved False → party_traveled + # stays RED even though set_quest_status was called. + events = _dm_text_turns(9) + _toolcall("set_quest_status") + state = _single_scene_state(day=3, quest_completed=False, visited_count=1) + rc, out = _run_gate(tmp_path, events, state) + assert "[FAIL] party_traveled" in out, out + assert rc == 1, out + + +def test_party_traveled_still_red_when_arc_resolved_but_too_few_beats(tmp_path): + # Guard against broadening to beats-only / arc-only: clock advanced + quest completed but + # only 7 beats (< SINGLE_SCENE_MIN_BEATS 8) → exception not met → party_traveled RED. + events = _dm_text_turns(7) + state = _single_scene_state(day=2, quest_completed=True, visited_count=1) + rc, out = _run_gate(tmp_path, events, state) + assert "[FAIL] party_traveled" in out, out + assert rc == 1, out diff --git a/qa/test_dm_beat_mark.py b/qa/test_dm_beat_mark.py new file mode 100644 index 00000000..32e9bc75 --- /dev/null +++ b/qa/test_dm_beat_mark.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Tests for qa/dm_beat_mark.py — the #357 recycle discriminator (SYN-01). + +Focus: FIX 2(a) (#623). An EMPTY marked_session on a CONTINUING beat (first=0) is a mark-write +bug; scanning from line 0 would match the PREVIOUS beat's prose as "new" and stamp a recycled +(dead) beat fallback_recovered:true. cmd_check must force-fail (return 1) in that case — while +NEVER wrongly failing a true first-prose-then-die COLD OPEN (first=1, where no session +legitimately existed at mark time) and keeping the legacy fail-open for marks with no recorded +first signal. + +Stdlib + pytest only; self-contained. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import dm_beat_mark as dbm # noqa: E402 + + +def _write_session(state_dir: Path, campaign: str, sid: str, rows: list[dict]) -> Path: + """Write campaigns//{snapshot.json, sessions/.jsonl} and return the log path.""" + camp = state_dir / "campaigns" / campaign + sessions = camp / "sessions" + sessions.mkdir(parents=True, exist_ok=True) + # snapshot must be > 1 byte and select this campaign (largest non-empty snapshot wins). + (camp / "snapshot.json").write_text( + json.dumps({"active_session_id": sid, "session_ids": [sid]}), encoding="utf-8" + ) + log = sessions / f"{sid}.jsonl" + log.write_text("\n".join(json.dumps(r) for r in rows) + ("\n" if rows else ""), encoding="utf-8") + return log + + +def _prose_row(text: str) -> dict: + return {"kind": "narration", "text": text} + + +# ── cmd_mark records the first signal ──────────────────────────────────────────── + +def test_cmd_mark_records_first_signal(tmp_path): + state = tmp_path / "state" + _write_session(state, "camp1", "sess1", [_prose_row("Opening prose.")]) + mark_file = state / ".dm_prebeat_mark" + assert dbm.cmd_mark(str(state), str(mark_file), "0") == 0 + payload = json.loads(mark_file.read_text(encoding="utf-8")) + assert payload.get("first") == "0" + # cold open + assert dbm.cmd_mark(str(state), str(mark_file), "1") == 0 + assert json.loads(mark_file.read_text(encoding="utf-8")).get("first") == "1" + # absent signal -> no "first" key (legacy fail-open behavior in cmd_check) + assert dbm.cmd_mark(str(state), str(mark_file), None) == 0 + assert "first" not in json.loads(mark_file.read_text(encoding="utf-8")) + + +# ── FIX 2(a): empty mark + continuing beat -> NOT genuine ───────────────────────── + +def test_check_empty_mark_continuing_beat_is_not_genuine(tmp_path): + # The mark-write bug: a CONTINUING beat's mark came back empty (no baseline). The session log + # holds only the PREVIOUS beat's prose. cmd_check must return 1 (recycled, NOT genuine) so the + # dead beat is not masked. + state = tmp_path / "state" + _write_session(state, "camp1", "sess1", [_prose_row("Previous beat's prose.")]) + mark_file = state / ".dm_prebeat_mark" + # Simulate the empty mark a write-bug produced, but WITH the first=0 (continuing) signal. + mark_file.write_text(json.dumps({"session": "", "lines": 0, "first": "0"}), encoding="utf-8") + assert dbm.cmd_check(str(state), str(mark_file)) == 1 + + +def test_check_empty_mark_cold_open_with_new_prose_stays_genuine(tmp_path): + # HARD CONSTRAINT: a TRUE first-prose-then-die COLD OPEN (first=1) legitimately had no session + # at mark time. Its prose IS new (logged this beat). It must NOT be force-failed — the legacy + # scan-from-0 path applies and finds the new prose -> genuine (0). + state = tmp_path / "state" + _write_session(state, "camp1", "sess1", [_prose_row("The cold open's fresh opening prose.")]) + mark_file = state / ".dm_prebeat_mark" + mark_file.write_text(json.dumps({"session": "", "lines": 0, "first": "1"}), encoding="utf-8") + assert dbm.cmd_check(str(state), str(mark_file)) == 0 + + +def test_check_empty_mark_no_first_signal_is_legacy_fail_open(tmp_path): + # A mark with NO recorded first signal (legacy / external caller) keeps the legacy fail-open + # scan-from-0 behavior — it must NOT be force-failed just because the session is empty. + state = tmp_path / "state" + _write_session(state, "camp1", "sess1", [_prose_row("Some prose.")]) + mark_file = state / ".dm_prebeat_mark" + mark_file.write_text(json.dumps({"session": "", "lines": 0}), encoding="utf-8") + assert dbm.cmd_check(str(state), str(mark_file)) == 0 + + +# ── A normal mark with a real baseline still works (no regression) ──────────────── + +def test_check_real_baseline_new_prose_after_mark_is_genuine(tmp_path): + # Mark captured a real baseline (the session existed with N lines). A NEW prose row was logged + # AFTER the mark -> genuine (0). This is the healthy mid-session recovery path. + state = tmp_path / "state" + log = _write_session(state, "camp1", "sess1", [_prose_row("Beat 1 prose (baseline).")]) + mark_file = state / ".dm_prebeat_mark" + # Mark AT the current baseline (1 line) via the real cmd_mark on a continuing beat. + assert dbm.cmd_mark(str(state), str(mark_file), "0") == 0 + baseline = json.loads(mark_file.read_text(encoding="utf-8")) + assert baseline["session"] != "" # a real baseline WAS captured + assert baseline["lines"] == 1 + # Now append a NEW prose row past the mark. + with open(log, "a", encoding="utf-8") as f: + f.write(json.dumps(_prose_row("Beat 2 prose (new this beat).")) + "\n") + assert dbm.cmd_check(str(state), str(mark_file)) == 0 + + +def test_check_real_baseline_no_new_prose_is_recycled(tmp_path): + # Mark captured a real baseline; NOTHING new was logged past it -> recycled (1), the existing + # discriminator behavior (unchanged by FIX 2(a)). + state = tmp_path / "state" + _write_session(state, "camp1", "sess1", [_prose_row("Beat 1 prose (baseline).")]) + mark_file = state / ".dm_prebeat_mark" + assert dbm.cmd_mark(str(state), str(mark_file), "0") == 0 + # No append: nothing new past the mark. + assert dbm.cmd_check(str(state), str(mark_file)) == 1 + + +def test_check_unreadable_mark_fails_open(tmp_path): + # A corrupt/unreadable mark (a REAL baseline that we just can't parse) keeps fail-open (0). + state = tmp_path / "state" + _write_session(state, "camp1", "sess1", [_prose_row("Prose.")]) + mark_file = state / ".dm_prebeat_mark" + mark_file.write_text("{not json", encoding="utf-8") + assert dbm.cmd_check(str(state), str(mark_file)) == 0 + + +def test_main_mark_passes_first_arg(tmp_path): + # The CLI front door threads the optional 5th arg (first) into cmd_mark. + state = tmp_path / "state" + _write_session(state, "camp1", "sess1", [_prose_row("Prose.")]) + mark_file = state / ".dm_prebeat_mark" + rc = dbm.main(["dm_beat_mark.py", "mark", str(state), str(mark_file), "0"]) + assert rc == 0 + assert json.loads(mark_file.read_text(encoding="utf-8")).get("first") == "0" + + +if __name__ == "__main__": + import pytest + + raise SystemExit(pytest.main([__file__, "-q", "-p", "no:xdist"])) diff --git a/qa/test_release_readiness.py b/qa/test_release_readiness.py index a40885b9..f2a46892 100644 --- a/qa/test_release_readiness.py +++ b/qa/test_release_readiness.py @@ -766,6 +766,99 @@ def test_low_product_score_is_clean_red_not_harness_contaminated(self): self.assertEqual(payload["failed_gates"], ["cross_persona_sat"]) self.assertEqual(payload["signals"]["image_request_denominator"], 5) + def test_part_b_harness_error_is_inconclusive_evidence_gap_not_quality_fail(self): + # FIX 1 (#623 false-cap): one persona's player process CRASHED (part_b.harness_error=true, + # persona_loop=FAIL, score_pass=false). It must be reclassified as INCONCLUSIVE: surfaced + # as a cross_persona_sat EVIDENCE GAP (harness_contaminated), EXCLUDED from + # score_pass_failed_personas, and it must still BLOCK release (RED). The other four + # personas played clean (score_pass=true, harness_error absent). + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + runs = [] + for persona in ("newbie", "veteran", "adversarial", "narrative", "optimizer"): + run = tmp / f"gate-{persona}" + player = run / "player" + player.mkdir(parents=True) + crashed = persona == "adversarial" + (run / "score.json").write_text( + json.dumps( + { + "run": f"gate-{persona}", + "persona": persona, + "pass": not crashed, + "completed_intro_flow": not crashed, + "persona_satisfaction": 2 if crashed else 9, + "gave_up": False, + "bug_reports_critical": 0, + "console_errors": 0, + "image_404s": 0, + } + ), + encoding="utf-8", + ) + part_b = ( + {"persona_loop": "FAIL", "score_pass": False, "harness_error": True} + if crashed + else {"persona_loop": "PASS", "score_pass": True} + ) + (run / "run.json").write_text( + json.dumps({"build_sha": "deadbee", "part_a": {"result": "PASS"}, "part_b": part_b}), + encoding="utf-8", + ) + (player / "network.ndjson").write_text( + json.dumps({"url": f"http://127.0.0.1/image?scope={persona}", "status": 200}), + encoding="utf-8", + ) + runs.append(run) + + story = tmp / "story.json" + mech = tmp / "mech.json" + behavioral = tmp / "behavioral.txt" + audit = tmp / "audit.log" + palette = tmp / "session_surface.final.json" + story.write_text(json.dumps({"overall": 5}), encoding="utf-8") + mech.write_text(json.dumps({"overall": 5}), encoding="utf-8") + behavioral.write_text("GREEN\n", encoding="utf-8") + audit.write_text("PASS\n", encoding="utf-8") + palette.write_text(json.dumps({"can_act": True}), encoding="utf-8") + + rc, _text, payload = self.run_rri( + tmp, + "--runs", ",".join(str(r) for r in runs), + "--expected-personas", "newbie,veteran,adversarial,narrative,optimizer", + "--story", str(story), + "--mech", str(mech), + "--behavioral", "GREEN", + "--behavioral-path", str(behavioral), + "--ui-audit", "PASS", + "--ui-audit-log", str(audit), + "--palette-live", "true", + "--palette-source", str(palette), + "--build-sha", "deadbee", + ) + + # INCONCLUSIVE, not a clean quality RED: still blocks release. + self.assertEqual(rc, 1) + self.assertFalse(payload["release_ready"]) + # Reclassified: harness_contaminated (evidence gap), NOT a score_pass quality fail. + self.assertTrue(payload["harness_contaminated"]) + self.assertNotIn("adversarial", payload["signals"]["score_pass_failed_personas"]) + self.assertEqual(payload["signals"]["score_pass_failed_personas"], []) + self.assertIn("adversarial", payload["signals"]["harness_error_personas"]) + # The crash is surfaced as a cross_persona_sat evidence gap referencing harness_error. + gap_gates = {g["gate"] for g in payload["evidence_gaps"]} + self.assertIn("cross_persona_sat", gap_gates) + self.assertTrue( + any("harness_error" in (g.get("missing") or "") for g in payload["evidence_gaps"]), + payload["evidence_gaps"], + ) + # NOT attributed as a dropped product arc (that path is for played non-PASS runs). + self.assertFalse( + any(g["gate"] == "arc_completed" and "adversarial" in (g.get("detail") or "") + for g in payload["evidence_gaps"]), + payload["evidence_gaps"], + ) + def test_green_arguments_without_evidence_paths_are_not_release_ready(self): with tempfile.TemporaryDirectory() as td: tmp = Path(td) diff --git a/qa/ui_playtest_app.sh b/qa/ui_playtest_app.sh index db25ac76..3f0c282a 100755 --- a/qa/ui_playtest_app.sh +++ b/qa/ui_playtest_app.sh @@ -73,11 +73,17 @@ PLAYER_AGENT="${WOS_APP_PLAYER_AGENT:-claude}" NATIVE_AUTOSTART="${WOS_APP_NATIVE_AUTOSTART:-0}" CODEX_HOME_FOR_APP="${WOS_APP_CODEX_HOME:-${CODEX_HOME:-}}" # Part-A cold-open mint deadline (seconds). The #356 banner spawns the DM cold open, whose -# --effort max world-build runs ~280–400s (qa/lib_beat_driver.sh WORLDOS_COLDOPEN_TIMEOUT=400); -# the old 210s poll (70 × 3s) was SHORTER than a max-effort cold open, so a slow-but-healthy -# mint timed out as a spurious FAIL. Give the poll a 420s budget (just past the cold-open -# deadline), env-overridable for fast inner loops. -PART_A_DEADLINE="${WOS_APP_PART_A_DEADLINE:-420}" +# --effort max world-build runs ~280–400s. FIX 3 (#623): the old FLAT 420 was SHORTER than the +# DM cold-open's OWN model-aware timeout (clawdnd_dm_timeout 1 = 500 opus / 550 non-opus), so a +# healthy-but-slow cold open in the 420–500s band was abandoned by THIS poll ~80s before the DM +# itself would have given up — a coin-flip flaky leg. Derive the deadline FROM that same tier +# (cold-open timeout + a ~90s mint/IO margin) so the poll always outlasts the cold open it waits +# on. lib_beat_driver.sh is already sourced (line 62), so clawdnd_dm_timeout is in scope. The +# WORLDOS_COLDOPEN_TIMEOUT env flows through clawdnd_dm_timeout; the explicit +# WOS_APP_PART_A_DEADLINE override still wins (fast inner loops). +_part_a_coldopen_tier="$(CLAWDND_DM_MODEL="$(worldos_env DM_MODEL opus)" clawdnd_dm_timeout 1)" +case "$_part_a_coldopen_tier" in ''|*[!0-9]*) _part_a_coldopen_tier=500 ;; esac +PART_A_DEADLINE="${WOS_APP_PART_A_DEADLINE:-$(( _part_a_coldopen_tier + 90 ))}" # Launcher-viewer readiness window (seconds). The .app's built-in viewer must answer # /openworlds/ 200 before we click/auto-start. Two regimes: # * CLICK path (default): a launcher UI shell serves /openworlds/ 200 almost immediately, @@ -312,6 +318,45 @@ dm_spend() { printf '%s' "$total" } +# FIX 3 (#623): Part-A cold-open LIVENESS. A poll that abandons a healthy-but-slow cold open at a +# flat deadline is a coin-flip flaky leg; a poll that just inflates the timeout blindly lets a +# DEAD cold open hang. So distinguish the two: the cold open is "alive" while its DM stream is +# still being written (dm.combined.jsonl mtime advanced within the freshness window) OR the +# play.sh/play_party.sh DM process for this run is still up. A dead/never-started cold open (no +# run dir, stale log, AND no proc) is NOT alive → it fails PROMPTLY at the deadline. +COLDOPEN_LIVENESS_WINDOW_S="${WOS_APP_COLDOPEN_LIVENESS_WINDOW_S:-120}" +# Portable file mtime in epoch seconds (BSD stat on macOS, GNU stat on the Linux VM). Echoes '' +# when the file is absent/unreadable. +_file_mtime_epoch() { # $1=path + local p="$1" + [ -f "$p" ] || return 1 + stat -f %m "$p" 2>/dev/null || stat -c %Y "$p" 2>/dev/null || return 1 +} +# rc 0 if the cold-open for run dir $1 still shows forward progress (fresh DM stream OR a live DM +# proc). Empty $1 (nothing minted yet) ⇒ not-live (rc 1) so a never-started cold open fails fast. +coldopen_is_live() { # $1=run_dir_name + local run_dir="$1" + [ -n "$run_dir" ] || return 1 + # (i) DM stream freshness: dm.combined.jsonl mtime advanced within the window. + local log mt now + log="$ROOT/play-state/$run_dir/dm.combined.jsonl" + mt="$(_file_mtime_epoch "$log")" || mt="" + if [ -n "$mt" ]; then + now="$(date +%s)" + if [ $(( now - mt )) -le "$COLDOPEN_LIVENESS_WINDOW_S" ]; then + return 0 + fi + fi + # (ii) DM process still alive (the play.sh/play_party.sh loop carries the run id positionally, + # the SAME signal the teardown pkill matches). A live proc = the cold open is still working. + if pgrep -f " $run_dir " >/dev/null 2>&1 \ + || pgrep -f "play_party.sh .* $run_dir" >/dev/null 2>&1 \ + || pgrep -f "play.sh .* $run_dir" >/dev/null 2>&1; then + return 0 + fi + return 1 +} + ############################################################################################### # PART A — NATIVE-TRANSITION GATE (re-verifies #356) ############################################################################################### @@ -618,9 +663,23 @@ PY # nothing has minted: on a busy multi-app desktop another window can steal focus between the # activate and the CGEvent, swallowing the click — a re-click recovers it. local part_a_polls=$(( PART_A_DEADLINE / 3 )); [ "$part_a_polls" -lt 1 ] && part_a_polls=1 - a_log "[A] polling for a minted live session (new run dir + can_act:true on a new port; deadline ${PART_A_DEADLINE}s / ${part_a_polls} polls)…" + # FIX 3 (#623): the poll is LIVENESS-AWARE. The hard deadline is PART_A_DEADLINE (now derived + # from the cold-open tier, so it already outlasts the cold open). Past it, we grant a BOUNDED + # grace extension ONLY while the cold open is still making forward progress (coldopen_is_live: + # fresh DM stream OR a live DM proc) — a healthy-but-slow mint finishes instead of a coin-flip + # FAIL. A DEAD/never-started cold open (no run dir, stale log, no proc) is NOT live, so the + # grace never triggers and it fails PROMPTLY at the deadline. The grace is capped so a + # pathologically-slow-but-"alive" cold open still terminates. The PASS condition below + # (can_act:true && minted_run) is UNCHANGED — the real integrity assertion. + local part_a_grace_cap_s="${WOS_APP_PART_A_GRACE_CAP_S:-$(( COLDOPEN_LIVENESS_WINDOW_S * 3 ))}" + local part_a_start; part_a_start="$(date +%s)" + local part_a_hard_deadline=$(( part_a_start + PART_A_DEADLINE )) + local part_a_max_deadline=$(( part_a_hard_deadline + part_a_grace_cap_s )) + a_log "[A] polling for a minted live session (new run dir + can_act:true on a new port; deadline ${PART_A_DEADLINE}s, liveness grace ≤${part_a_grace_cap_s}s)…" local minted_port="" minted_run="" can_act="false" - for i in $(seq 1 "$part_a_polls"); do + local i=0 grace_logged=0 + while :; do + i=$(( i + 1 )) # (i) a new play-state dir local now_dirs new_dir now_dirs="$(ls -1 "$ROOT/play-state" 2>/dev/null | sort || true)" @@ -652,6 +711,25 @@ PY a_log "[A] MINTED: run=$minted_run port=$minted_port can_act=true (after ${i} polls)" break fi + # Deadline + liveness gate. Before the hard deadline: keep polling. Past it: keep polling ONLY + # while the cold open is demonstrably alive (and still under the grace cap); otherwise STOP and + # fall through to the FAIL classification — a dead/never-started cold open fails promptly. + local _now; _now="$(date +%s)" + if [ "$_now" -ge "$part_a_hard_deadline" ]; then + if [ "$_now" -ge "$part_a_max_deadline" ]; then + a_log "[A] grace cap reached (${part_a_grace_cap_s}s past the ${PART_A_DEADLINE}s deadline) — giving up the mint poll." + break + fi + if coldopen_is_live "$minted_run"; then + if [ "$grace_logged" = "0" ]; then + a_log "[A] past the ${PART_A_DEADLINE}s deadline but the cold open is STILL LIVE (run='${minted_run:-none}') — extending within the ${part_a_grace_cap_s}s grace." + grace_logged=1 + fi + else + a_log "[A] past the ${PART_A_DEADLINE}s deadline and the cold open is NOT live (run='${minted_run:-none}', stale stream + no DM proc) — failing promptly." + break + fi + fi sleep 3 done @@ -739,6 +817,12 @@ PY # PART B — PERSONA LOOP (the .app-faithful backend + the real palette persona) ############################################################################################### PART_B_RESULT="skipped"; PART_B_PLAYER_COST="0"; PART_B_SCORE_PASS="false"; PART_B_FAILURE_BUCKET=""; PART_B_FAILURE_DETAIL="" +# FIX 1 (#623 false-cap): a NON-zero player PROCESS exit (a harness/player CRASH) that is NOT a +# 429 is INCONCLUSIVE evidence — a "re-measure", not a product-quality FAIL. This flag threads +# that fact into run.json (part_b.harness_error) so release_readiness.py RED-caps it as an +# evidence gap, never as a score_pass quality fail. Default false; only a non-zero, non-quota +# player_rc flips it true. +PART_B_HARNESS_ERROR="false" run_part_b() { log "=== PART B: persona loop on the .app-faithful backend ===" [ -f "$PERSONA_FILE" ] || { log "[B] no persona brief at $PERSONA_FILE — skipping"; PART_B_RESULT="no_persona"; set_bucket_pair B "$(bucket_pair no_actor "persona brief missing: $PERSONA_FILE")"; return 1; } @@ -1026,6 +1110,18 @@ PY PART_B_RESULT="FAIL" PART_B_SCORE_PASS="false" set_bucket_pair B "$(classify_part_b_failure_from_artifacts "$RUNDIR" "$PART_B_RESULT")" + # FIX 1 (#623 false-cap): discriminate a HARNESS/player CRASH from a quality fail. The + # discriminator is player_rc (the PROCESS exit), NEVER the quality score — a persona that + # PLAYS to completion and scores low (incl. give_up) exits rc=0 and flows through the + # unchanged score_pass quality gate above. Only a NON-ZERO player-process exit is a crash. + # A 429/session-limit is its OWN honest infra path (release_readiness infra_abort_hint / + # the sweep's QUOTA_ABORT), so exclude it here — only a NON-quota crash is "inconclusive". + if [ "$player_rc" -ne 0 ] \ + && ! grep -qriE "session limit|HTTP 429|hit your (session|usage) limit" \ + "$RUNDIR/backend.log" "$PLAYERDIR/player.err" 2>/dev/null; then + PART_B_HARNESS_ERROR="true" + log "[B] player_rc=$player_rc (non-quota harness/player crash) — marking part_b.harness_error=true (INCONCLUSIVE, re-measure; NOT a quality fail)" + fi fi [ -f "$RUNDIR/summary.md" ] && { echo "----- part B summary.md -----"; cat "$RUNDIR/summary.md"; } } @@ -1152,13 +1248,20 @@ python3 - "$RUNDIR/run.json" "$RUN" "$WORLD" "$PERSONA" "$BEATS" "$BUDGET" "$BUI "$FINAL_DM_SPEND" "$PART_B_PLAYER_COST" "$TOTAL_SPEND" \ "$PART_A_FAILURE_BUCKET" "$PART_A_FAILURE_DETAIL" "$PART_B_FAILURE_BUCKET" "$PART_B_FAILURE_DETAIL" \ "$PART_B_PROVIDER" "$PLAYER_AGENT" "$TOP_PROVIDER_FAMILY" "$TOP_AUTH_SURFACE" "$TOP_DM_MODEL" "$TOP_PLAYER_MODEL" \ - "deterministic-ui-playtest" "qa/ui_playtest_score.py" <<'PY' + "deterministic-ui-playtest" "qa/ui_playtest_score.py" "$PART_B_HARNESS_ERROR" <<'PY' import json, sys, datetime (out, run, world, persona, beats, budget, sha, ver, part, a_res, a_run, a_port, a_kept, a_first_turn_ready, b_res, b_score_pass, dm_spend, player_cost, total) = sys.argv[1:20] a_bucket, a_detail, b_bucket, b_detail = sys.argv[20:24] provider, player_agent = sys.argv[24:26] provider_family, auth_surface, dm_model, player_model, scorer_provider, scorer_model = sys.argv[26:32] +# FIX 1 (#623 false-cap): appended as the LAST trailing argv so the existing positional slices +# above are untouched (the two literals at argv[30]/[31] are pre-existing/unused; the flag is +# argv[32]). A NON-quota player_rc!=0 crash sets this true → run.json part_b.harness_error, which +# release_readiness.py reads to RED-cap the persona as INCONCLUSIVE (evidence gap), NOT a +# score_pass quality fail. Bounds-guarded so it defaults false when absent (Part-A-only / older +# runs that predate this trailing arg). +b_harness_error = (sys.argv[32] == "true") if len(sys.argv) > 32 else False json.dump({ "run": run, "world": world, "persona": persona, "beats_cap": int(beats), "budget_usd": float(budget), "build_sha": sha, "version": ver, "part": part, @@ -1175,6 +1278,7 @@ json.dump({ "dm_model": dm_model, "player_model": player_model, "scorer_provider": scorer_provider, "scorer_model": scorer_model, "original_result": b_res, + "harness_error": b_harness_error, "failure_bucket": b_bucket or None, "failure_detail": b_detail or None}, "spend_usd": {"dm_and_companions": round(float(dm_spend or 0), 4), diff --git a/qa/vm/sweep_v2.sh b/qa/vm/sweep_v2.sh index 7039fa5a..e645ba1a 100755 --- a/qa/vm/sweep_v2.sh +++ b/qa/vm/sweep_v2.sh @@ -113,6 +113,27 @@ run_persona(){ # $1=persona $2=port -> writes results/score-$1.json > "$RES/vm2-$persona.log" 2>&1 local rc=$? lsof -ti:$port 2>/dev/null | xargs kill -9 2>/dev/null + # FIX 1 (#623 false-cap): a NON-ZERO player/harness PROCESS exit (rc!=0) is a harness CRASH, + # not a product-quality signal — it must NOT be laundered into a score_pass quality fail. + # RETRY ONCE on a clean store before we believe it. A 429 still short-circuits to the honest + # quota path (reuse quota_tripped), so a quota abort is never spent on a pointless retry. Only + # when the RE-RUN also exits rc!=0 do we keep the result (ui_playtest_app.sh has by then + # stamped part_b.harness_error=true, which the RRI rollup reads as INCONCLUSIVE, not a FAIL). + local _bl="qa/ui_playtest_runs/vm2-$persona/backend.log" + if [ "$rc" -ne 0 ] && ! quota_tripped "$_bl"; then + note " $persona rc=$rc (non-quota harness crash) — retrying ONCE on a clean store" + pkill -f "play.sh baldurs-gate vm2-$persona" 2>/dev/null + pkill -f "play_party.sh baldurs-gate vm2-$persona" 2>/dev/null + if [ -n "$persona" ]; then + rm -rf "play-state/vm2-$persona" "play-state/vm2-$persona-b" 2>/dev/null + fi + WOS_APP_PART=B WOS_APP_SKIP_BUILD=1 WOS_APP_PREFERRED_PORT=$port \ + timeout 2400 bash qa/ui_playtest_app.sh "vm2-$persona" baldurs-gate "$persona" 40 18.00 \ + >> "$RES/vm2-$persona.log" 2>&1 + rc=$? + note " $persona retry rc=$rc" + lsof -ti:$port 2>/dev/null | xargs kill -9 2>/dev/null + fi pkill -f "play.sh baldurs-gate vm2-$persona" 2>/dev/null pkill -f "play_party.sh baldurs-gate vm2-$persona" 2>/dev/null local sc="qa/ui_playtest_runs/vm2-$persona/score.json" diff --git a/scripts/play.sh b/scripts/play.sh index 9a24965d..b3694de7 100755 --- a/scripts/play.sh +++ b/scripts/play.sh @@ -335,7 +335,7 @@ dm_turn() { # SYN-01: pre-beat log-tail mark — ONCE per beat, BEFORE attempt 1 (the in-function retry # below must not re-mark: attempt 1's logged prose still counts as this beat's), so the # caller's clawdnd_resolve_dm_reply can tell a GENUINE #357 recovery from RECYCLED prose. - clawdnd_dm_prebeat_mark "$STATE_DIR" + clawdnd_dm_prebeat_mark "$STATE_DIR" "$first" # #623: prepend the live-progress rule (the ONE shared CLAWDND_LIVE_PROGRESS_RULE in # qa/lib_beat_driver.sh — parity with scripts/play_party.sh + scripts/play_codex_dm.sh) so the DM # logs an EARLY /events narration beat. Its ABSENCE in this SOLO path was the #623 bug: the DM @@ -380,10 +380,21 @@ dm_turn() { # Surface attempt 1's REAL error (it's a stdout result event in $out; only stderr reaches # $DM_LOG.err, so without this the run log shows just a downstream "Session ID … in use"). clawdnd_report_attempt_failure "$out" "$rc" - # timeout(1) exits 124 on the deadline; any nonzero gets ONE retry — on a FRESH session id. A - # failed attempt STILL registered its --session-id, so reusing it dies "Session ID … is already - # in use." → 0-byte → empty narration. A lean beat re-mints via clawdnd_dm_lean_args; the - # cold-open / legacy --resume path re-mints via clawdnd_dm_remint_session_on_retry. (The + # FIX 2(b) (#623): a DEADLINE-KILLED (rc=124) ROUTINE beat (first=0) must NOT retry — the retry + # ESCALATES to the cold-open tier (500/550s), so a routine beat that already burned its full + # 360s deadline would then burn a SECOND ~500s deadline → a ~14-15min single-beat wall blocking + # the sequential queue, for a beat the model clearly can't resolve in time. Send it STRAIGHT to + # the visible-failure path (clawdnd_resolve_dm_reply → clawdnd_chatlog_dm_failed in the caller). + # We STILL retry: (a) any NON-124 failure (real transient API/session errors recover on a fresh + # session id) and (b) a deadline-killed COLD OPEN (first=1) — the one-time max-effort world-build + # legitimately needs the escalated budget. Only the rc==124 && first==0 case is dropped. + if [ "$rc" -eq 124 ] && [ "$first" = "0" ]; then + echo "[play] DM turn rc=124 (deadline) on a routine beat — NOT retrying (a 2nd escalated deadline would block the queue); routing to the visible-failure path." >&2 + else + # timeout(1) exits 124 on the deadline; a retriable failure gets ONE retry — on a FRESH session + # id. A failed attempt STILL registered its --session-id, so reusing it dies "Session ID … is + # already in use." → 0-byte → empty narration. A lean beat re-mints via clawdnd_dm_lean_args; + # the cold-open / legacy --resume path re-mints via clawdnd_dm_remint_session_on_retry. (The # re-ground directive $extra is unchanged — we only refresh the session id.) # F12-1: the retry must NOT reuse attempt 1's deadline verbatim — a healthy-but-long beat that # tripped the routine deadline would just be killed again at the same mark. Escalate attempt 2 @@ -413,6 +424,7 @@ dm_turn() { out="$DM_LOG.$(date +%s%N).jsonl" _dm_invoke; rc=$? [ "$rc" -ne 0 ] && echo "[play] DM turn retry also rc=$rc — relying on engine-logged narration" >&2 + fi fi cat "$out" >> "$COMBINED" 2>/dev/null # SYN-01: shared classification front door — notes the FINAL attempt's $out for the caller's diff --git a/scripts/play_party.sh b/scripts/play_party.sh index 393970f6..f06a6ae6 100755 --- a/scripts/play_party.sh +++ b/scripts/play_party.sh @@ -326,7 +326,7 @@ turn() { # SYN-01: pre-beat log-tail mark — ONCE per beat, BEFORE attempt 1 (the in-function retry # below must not re-mark: attempt 1's logged prose still counts as this beat's), so the # caller's clawdnd_resolve_dm_reply can tell a GENUINE #357 recovery from RECYCLED prose. - clawdnd_dm_prebeat_mark "$STATE_DIR" + clawdnd_dm_prebeat_mark "$STATE_DIR" "$first" # #623: prepend the live-progress rule so the DM logs an early /events narration beat (parity # with play_codex_dm.sh) — without it the long beat shows blank → the perceived drop/hang. msg="$CLAWDND_LIVE_PROGRESS_RULE"$'\n\n'"$msg" @@ -375,6 +375,15 @@ turn() { # consumed --session-id ("Session ID … is already in use."). Lean re-mints itself; the # cold-open / --resume path re-mints via the shared helper. ($extra is unchanged.) clawdnd_report_attempt_failure "$out" "$rc" + # FIX 2(b) (#623): a DEADLINE-KILLED (rc=124) ROUTINE beat (first=0) must NOT retry — the + # retry escalates to the cold-open tier (500/550s), so a routine beat that already burned its + # full 360s deadline would burn a SECOND ~500s deadline (~14-15min single-beat wall) blocking + # the sequential queue. Route it STRAIGHT to the visible-failure path. We STILL retry any + # NON-124 failure (transient API/session errors recover on a fresh session) and a deadline- + # killed COLD OPEN (first=1, the one-time max-effort world-build legitimately needs the budget). + if [ "$rc" -eq 124 ] && [ "$first" = "0" ]; then + echo "[play-party] DM turn rc=124 (deadline) on a routine beat — NOT retrying (a 2nd escalated deadline would block the queue); routing to the visible-failure path." >&2 + else # F12-1: the retry must NOT reuse attempt 1's deadline verbatim — escalate attempt 2 to the # model-aware cold-open tier (never de-escalating below attempt 1's), same as play.sh. beat_timeout="$(clawdnd_dm_retry_timeout "$beat_timeout")" @@ -388,6 +397,7 @@ turn() { fi out="$DM_LOG.$(date +%s%N).jsonl" _dm_invoke; rc=$? + fi fi cat "$out" >> "$COMBINED" # SYN-01: shared classification front door — notes the FINAL attempt's $out for the caller's