Raxol.Harness.LiveSessionDriver (Raxol v2.6.1)

View Source

The live-session unit's DRIVER: a plain-process loop supervising ONE live agent session end-to-end, from an injected Raxol.Harness.SessionLane subscription through Raxol.Harness.StreamCadence and into a Raxol.Harness.Surface, and back out through the lane's :interrupt/:steer dispatch.

examples/harness_fixture_demo.exs is the shipped process-loop precedent this module mirrors: NOT a GenServer. Raxol.Harness.StreamCadence's own moduledoc (section 2, "the owner-consumption contract") is explicit that its input-priority guarantee needs an owner whose OWN receive handles input messages ahead of {:render_batch, ...} -- a GenServer's single handle_info/2 callback cannot express "check this pattern first, unconditionally, before touching the general mailbox" the way a raw receive ... after 0 -> receive ... end can. This module IS that owner.

What this module supervises

One live session, wired through four collaborators, all built inside the driver's own process (so a :command_sink closure built at Surface.new/2 time can safely capture self()):

  • the injected {lane_module, session} (a Raxol.Harness.SessionLane implementation) -- subscribe, interrupt, steer, monitor;
  • a Raxol.Harness.StreamCadence server, decoupling the lane's raw event rate from render cadence;
  • a linked forwarder process that owns the actual subscribe/1 call and re-shapes {:session_event, sid, event} messages into StreamCadence.ingest/2 calls (through Raxol.Harness.EventBoundary.normalize/1, the security seam -- a live event is untrusted input the moment it crosses this process boundary);
  • a Raxol.Harness.StallDetector, fed from both revealed events and the elapsed-time ticker.

The owner-consumption contract (cite: Raxol.Harness.StreamCadence

moduledoc section 2)

loop/1's very first move on every pass is a receive that matches ONLY {:inline_input, _} / {:surface_command, _} with after 0. If neither is already sitting in the mailbox, that receive falls through immediately (0ms) into a second, blocking receive that matches every message this process understands -- including {:render_batch, _}. This is the "owner handles input messages before render-batch messages" half of the cadence contract; the OTHER half (:input_check, the source-side hold) is wired below as a belt-and-suspenders addition, not a substitute -- see build/1's input_check construction. The :input_check seam is deliberately CONSERVATIVE: it reads Process.info(self(), :message_queue_len), which is true the moment ANY message is queued (including an unconsumed {:render_batch, ...} the loop hasn't gotten to yet), not only a genuine pending keystroke. Raxol.Harness.CadencePolicy.max_consecutive_yields/0 bounds the added latency this over-approximation can cost to roughly one frame interval, which is why the mandatory correctness guarantee is the loop's own input-first receive above, and :input_check is only ever a latency optimization on top of it.

Fold-before-seal ordering (the live/fixture parity invariant)

A live session and a fixture replay of the SAME events must render the same sealed history. The load-bearing half of that is ordering: a block's turn bracket (turn_completed / turn_canceled) — and anything a later unit derives from it into the block, e.g. a completion/evidence row — must fold into the projection BEFORE the block seals. The fixture reveal gets this from the seal frontier's one-step hold on the newest completed block; a live reveal is always momentarily "caught up", which would defeat that hold — so this driver opens the Surface with stream_open: true (the hold stays engaged while more events may come) and releases it at two distinct levels:

  • per turn — every turn bracket (turn_completed final or not, turn_canceled) runs Surface.flush_held/1: the blocks that bracket completed seal NOW, and the stream stays open for the next turn (final: true closes one pump run — one turn — never the session; a multi-turn conversation runs one turn per prompt on the same session id);
  • terminallySurface.close_stream/1 at the two process-level moments no more events can ever arrive: session-process death and a dead event feed (forwarder/cadence crash). This is also the backstop that lands a stranded mid-turn tail in history when a session dies without ever emitting its bracket.

Enforced by the "fold-before-seal ordering" and "turn completion releases the hold but never ends the session" tests.

Loss and malformed-event honesty

Nothing here paints a gapless lie over data the cadence layer had to shed, or an event the boundary rejected. A {:cadence_dropped, n} batch element and a {:malformed_event} batch element each seal ONE honest marker line into history via Raxol.Harness.Surface.seal_marker/2 -- the same mechanism StreamCadence's own moduledoc documents as the in-band loss report. The transcript always shows a visible gap instead of silently rendering as if nothing had been lost or rejected.

The interrupt / steer asymmetry (cite: Raxol.Harness.SessionLane moduledoc)

:interrupt is fire-and-forget: lane.interrupt/2 is called synchronously (it is expected to be cheap -- dispatch, not execution), and its outcome is rendered as a one-shot "sent, awaiting confirmation" lane notice. The REAL acknowledgment never comes from that call's return value -- it comes from the staged kill's own durable events (:interrupt_signaled, :interrupt_kill_failed, :turn_canceled) arriving on the SAME event stream every other batch element does, each rendered honestly as it lands (see apply_lifecycle/2).

The interrupt turn id is ADVISORY (no server-side CAS, and none needed)

Steer is guarded by the session's compare-and-swap against expected_turn_id; interrupt is NOT, and deliberately so. The real kill entry point, Raxol.Agent.Interrupt.interrupt/3, targets a running turn's tool_ref and takes only :reason/:actor options -- there is no turn-id selector: interrupt kills whatever turn is CURRENTLY running. So the turn_id this driver puts in the interrupt payload is advisory attribution, not a target: a stale current_turn_id can NEVER cause the wrong turn to die (the running turn is decided at the session, not here). The only thing a stale belief can affect is the wording of the driver's own optimistic "interrupt sent (turn X)" notice -- which is why that line says "awaiting confirmation" and the AUTHORITATIVE "which turn died" statement is the event-observed :turn_canceled ack, rendered from the event's own turn_id (turn_canceled_notice/1), never from current_turn_id.

Interrupt has no in-flight dedup; steer does (intentional asymmetry)

Steer keeps a single-in-flight guard (steer_task) because it races the session's CAS: two concurrent steers could both dispatch and the second's outcome would be ambiguous, so a second steer while one is pending is refused with an honest notice. Interrupt has NO such guard on purpose: it is idempotent fire-and-forget (signal an already-signaled turn and the staged kill simply proceeds / no-ops), so every ESC press just re-renders the "sent" notice -- there is no ambiguous concurrent state to protect against, and refusing a second ESC would be worse (a user leaning on ESC to stop a runaway tool must never be told "already interrupting, wait").

:steer is the opposite shape: a synchronous typed DECISION, dispatched via Task.async/1 so a slow lane can never block ESC-interrupt (or anything else) behind it. Every terminal outcome renders a distinct, honest notice -- accepted, duplicate, or one of three ways to say "NOT delivered" (stale turn, no live turn, or any other dispatch error). A compare-and-swap failure is NEVER silently swallowed: that is the one reason this module exists to render five separate steer-result branches instead of one generic "steer sent" line. Because a steer is dispatched under a single-in-flight guard, that guard carries its own LIVENESS bound: a lane call that neither replies nor crashes is killed after steer_timeout_ms and the guard released with an honest notice, so a wedged steer can never permanently disable steering (handle_steer_timeout/2).

Lifecycle honesty

A turn completing — final: true included — is a TURN fact: its blocks seal, the stream stays open, and nothing claims the session is over. A session-process death (or a dead event feed) IS a session fact: it renders a plain footer statement and then lets the loop KEEP RUNNING — the scrollback above the footer is the permanent record either way, and this module never tears the terminal down on a lifecycle event of its own accord (only :halt, sent by an embedder, ends the loop).

Teardown ownership (this module owns NONE)

Exactly like Raxol.Harness.Surface's own documented precondition #7: this module never emits CSI r or any other terminal-teardown byte. halt/1 (or the loop simply receiving :halt) ends the receive loop and returns -- the embedding driver (the fixture demo's own precedent is Raxol.Terminal.InlineDriver) owns releasing the scroll region and restoring cooked mode, exactly as it already does for Raxol.Harness.Surface directly.

Out of scope

Reattach / replay-from-offset (a subscriber rejoining a session already in progress, or seeking a read-model to a journal offset) is explicitly NOT this unit's job -- Raxol.Agent.Command's own moduledoc documents :attach/:seek as decoded-but-not-yet-routed. This driver always starts a session lane subscription from "now"; a later unit is expected to own history replay before this module's own subscribe/1 call.

Growth characteristic (SEAM-bounded -- inert until the embedder + a

compaction unit land)

DISCLOSED, not yet fixed. apply_batch_item/2 appends each live event into model.events and re-derives the WHOLE revealed prefix through Raxol.Harness.Surface.advance/2 (Projection.project/2) on every event. Over a FIXTURE -- a bounded, known event list -- that is the cheap growing-prefix fold Surface was built for. Over an UNBOUNDED live stream it is O(n^2) CPU and unbounded retention: model.events never sheds a sealed event. This is INERT today -- no shipped embedder feeds the driver a real long/fast session (see SEAM #4 in the PR wiring ledger); the keystone test is the only end-to-end consumer and streams only short sessions, so nothing reaches the quadratic regime. The fix is turn-granularity compaction: drop the source events of FULLY-SEALED turns once their bracket has folded. That is sound because turns project independently (no session-wide id index couples them), the tool_use/tool_result merge is intra-turn, and seal-time recency grading is invariant to dropping older whole turns -- but it rewrites the seal-frontier bookkeeping (painted_count, fold-override indices) and must be gated behind a multi-turn live/fixture BYTE-parity test that does not yet exist. So it lands as its OWN reviewed unit WITH the embedder wiring -- the same SEAM that first makes an unbounded stream reachable -- rather than bolted onto this diff, where a subtle bookkeeping error would corrupt sealed history (the one failure worse than latency, and permanent once a row scrolls into native scrollback). Owner: harness-ui lane.

Doc guarantee -> test mapping

Every claim above is exercised by name in test/harness/live_session_driver_test.exs's own moduledoc table (main package, scripted fake lane) and packages/raxol_agent/test/raxol/agent/harness/live_session_agent_test.exs (real agent-side lane pieces: real Interrupt.interrupt/3, real Steer.resolve/2, and the real SessionStreamer + Contract.pump/3 keystone).

Summary

Functions

Ends the driver's loop. Sends :halt; never blocks.

Blocking convenience form: builds the driver state and runs the loop in the CALLING process (no spawn). Returns :ok once the loop exits (on :halt or the q-while-composer-empty quit key).

Spawns a linked driver process and enters its loop. Returns immediately with {:ok, pid}; the process builds its own state (Surface, cadence, forwarder, monitor) INSIDE itself, so the :command_sink closure Surface.new/2 receives captures the driver's own self(), not the caller's.

Types

lane()

@type lane() :: {module(), Raxol.Harness.SessionLane.session()}

Functions

halt(pid)

@spec halt(pid()) :: :ok

Ends the driver's loop. Sends :halt; never blocks.

run(opts)

@spec run(keyword()) :: :ok

Blocking convenience form: builds the driver state and runs the loop in the CALLING process (no spawn). Returns :ok once the loop exits (on :halt or the q-while-composer-empty quit key).

start_link(opts)

@spec start_link(keyword()) :: {:ok, pid()}

Spawns a linked driver process and enters its loop. Returns immediately with {:ok, pid}; the process builds its own state (Surface, cadence, forwarder, monitor) INSIDE itself, so the :command_sink closure Surface.new/2 receives captures the driver's own self(), not the caller's.