Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions qa/ui_playtest_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,18 @@ rm -rf "$RUNDIR" 2>/dev/null
mkdir -p "$NATIVE_DIR" "$PLAYERDIR/screenshots" "$PLAYERDIR/a11y"
NATIVE_LAUNCHER_STATE_DIR="$RUNDIR/native-launcher-state"

# #735: each persona REUSES its play-state store (play-state/$RUN and the Part-B store
# play-state/${RUN}-b). The ': >' truncations in play.sh only reset the sidecars — never the
# campaigns/ tree — so a RE-RUN mints a SECOND seated campaign on top of the prior run's save.
# Two seated saves with equal recency in one store was the precondition for the active-PC flip
# (the engine/viewer live-campaign resolvers then disagreed on the tie). Wipe BOTH stores for
# this run prefix before launch so each persona mints into a CLEAN store -> exactly one seated
# campaign. Guarded: only fire when $RUN is non-empty so the glob can never widen to all of
# play-state/. bash 3.2-clean (no globstar / no arrays needed).
if [ -n "$RUN" ]; then
rm -rf "$ROOT"/play-state/"$RUN" "$ROOT"/play-state/"$RUN"-b 2>/dev/null
fi

_DEFAULTS_SENTINEL="__worldos_defaults_missing__"
ORIGINAL_SELECTED_PROVIDER="$(defaults read dev.clawdnd.app selectedProvider 2>/dev/null || printf '%s' "$_DEFAULTS_SENTINEL")"
ORIGINAL_STATE_DIR="$(defaults read dev.clawdnd.app stateDir 2>/dev/null || printf '%s' "$_DEFAULTS_SENTINEL")"
Expand Down
9 changes: 9 additions & 0 deletions qa/vm/sweep_v2.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ note "start build=$SHA (parallel mode, lean ON — production-matching, fast Opu

run_persona(){ # $1=persona $2=port -> writes results/score-$1.json
local persona="$1" port="$2"
# #735: wipe THIS persona's reused play-state stores (the solo run + the Part-B `-b` store)
# before launch so each run mints into a CLEAN campaigns/ tree -> exactly one seated campaign.
# A re-run otherwise stacks a 2nd seated save in the same store (the ': >' truncations reset
# only the sidecars, never campaigns/), and two equal-recency seated saves were the precondition
# for the active-PC silent-switch (the live-campaign resolvers disagreed on the tie). cwd is the
# repo (line 38). Guarded on a non-empty persona so the glob can never widen to all of play-state/.
if [ -n "$persona" ]; then
rm -rf "play-state/vm2-$persona" "play-state/vm2-$persona-b" 2>/dev/null
fi
# Opus de-risk: longer per-persona deadline (Opus cold-open ~300s + slower beats) + a bigger run
# budget (Opus cold-open ~$2.4 + beats + player). The harnesses cap per-turn model-aware (#684/#686).
WOS_APP_PART=B WOS_APP_SKIP_BUILD=1 WOS_APP_PREFERRED_PORT=$port \
Expand Down
31 changes: 31 additions & 0 deletions servers/engine/tests/test_lean_reground_contamination.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,34 @@ def test_active_campaign_none_when_empty(state):
"""No campaigns yet → None (the harness then no-ops lean, today's behavior)."""
assert store.active_campaign_id() is None
assert server.active_campaign()["campaign_id"] is None


# ── 3) THE TIEBREAK: equal updated_at must resolve DETERMINISTICALLY (#735) ────────


def test_active_campaign_id_breaks_updated_at_tie_on_smallest_id(state):
"""The keystone determinism guard behind #735 (active PC flips between beats).

When TWO seated campaigns coexist in one state dir and tie on ``updated_at`` (the
real precondition the QA harness manufactured — two re-run mints with the same
wall-clock save), the resolver MUST still return ONE id every call. ``active_campaign_id``
iterates ``sorted(iterdir())`` and keeps the FIRST-seen on a strict ``>`` tie, so the
lexicographically-SMALLEST id wins — fully deterministic. The viewer's
``_pick_campaign`` mirrors this exact rule so the live campaign (and thus the active PC)
can never flip between beats on a recency tie. This pins the engine half of that contract.
"""
one = _seed_campaign(["First save."])
two = _seed_campaign(["Second save."])
assert one != two
# EXACT-equal updated_at on both → a pure tie that ONLY the id tiebreak can resolve.
_set_updated_at(one, 5_000.0)
_set_updated_at(two, 5_000.0)

smallest = min(one, two) # the deterministic winner the resolver must return
picked = store.active_campaign_id("baldurs-gate")
assert picked == smallest, (
f"the lexicographically-smallest id must win on an updated_at tie "
f"(got {picked!r}, expected {smallest!r} of {sorted((one, two))})")
# Stable across repeated calls — no iteration-order jitter, regardless of mint order.
for _ in range(20):
assert store.active_campaign_id("baldurs-gate") == smallest
60 changes: 48 additions & 12 deletions viewer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,15 +544,43 @@ def _campaign_has_player(snap: dict) -> bool:
return any(isinstance(c, dict) and c.get("kind") == "player" for c in chars.values())


def _snapshot_updated_at(snap: dict, snap_path: Path) -> float:
"""The campaign's monotonic 'last written' clock for the auto-follow pick (#735).

Prefer the snapshot BODY's ``updated_at`` — the engine (the sole writer) re-stamps it on
EVERY save (``store.save_campaign`` -> ``Campaign.updated_at = time.time()``), so it advances
exactly when the run's story moves and is identical to the key the engine's authoritative
``store.active_campaign_id`` resolves on. That makes the viewer pick agree with the engine
and, crucially, IMMUNE to filesystem mtime jitter (an unrelated touch, a copy, a poisoned
save time) that the old ``_campaign_recency`` heuristic followed. Fall back to the filesystem
``_campaign_recency`` only for a LEGACY snapshot that predates the body clock, so older saves
still round-trip and resolve by their session/snapshot mtime exactly as before."""
body = snap.get("updated_at")
if isinstance(body, (int, float)) and not isinstance(body, bool):
return float(body)
return _campaign_recency(snap_path)


def _pick_campaign(arg: str | None) -> str | None:
"""Resolve which campaign to project. An explicit arg wins; otherwise pick the
most-recently-ACTIVE campaign by recency (#38) so launching the viewer follows
whatever run is live without a relaunch. Snapshots that fail to parse are
skipped so a half-written/corrupt one can't win the race and blank the view.
A campaign with a SEATED PLAYER is preferred over a party-less orphan (a cold-open
retry's second mint, or a fresh start_world before the PC seats); recency only
tie-breaks AMONG real runs, so a just-orphaned empty sibling can never win the
auto-follow and blank the table while a seated run is live (the hero-bind party-wipe)."""
most-recently-ACTIVE campaign (#38) so launching the viewer follows whatever run is
live without a relaunch. Snapshots that fail to parse are skipped so a
half-written/corrupt one can't win the race and blank the view.

DETERMINISTIC + STICKY (#735): a campaign with a SEATED PLAYER is preferred over a
party-less orphan (a cold-open retry's second mint, or a fresh start_world before the PC
seats). Among real runs the pick is the largest BODY ``updated_at`` (the engine's
sole-writer clock — see ``_snapshot_updated_at``), and a remaining tie breaks on the
lexicographically-SMALLEST campaign id. This mirrors ``store.active_campaign_id`` EXACTLY
(largest ``updated_at``, smallest-id tiebreak), so the viewer's auto-follow and the engine
resolve to the SAME live campaign every read. The old key was ``(has_player, filesystem
recency)`` with NO tiebreak, so two equal-recency seated saves resolved to whichever
``glob`` yielded first — filesystem-order-dependent, so the attached campaign (and thus the
active PC) FLIPPED between beats. The deterministic key is the stickiness: on a recency tie
the SAME id wins every time, so the pick only moves when a genuinely STRICTLY-newer campaign
is written — never on jitter. recency only tie-breaks AMONG real runs, so a just-orphaned
empty sibling can never win the auto-follow and blank the table while a seated run is live
(the hero-bind party-wipe)."""
if arg:
return arg
cdir = _campaigns_dir()
Expand All @@ -566,11 +594,19 @@ def _pick_campaign(arg: str | None) -> str | None:
continue # empty/`{}` snapshot — nothing to show; don't let it win
except (json.JSONDecodeError, OSError):
continue
snaps.append((p.parent.name, _campaign_has_player(snap), _campaign_recency(p)))
# Prefer a seated run (has_player True > False), then recency. A party-less orphan only wins
# when EVERY candidate is party-less (a brand-new game between start_world and the PC seat),
# so a legitimate fresh game is at most briefly demoted, never stranded.
return max(snaps, key=lambda x: (x[1], x[2]))[0] if snaps else None
snaps.append((p.parent.name, _campaign_has_player(snap), _snapshot_updated_at(snap, p)))
if not snaps:
return None
# Prefer a seated run (has_player True > False), then the largest body updated_at. The id
# tiebreak is the SMALLEST id (mirrors store.active_campaign_id, which keeps the first of
# sorted(iterdir()) on a `>` tie): max() picks the largest key, so negate the ordering by
# selecting the min id among the recency winners. A party-less orphan only wins when EVERY
# candidate is party-less (a brand-new game between start_world and the PC seat), so a
# legitimate fresh game is at most briefly demoted, never stranded.
best_has_player, best_updated = max((x[1], x[2]) for x in snaps)
contenders = [cid for cid, has_player, updated in snaps
if has_player == best_has_player and updated == best_updated]
return min(contenders)


def _campaign_dir(campaign_id: str) -> Path:
Expand Down
Loading
Loading