Raxol.Harness.SessionLane behaviour (Raxol v2.6.1)

View Source

The seam between the harness surface (this package, which must NEVER depend on raxol_agent) and a live agent session lane. A concrete lane implementation lives on the agent side (e.g. Raxol.Agent.Harness.SessionLane in packages/raxol_agent) or in a test double -- never here.

The asymmetry: interrupt/2 is event-observed, steer/2 is a reply

The two commands this behaviour exposes are deliberately NOT symmetric in how their outcome reaches the caller, and that asymmetry is the whole point, not an oversight:

  • interrupt/2 is fire-and-forget. Its acknowledgment is EVENT-OBSERVED: the staged supervised kill this seam dispatches emits durable stage events on the SAME event stream the caller is already subscribed to (:interrupt_signaled, :interrupt_waited, :interrupt_killed/:interrupt_kill_failed, :turn_canceled) -- those events ARE the acknowledgment, so no reply is defined here. Every kill stage is durable, journaled, and observable; there is nothing a synchronous reply would tell the caller that the event stream doesn't already say, more reliably (it survives the calling process crashing mid-call).
  • steer/2 is a synchronous typed decision. A stale-turn rejection (the turn the caller believed was running already ended, or another steer won the compare-and-swap race) is deliberately non-journaled -- zero model effect, nothing appended to the turn's durable history (see Raxol.Agent.Steer's own moduledoc: "Nothing is journaled and the state is unchanged"). Because a rejection produces no event, there is no event a surface could ever observe to learn the outcome -- only a direct reply can carry it honestly. Fire-and-forget steer would leave the surface with no way to distinguish "accepted" from "silently dropped".

The session shape (cross-package law)

session() is a plain MAP, never a struct -- this package's own cross-package convention (see the project's CLAUDE.md: struct patterns across package boundaries use map patterns). A lane implementation on the agent side may carry a real Raxol.Agent.Session struct internally, but what crosses this seam is always the map shape below.

Summary

Types

A live session handle: at minimum a :session_id, optionally the session process's :pid (used by monitor/1 and, on the agent side, by Raxol.Agent.Command.route/2's {:harness_command, action} delivery).

Callbacks

Fire-and-forget dispatch of an interrupt command -- the supervised kill of the running turn. See the moduledoc: acknowledgment is EVENT-OBSERVED (durable stage events on the same stream subscribe/1 delivers), so this callback defines no reply payload beyond dispatch success/failure itself.

Monitor the session process for death honesty (Process.monitor/1 under the hood on the agent side). Returns nil when there is no :pid to monitor -- a session handle that never carried a pid is not a failure to monitor, it is simply nothing to watch.

Synchronous typed steer decision. request carries :text, :expected_turn_id, and :client_msg_id (mirroring Raxol.Agent.Steer.Request). The result vocabulary mirrors the agent lane's steer compare-and-swap decision core

Subscribe the CALLING process to this session's live event stream. Must be called from the process that wants to receive events -- after a :ok return, that process receives {:session_event, session_id, event} messages, where event is a map or struct the caller normalizes via Raxol.Harness.EventBoundary.normalize/1 before it ever reaches the projection/status-strip pipeline.

Types

session()

@type session() :: %{:session_id => term(), optional(:pid) => pid()}

A live session handle: at minimum a :session_id, optionally the session process's :pid (used by monitor/1 and, on the agent side, by Raxol.Agent.Command.route/2's {:harness_command, action} delivery).

Callbacks

interrupt(session, payload)

@callback interrupt(session(), payload :: map()) :: :ok | {:error, term()}

Fire-and-forget dispatch of an interrupt command -- the supervised kill of the running turn. See the moduledoc: acknowledgment is EVENT-OBSERVED (durable stage events on the same stream subscribe/1 delivers), so this callback defines no reply payload beyond dispatch success/failure itself.

monitor(session)

@callback monitor(session()) :: reference() | nil

Monitor the session process for death honesty (Process.monitor/1 under the hood on the agent side). Returns nil when there is no :pid to monitor -- a session handle that never carried a pid is not a failure to monitor, it is simply nothing to watch.

steer(session, request)

@callback steer(session(), request :: map()) ::
  {:ok, {:accepted, map()}} | {:ok, {:duplicate, map()}} | {:error, term()}

Synchronous typed steer decision. request carries :text, :expected_turn_id, and :client_msg_id (mirroring Raxol.Agent.Steer.Request). The result vocabulary mirrors the agent lane's steer compare-and-swap decision core:

  • {:ok, {:accepted, ref}} -- the steer landed in the running turn.
  • {:ok, {:duplicate, ref}} -- the same client_msg_id was already accepted; this re-delivery references the ORIGINAL accept.
  • {:error, {:stale_turn, expected, actual}} -- the CAS lost; nothing was journaled (see moduledoc).
  • {:error, :no_live_turn} -- no turn is currently running.
  • {:error, :client_msg_id_reuse} -- the same idempotency key arrived carrying different content.
  • {:error, term()} -- any other dispatch failure, INCLUDING {:error, :timeout} for an unbounded wait -- implementations must be bounded and translate a timeout rather than hang the caller forever.

subscribe(session)

@callback subscribe(session()) :: :ok | {:error, term()}

Subscribe the CALLING process to this session's live event stream. Must be called from the process that wants to receive events -- after a :ok return, that process receives {:session_event, session_id, event} messages, where event is a map or struct the caller normalizes via Raxol.Harness.EventBoundary.normalize/1 before it ever reaches the projection/status-strip pipeline.