Commit Graph
2 Commits
Author SHA1 Message Date
arcattackandClaude Opus 5 1cda880c6d console/relay: document it properly + fix two regressions the review caught
DOCS (the ask: after a compaction this session lost track of how the console
works and launched the wrong program, twice).

  * NEW context/operator-console.md -- the dedicated topic that was missing.
    Leads with the thing I got wrong: btoperator.py is the PySide6 GUI the
    operator uses; btconsole.py is the headless relay it spawns.  Then ports,
    the route table, the roster/seat/identity model, the full round lifecycle
    with every launch gate, liveness, mode-specific traps, and log locations.
  * NEW docs/OPERATOR_GUIDE.md -- sysop-facing: start the console, set up a
    mission, watch pods arrive, launch, run back-to-back rounds, what to press
    when LAUNCH looks dead, a troubleshooting table keyed on the exact log
    lines, and what to save BEFORE restarting a session (Start Session
    truncates operator_relay.log, so restarting to clear a problem destroys the
    evidence of it).
  * CLAUDE.md: two Quick Lookup rows + a DO-NOT entry naming the two programs,
    so the distinction survives the next compaction.
  * context/multiplayer.md: a pointer out of the scattered console notes to the
    new topic (they were buried across ~8 places in a large file, which is
    exactly why they evaporated).

FIXES -- both are regressions in my own previous commit, found by the review
pass, and one would have made a games night WORSE:

  * THE REAPER WOULD HAVE KILLED HEALTHY PLAYERS.  It was gated only on "no
    mission running", which a round RESET satisfies -- so it was armed for the
    whole BETWEEN-ROUNDS wait, and that is a period when a pod is legitimately
    byte-silent: its seat beacon is write-only for the process lifetime
    (L4NET.CPP: "the relay ignores its silence") and its console pad has no egg
    yet so it cannot ACK.  A real night showed 12-minute and 5-minute gaps; the
    180s deadline would have dropped healthy pods and forced their clients to
    relaunch.  It now runs ONLY in the active staging window, skips pads with no
    egg and conns that have not HELLO'd, and last_seen is also stamped from
    inbound UDP (a pod streaming updates while its TCP idles was being counted
    as silent).  Half-open detection is keepalive's job; this is just a backstop.
  * UnboundLocalError in the operator UI.  My end_sent reset was an `elif` in
    the chain that assigns `head`, so that branch left `head` unbound -- a crash
    on the first status refresh after End Mission, which is exactly the path the
    relay's "StopMission sent" line produces.  Moved out of the chain.

Plus one pre-existing wedge with the same symptom as the reported bug, live-
proven in operator_relay.log (~5 minutes of a night lost): _abort_round clears
eggs_released BEFORE the survivors' sockets close, so _maybe_reset_round's own
`if not self.eggs_released: return` skips the template restore forever -- the
roster stays trimmed, the release gates can never be met, and walk-ups get
ROSTER FULL.  _rearm_for_new_round's restore is therefore now UNCONDITIONAL (it
was gated on eggs_released, which made Re-arm useless in the one state that most
needs it) and it clears round_hold_until so an abort's settle window is not
inherited.  Aborts are ordinary: nothing on the wire distinguishes a straggler's
late FIN from a pod dying mid-load.

scratchpad/test_relay_rearm.py now 19 checks, all passing, including the two new
regression guards (a 15-minute-silent waiting pod is NOT reaped; a pad that was
never sent an egg is NOT reaped) and the mid-pair no-re-arm guard.

Still open, recorded in the new topic's frontmatter: the UDP endpoint map trusts
the sender's self-declared fromHost; the egg-ACK is a fixed-offset parse of one
recv with no reassembly; remote-operator mode can never enable LAUNCH.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:36:14 -05:00
arcattackandClaude Opus 5 4736cba1ca relay: LAUNCH is never silently inert again -- fix the post-round wedge (operator report)
THE SYMPTOM: "after a game ends I push LAUNCH and nothing happens until I reset
the session entirely or reboot the console."  The operator also observed the
mirror image, which is the tell: with a STABLE group, relaunching worked fine
several times in a row.

ROOT CAUSE.  Two gates, both all-or-nothing, and a UI latch that agreed with
them:
  * btconsole.py: the manual LAUNCH branch is guarded on `launches_sent == 0`,
    but a finished mission leaves it at 2.  The only paths back to 0 were
    _check_launch_gate (needs EVERY seat of the last round to re-ACK) and
    _maybe_reset_round (needs EVERY pod gone).  A games night lives between
    those -- most pods rejoin, one player closes their window.  Worse, the
    "not all seats filled" diagnostic sits INSIDE the `launches_sent == 0`
    guard, so in exactly that state NOTHING was printed.
  * btoperator.py: the LAUNCH button is gated on `not monitor.launched`, and
    `launched` was cleared ONLY by the two relay lines those same gates emit
    ("WAITING FOR OPERATOR", "round RESET").  So the button was greyed out in
    precisely the wedged state -- the click was a no-op by construction.
That is why a stable group worked (everyone re-ACKs -> gate fires) and why only
a session restart recovered (fresh relay process = fresh state).

FIXES
  * An explicit operator LAUNCH is now sufficient authority to start a new
    round: _rearm_for_new_round() clears the finished round's state and restores
    the template roster/egg, KEEPING seats/beacons/connections, so whoever is
    here stays here.  Triggered on a press while a round is latched.
  * New `rearm`/`newround` operator command + a Re-arm button, so recovery never
    needs a session restart.  Proven live over the control port.
  * Every operator command is logged AT RECEIPT with the state that decides its
    fate, so "did it arrive or was it ignored?" is answerable from the log.
  * A stale End Mission can no longer kill the next mission: _tick_stop consumed
    a stop_requested set between rounds by latching it until launches_sent hit 2,
    then StopMissioning the new mission in the tick it launched -- also
    indistinguishable from "launch did nothing".  It is now consumed + announced
    while idle.  The UI's end_sent likewise reset per round, not per session
    (End Mission used to work exactly once a night).
  * The UI clears `launched` on "StopMission sent" and on the re-arm line, so the
    button returns when the round actually ends, and only greys once a command
    has really been written (a dead relay now says so instead of logging
    ">> sent" into the void -- _console_finished leaves console_proc non-None).

HARDENING (the "reboot the console" half)
  * SO_KEEPALIVE on every accepted socket + a last_seen deadline and a reaper:
    nothing detected a pod that vanished WITHOUT a FIN (sleeping laptop, dropped
    Wi-Fi, killed process, NAT timeout).  Such a conn kept acked=True forever,
    which permanently blocked the round reset and held a phantom seat.  The
    reaper stands down while a mission is running.
  * Every pod-facing send went blocking with NO timeout on the single-threaded
    selector loop, so one wedged peer could freeze the entire relay.  All sends
    now go through _send_all_guarded (10s timeout, drops the peer on failure).
  * _send_egg no longer calls getpeername() on a possibly-dead socket.

REGRESSION I INTRODUCED AND CAUGHT ON THE RIG: the first cut re-armed whenever
launches_sent >= 1, which also matched the NORMAL state between RunMission #1 and
#2 (launch_requested deliberately stays set across the pair).  That reset the
pair mid-flight and re-released eggs every tick -- a re-arm storm that kicked
every pod into an identity-resync loop.  The re-arm now additionally requires
`launch_at is None`, i.e. no launch sequence in flight.  Verified: 0 REJOIN lines
and exactly 1 RE-ARM line across a full 3-mission rig session.

VERIFIED
  * scratchpad/test_relay_rearm.py -- 6 groups, all passing: the exact wedge
    state recovers; a stale stop is consumed while idle; staging is NOT restarted
    under an impatient operator; mid-pair never re-arms; the happy path is
    untouched and RunMission still reaches the pods; the reaper drops a silent
    conn and keeps a live one, and stands down mid-mission.
  * Live 2-pod rig (scratchpad/rig_relay.ps1 + relay_ctl.py over the control
    port): two clean back-to-back missions, StopMission, round RESET, a live
    `rearm`, and LAUNCH-with-nobody-present now printing why instead of nothing.

NOT reproduced end-to-end: the field wedge needs 3+ pods with staggered rejoins
(this rig's pods re-exec together, so the all-gone reset always fires).  The
state itself is covered by the unit test.  Awaiting live confirmation on a real
games night.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:21:24 -05:00