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}(aRaxol.Harness.SessionLaneimplementation) -- subscribe, interrupt, steer, monitor; - a
Raxol.Harness.StreamCadenceserver, decoupling the lane's raw event rate from render cadence; - a linked forwarder process that owns the actual
subscribe/1call and re-shapes{:session_event, sid, event}messages intoStreamCadence.ingest/2calls (throughRaxol.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_completedfinal or not,turn_canceled) runsSurface.flush_held/1: the blocks that bracket completed seal NOW, and the stream stays open for the next turn (final: truecloses one pump run — one turn — never the session; a multi-turn conversation runs one turn per prompt on the same session id); - terminally —
Surface.close_stream/1at 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
@type lane() :: {module(), Raxol.Harness.SessionLane.session()}
Functions
@spec halt(pid()) :: :ok
Ends the driver's loop. Sends :halt; never blocks.
@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).
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.