Raxol.AgentClientProtocol.Session (Raxol Agent Client Protocol v0.1.0)

Copy Markdown View Source

One GenServer per session_id: the ACP turn state machine.

It owns:

  • the turn state machine (:idle | {:prompting, Turn} | {:cancelling, Turn}, including the born-cancelled and stale-timer cases),

  • rx_seq latch consumption (cancel/prompt commute by wire order, not scheduling order),
  • register-before-respond by construction (the :via name is minted in start_link/1 before init/1 runs),
  • the prompt turn as a supervised Task with a task-group hold-open gate (the drain fires only when the monitor count hits zero),
  • session/update emission ordering (updates flow through the Connection on the same FIFO lane as the eventual prompt reply, so no update can serialize after the response),
  • cancellation (graceful :acp_cancel interrupt, then a non-blocking 30s Process.exit/2 backstop; cancel-of-idle is a clean latch-only no-op; the disarm-on-response guard is the state machine itself),
  • the permission flow (async_request with a per-ask tag; fail-closed deny on every non-{:selected, _} row; a turn cancel aborts open asks),
  • the prompt reply via the Connection's delegated-reply mechanism (the Session is the adopter and sends the one reply),
  • the mode_state seam: set_mode validates against the injected %SessionModeState{}; a nil mode state makes set_mode invalid-params.

Connection seam (dependency injection)

This module reaches the Connection through an injected module (:conn_mod, default Raxol.AgentClientProtocol.Connection) so the session layer compiles and tests independently of the sibling Connection module, and so the test FakeConnection can double-check that the Connection surface the Session consumes (delegate_reply/3, reply/3, notify/3, async_request/6, cancel_request/2) is sufficient. The semantics are unchanged — the Session still mints the reply, still owns the drain gate.

Turn runner seam

begin_prompt/4 has a fixed 4-arity and does not carry the turn function, so the turn runner is injected at session start (:turn_runner, an arity-2 fun (session_pid, %PromptRequest{}) -> {:stop, StopReason} | %PromptResponse{}). It runs inside the supervised root Task; its return value is folded into turn.outcome (write-once) when the task result arrives. A crash folds to internal-error. The runner MAY receive/peek :acp_cancel between steps to wind down gracefully (the 30s backstop kills it otherwise).

Summary

Functions

Begin a prompt turn (called by the session/prompt handler task).

Returns a specification to start this module under a supervisor.

Emit a session/update for the live turn (called by a turn task). Forwarded to the Connection via notify/3 (same FIFO lane as the eventual reply). A post for a non-live turn returns {:error, :turn_over} and emits nothing (the straggler-task guard).

The package-level unique Registry name. Keys are {conn_pid, session_id}; session_id stays a binary, never atomized.

Request permission from the client (called by a turn task, blocks :infinity). The Session stays fully responsive while the task is parked, which is what lets a cancel abort the ask. Returns {:ok, {:selected, %SelectedPermissionOutcome{}}} ONLY on a decoded selected outcome; every other terminal (timeout, client error, decode failure, disconnect, session cancel) returns {:ok, :cancelled} — deny is the zero value everywhere (fail-closed).

Set the active mode (called by the session/set_mode handler task). Validates mode_id against the injected %SessionModeState{}; legal mid-turn. A nil mode state or an unknown mode replies -32602 invalid-params.

Spawn a subagent task inside the current turn group (called by a turn task). The task is monitored and its ref joins monitors — the hold-open gate: the prompt reply is withheld until every group member is down. Returns {:ok, ref}, or {:error, :turn_over} when there is no live prompting turn.

Start a Session. Registration happens via the :via name in start_link/1, before init/1 runs — this is the structural half of register-before-respond. A duplicate live {conn, session_id} returns {:error, {:already_started, pid}} (the session/load-of-a-live-session case).

Resolve a steer request against the live turn.

The :via tuple that keys a Session on {conn, session_id}.

Types

t()

@type t() :: %Raxol.AgentClientProtocol.Session{
  config: %{
    permission_timeout: timeout(),
    cancel_backstop_ms: pos_integer(),
    idle_timeout: timeout()
  },
  conn: pid(),
  conn_mod: module(),
  emitter: module(),
  idle_epoch: non_neg_integer(),
  idle_timer: reference() | nil,
  journal: {module(), term()} | nil,
  last_cancel_seq: non_neg_integer(),
  mode_state:
    Raxol.AgentClientProtocol.Schema.AgentTypes.SessionModeState.t() | nil,
  session_id: String.t(),
  steer_adapter: module(),
  steer_state: term(),
  task_sup: pid() | atom(),
  turn:
    :idle
    | {:prompting, Raxol.AgentClientProtocol.Session.Turn.t()}
    | {:cancelling, Raxol.AgentClientProtocol.Session.Turn.t()},
  turn_runner: (pid(), struct() ->
                  {:stop, atom()}
                  | Raxol.AgentClientProtocol.Schema.AgentTypes.PromptResponse.t()),
  turn_seq: non_neg_integer()
}

Functions

begin_prompt(session, req, reply_ref, rx_seq)

@spec begin_prompt(GenServer.server(), struct(), reference(), non_neg_integer()) ::
  :ok | {:error, Raxol.AgentClientProtocol.Error.t()}

Begin a prompt turn (called by the session/prompt handler task).

Adopts the reply obligation via delegate_reply/3, then either starts the turn (spawns the supervised root task) or, if the prompt was wire-ordered before a latched cancel (rx_seq < last_cancel_seq), resolves it born-cancelled immediately. Returns :ok on the deferred path (the handler must then return :deferred) or {:error, %Error{}} when the session is busy — a second prompt on a :prompting/:cancelling session; in the busy case the handler replies with that error directly (no delegation happened).

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

post_update(session, notification)

@spec post_update(GenServer.server(), struct()) :: :ok | {:error, :turn_over}

Emit a session/update for the live turn (called by a turn task). Forwarded to the Connection via notify/3 (same FIFO lane as the eventual reply). A post for a non-live turn returns {:error, :turn_over} and emits nothing (the straggler-task guard).

registry()

@spec registry() :: atom()

The package-level unique Registry name. Keys are {conn_pid, session_id}; session_id stays a binary, never atomized.

request_permission(session, req)

@spec request_permission(GenServer.server(), struct()) ::
  {:ok, {:selected, struct()}} | {:ok, :cancelled}

Request permission from the client (called by a turn task, blocks :infinity). The Session stays fully responsive while the task is parked, which is what lets a cancel abort the ask. Returns {:ok, {:selected, %SelectedPermissionOutcome{}}} ONLY on a decoded selected outcome; every other terminal (timeout, client error, decode failure, disconnect, session cancel) returns {:ok, :cancelled} — deny is the zero value everywhere (fail-closed).

set_mode(session, mode_id)

Set the active mode (called by the session/set_mode handler task). Validates mode_id against the injected %SessionModeState{}; legal mid-turn. A nil mode state or an unknown mode replies -32602 invalid-params.

spawn_task(session, fun)

@spec spawn_task(GenServer.server(), (-> any())) ::
  {:ok, reference()} | {:error, :turn_over}

Spawn a subagent task inside the current turn group (called by a turn task). The task is monitored and its ref joins monitors — the hold-open gate: the prompt reply is withheld until every group member is down. Returns {:ok, ref}, or {:error, :turn_over} when there is no live prompting turn.

start_link(opts)

@spec start_link(keyword()) :: GenServer.on_start()

Start a Session. Registration happens via the :via name in start_link/1, before init/1 runs — this is the structural half of register-before-respond. A duplicate live {conn, session_id} returns {:error, {:already_started, pid}} (the session/load-of-a-live-session case).

Options:

  • :session_id (required, binary)
  • :conn (required, Connection pid — also the registry key half)
  • :mode_state — %SessionModeState{} or nil
  • :task_sup (required) — the per-connection Task.Supervisor (ctx.task_sup)
  • :turn_runner (required for prompting) — arity-2 fun (see moduledoc)
  • :conn_mod — Connection module (default Raxol.AgentClientProtocol.Connection)
  • :config — %{permission_timeout: _, cancel_backstop_ms: _}
  • :name — override the :via name (tests)

steer(session, request)

Resolve a steer request against the live turn.

Redirects the running turn with new user input WITHOUT killing it. Runs the injected SteerAdapter's expected_turn_id compare-and-swap decision from the Session's OWN mailbox — the single-writer serialization a CAS decision requires, satisfied by construction (only this process reads-modifies-writes the CAS state). Returns the honest CAS outcome synchronously:

  • {:ok, {:accepted, ref}} — the steer landed: the steering text was forwarded to the running turn (the {:acp_steer, text} runner seam) and one durable steer record was appended via the emitter.
  • {:ok, {:duplicate, ref}} — idempotent re-delivery of an already-accepted client_msg_id; ref is the ORIGINAL accept, nothing new happens.
  • {:error, {:stale_turn, expected, actual}} — the CAS lost (the turn ended, or another steer won); nothing journaled, zero model effect.
  • {:error, :no_live_turn} — no turn is running.
  • {:error, :client_msg_id_reuse} — the idempotency key arrived carrying different content (a client bug/attack, never a retry).
  • {:error, :no_steer_channel} — no steer adapter is wired (the default).

request is a map %{text: _, expected_turn_id: _, client_msg_id: _} (see Raxol.AgentClientProtocol.Session.SteerAdapter.request/0).

via(conn, session_id)

@spec via(pid(), String.t()) :: {:via, module(), {atom(), {pid(), String.t()}}}

The :via tuple that keys a Session on {conn, session_id}.