Current section

Files

Jump to
agent_session_manager lib asm session server.ex
Raw

lib/asm/session/server.ex

defmodule ASM.Session.Server do
@moduledoc """
Session aggregate root for run admission, approval routing, and cost totals.
"""
use GenServer
alias ASM.{Control, Error}
alias ASM.Provider
alias ASM.Run.ApprovalCoordinator
alias ASM.Session.Continuation
alias ASM.Session.State
alias ASM.SessionControl
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
name = Keyword.get(opts, :name)
GenServer.start_link(__MODULE__, opts, name: name)
end
@spec get_state(GenServer.server()) :: State.t()
def get_state(server), do: GenServer.call(server, :get_state)
@spec submit_run(GenServer.server(), String.t(), keyword()) ::
{:ok, String.t(), pid() | :queued} | {:error, Error.t()}
def submit_run(server, prompt, opts \\ []) when is_binary(prompt) and is_list(opts) do
GenServer.call(server, {:submit_run, prompt, opts})
end
@spec cancel_run(GenServer.server(), String.t()) :: :ok | {:error, Error.t()}
def cancel_run(server, run_id) when is_binary(run_id) do
GenServer.call(server, {:cancel_run, run_id})
end
@spec resolve_approval(GenServer.server(), String.t(), :allow | :deny) ::
:ok | {:error, Error.t()}
def resolve_approval(server, approval_id, decision)
when is_binary(approval_id) and decision in [:allow, :deny] do
GenServer.call(server, {:resolve_approval, approval_id, decision})
end
@spec pause_run(GenServer.server(), String.t()) :: :ok | {:error, Error.t()}
def pause_run(server, run_id) when is_binary(run_id) do
GenServer.call(server, {:pause_run, run_id})
end
@spec checkpoint(GenServer.server()) :: {:ok, map() | nil} | {:error, Error.t()}
def checkpoint(server) do
GenServer.call(server, :checkpoint)
end
@spec resume_run(GenServer.server(), String.t(), keyword()) ::
{:ok, String.t(), pid() | :queued} | {:error, Error.t()}
def resume_run(server, prompt, opts \\ []) when is_binary(prompt) and is_list(opts) do
GenServer.call(server, {:resume_run, prompt, opts})
end
@spec intervene(GenServer.server(), String.t(), String.t(), keyword()) ::
{:ok, String.t(), pid() | :queued} | {:error, Error.t()}
def intervene(server, run_id, prompt, opts \\ [])
when is_binary(run_id) and is_binary(prompt) and is_list(opts) do
GenServer.call(server, {:intervene, run_id, prompt, opts})
end
@impl true
def init(opts) do
session_id = Keyword.fetch!(opts, :session_id)
provider = opts |> Keyword.get(:provider, :claude) |> Provider.resolve!()
session_options = Keyword.get(opts, :options, [])
{:ok, State.new(session_id, provider, session_options)}
end
@impl true
def handle_call(:get_state, _from, state) do
{:reply, state, state}
end
def handle_call(:checkpoint, _from, state) do
{:reply, {:ok, state.checkpoint}, state}
end
@impl true
def handle_call({:submit_run, prompt, opts}, _from, state) do
run_id = Keyword.get_lazy(opts, :run_id, &ASM.Event.generate_id/0)
run_opts = Keyword.drop(opts, [:run_id])
case maybe_start_or_queue_run(state, run_id, prompt, run_opts) do
{:ok, pid, next_state} ->
{:reply, {:ok, run_id, pid}, next_state}
{:queued, next_state} ->
{:reply, {:ok, run_id, :queued}, next_state}
{:error, %Error{} = error} ->
{:reply, {:error, error}, state}
end
end
def handle_call({:cancel_run, run_id}, _from, state) do
case Map.fetch(state.active_runs, run_id) do
{:ok, run_pid} ->
GenServer.cast(run_pid, :interrupt)
{:reply, :ok, state}
:error ->
{removed?, new_queue} = remove_queued_run(state.run_queue, run_id)
if removed? do
{:reply, :ok, %{state | run_queue: new_queue}}
else
{:reply, {:error, runtime_error(:unknown, "Unknown run id: #{run_id}")}, state}
end
end
end
def handle_call({:pause_run, run_id}, _from, state) do
case Map.fetch(state.active_runs, run_id) do
{:ok, run_pid} ->
GenServer.cast(run_pid, :interrupt)
{:reply, :ok, state}
:error ->
{:reply, {:error, runtime_error(:unknown, "Unknown active run id: #{run_id}")}, state}
end
end
def handle_call({:resume_run, prompt, opts}, _from, state) do
case build_continuation(state, opts) do
{:ok, continuation} ->
run_id = Keyword.get_lazy(opts, :run_id, &ASM.Event.generate_id/0)
run_opts =
opts
|> Keyword.drop([:run_id, :target, :continuation])
|> Keyword.put(:continuation, continuation)
case maybe_start_or_queue_run(state, run_id, prompt, run_opts) do
{:ok, pid, next_state} ->
{:reply, {:ok, run_id, pid}, next_state}
{:queued, next_state} ->
{:reply, {:ok, run_id, :queued}, next_state}
{:error, %Error{} = error} ->
{:reply, {:error, error}, state}
end
{:error, %Error{} = error} ->
{:reply, {:error, error}, state}
end
end
def handle_call({:intervene, run_id, prompt, opts}, _from, state) do
with :ok <- pause_active_run(state, run_id),
{:ok, continuation} <- build_continuation(state, opts) do
resumed_run_id = Keyword.get_lazy(opts, :run_id, &ASM.Event.generate_id/0)
run_opts =
opts
|> Keyword.drop([:run_id, :target, :continuation])
|> Keyword.put(:continuation, continuation)
|> Keyword.put_new(:intervention_for_run_id, run_id)
case maybe_start_or_queue_run(state, resumed_run_id, prompt, run_opts) do
{:ok, pid, next_state} ->
{:reply, {:ok, resumed_run_id, pid}, next_state}
{:queued, next_state} ->
{:reply, {:ok, resumed_run_id, :queued}, next_state}
{:error, %Error{} = error} ->
{:reply, {:error, error}, state}
end
else
{:error, %Error{} = error} ->
{:reply, {:error, error}, state}
end
end
def handle_call({:resolve_approval, approval_id, decision}, _from, state) do
case ApprovalCoordinator.resolve(state.pending_approval_index, approval_id) do
{:error, :unknown_approval, _remaining} ->
{:reply, {:error, Error.new(:unknown, :approval, "Unknown approval id: #{approval_id}")},
state}
{:ok, run_pid, remaining} ->
GenServer.cast(run_pid, {:resolve_approval, approval_id, decision})
{:reply, :ok, %{state | pending_approval_index: remaining}}
end
end
@impl true
def handle_info({:register_approval, run_pid, %Control.ApprovalRequest{} = request}, state)
when is_pid(run_pid) do
index = ApprovalCoordinator.register(state.pending_approval_index, run_pid, request)
{:noreply, %{state | pending_approval_index: index}}
end
def handle_info({:clear_approval, approval_id}, state) when is_binary(approval_id) do
{:noreply,
%{
state
| pending_approval_index:
ApprovalCoordinator.clear(state.pending_approval_index, approval_id)
}}
end
def handle_info({:cost_update, %Control.CostUpdate{} = payload}, state) do
{:noreply, %{state | cost: add_cost(state.cost, payload)}}
end
def handle_info({:capture_checkpoint, run_id, provider_session_id, metadata}, state)
when is_binary(run_id) and is_binary(provider_session_id) and provider_session_id != "" do
checkpoint =
Continuation.capture(state, %{
run_id: run_id,
provider_session_id: provider_session_id,
metadata: normalize_checkpoint_metadata(metadata)
})
{:noreply, Continuation.restore(state, checkpoint)}
end
def handle_info({:DOWN, ref, :process, run_pid, _reason}, state) do
case Map.fetch(state.run_monitors, run_pid) do
{:ok, ^ref} ->
state
|> clear_run_by_pid(run_pid)
|> maybe_start_next_queued_run()
|> then(&{:noreply, &1})
_ ->
{:noreply, state}
end
end
defp maybe_start_or_queue_run(state, run_id, prompt, run_opts) do
max_active = state.provider_profile.max_concurrent_runs
cond do
map_size(state.active_runs) < max_active ->
start_run(state, run_id, prompt, run_opts)
queue_has_capacity?(state) ->
queue_entry = %{run_id: run_id, prompt: prompt, opts: run_opts}
{:queued, %{state | run_queue: :queue.in(queue_entry, state.run_queue)}}
true ->
{:error, runtime_error(:at_capacity, "Session at run capacity")}
end
end
defp queue_has_capacity?(state) do
max_queued = state.provider_profile.max_queued_runs
max_queued > 0 and :queue.len(state.run_queue) < max_queued
end
defp start_run(state, run_id, prompt, run_opts) do
with {:ok, run_sup} <- lookup_run_supervisor(state.session_id),
{:ok, run_pid} <- start_run_child(state, run_sup, run_id, prompt, run_opts) do
monitor_ref = Process.monitor(run_pid)
next_state = %{
state
| active_runs: Map.put(state.active_runs, run_id, run_pid),
run_monitors: Map.put(state.run_monitors, run_pid, monitor_ref)
}
{:ok, run_pid, next_state}
else
{:error, %Error{} = error} ->
{:error, error}
{:error, reason} ->
{:error, runtime_error(:runtime, "Failed to start run: #{inspect(reason)}")}
end
end
defp lookup_run_supervisor(session_id) do
case Registry.lookup(:asm_sessions, {session_id, :run_sup}) do
[{pid, _}] ->
{:ok, pid}
[] ->
{:error, runtime_error(:runtime, "Run supervisor not found for session #{session_id}")}
end
end
defp start_run_child(state, run_sup, run_id, prompt, run_opts) do
run_module = Keyword.get(run_opts, :run_module, ASM.Run.Server)
run_module_opts = Keyword.get(run_opts, :run_module_opts, [])
passthrough_opts = Keyword.drop(run_opts, [:run_module, :run_module_opts])
child_opts =
[
run_id: run_id,
session_id: state.session_id,
provider: state.provider.name,
prompt: prompt,
session_pid: self()
] ++ passthrough_opts ++ run_module_opts
DynamicSupervisor.start_child(run_sup, {run_module, child_opts})
end
defp maybe_start_next_queued_run(state) do
case :queue.out(state.run_queue) do
{{:value, %{run_id: run_id, prompt: prompt, opts: opts}}, remaining}
when map_size(state.active_runs) < state.provider_profile.max_concurrent_runs ->
case start_run(%{state | run_queue: remaining}, run_id, prompt, opts) do
{:ok, _pid, started_state} -> started_state
_ -> %{state | run_queue: remaining}
end
_ ->
state
end
end
defp clear_run_by_pid(state, run_pid) do
{run_id, active_runs} = pop_run_by_pid(state.active_runs, run_pid)
monitor_ref = Map.get(state.run_monitors, run_pid)
if monitor_ref, do: Process.demonitor(monitor_ref, [:flush])
pending_approval_index = drop_approvals_for_pid(state.pending_approval_index, run_pid)
if run_id do
%{
state
| active_runs: active_runs,
run_monitors: Map.delete(state.run_monitors, run_pid),
pending_approval_index: pending_approval_index
}
else
%{
state
| run_monitors: Map.delete(state.run_monitors, run_pid),
pending_approval_index: pending_approval_index
}
end
end
defp pop_run_by_pid(active_runs, run_pid) do
case Enum.find(active_runs, fn {_run_id, pid} -> pid == run_pid end) do
{run_id, _pid} -> {run_id, Map.delete(active_runs, run_id)}
nil -> {nil, active_runs}
end
end
defp drop_approvals_for_pid(index, run_pid) do
index
|> Enum.reject(fn {_approval_id, pid} -> pid == run_pid end)
|> Map.new()
end
defp remove_queued_run(queue, run_id) do
list = :queue.to_list(queue)
if Enum.any?(list, fn entry -> entry.run_id == run_id end) do
trimmed = Enum.reject(list, fn entry -> entry.run_id == run_id end)
{true, :queue.from_list(trimmed)}
else
{false, queue}
end
end
defp build_continuation(%State{} = state, opts) when is_list(opts) do
case Keyword.get(opts, :continuation) do
nil ->
SessionControl.continuation_from_checkpoint(
state.checkpoint,
Keyword.take(opts, [:target])
)
continuation when is_map(continuation) ->
{:ok, continuation}
other ->
{:error,
runtime_error(
:config_invalid,
"invalid continuation #{inspect(other)}; expected a continuation map or nil"
)}
end
end
defp pause_active_run(%State{active_runs: active_runs}, run_id) when is_binary(run_id) do
case Map.fetch(active_runs, run_id) do
{:ok, run_pid} ->
GenServer.cast(run_pid, :interrupt)
:ok
:error ->
{:error, runtime_error(:unknown, "Unknown active run id: #{run_id}")}
end
end
defp normalize_checkpoint_metadata(metadata) when is_map(metadata), do: metadata
defp normalize_checkpoint_metadata(_metadata), do: %{}
defp runtime_error(kind, message) do
Error.new(kind, :runtime, message)
end
defp add_cost(current, %Control.CostUpdate{} = payload) do
%{
input_tokens: default_zero(current[:input_tokens]) + payload.input_tokens,
output_tokens: default_zero(current[:output_tokens]) + payload.output_tokens,
cost_usd: default_zero(current[:cost_usd]) + payload.cost_usd
}
end
defp default_zero(nil), do: 0
defp default_zero(value), do: value
end