Packages

Elixir SDK for the SW4RM multi-agent coordination protocol

Current section

Files

Jump to
sw4rm_sdk lib sw4rm ack_manager.ex
Raw

lib/sw4rm/ack_manager.ex

defmodule Sw4rm.AckManager do
@moduledoc """
ACK lifecycle manager for message tracking per spec S11.
GenServer tracking outgoing and incoming message acknowledgements.
"""
use GenServer
defmodule Record do
@moduledoc "ACK tracking record for a single message."
@type t :: %__MODULE__{}
defstruct [:message_id, :stage, :error_code, :direction, :timestamp_ms, :note]
end
@terminal_stages [:fulfilled, :rejected, :failed, :timed_out]
# -- Client API --
@doc "Start the ACK manager. Required: `:agent_id`. Options: `:ack_timeout_seconds` (default 10)."
def start_link(opts) do
name = Keyword.get(opts, :name)
GenServer.start_link(__MODULE__, opts, name: name)
end
@doc "Begin tracking an outgoing message by ID. Returns `{:error, {:already_tracked, id}}` if duplicate."
@spec track_outgoing(GenServer.server(), String.t()) :: {:ok, Record.t()} | {:error, term()}
def track_outgoing(server, message_id),
do: GenServer.call(server, {:track_outgoing, message_id})
@doc "Begin tracking an incoming message by ID. Returns `{:error, {:already_tracked, id}}` if duplicate."
@spec track_incoming(GenServer.server(), String.t()) :: {:ok, Record.t()} | {:error, term()}
def track_incoming(server, message_id),
do: GenServer.call(server, {:track_incoming, message_id})
@doc "Update the ACK stage for a tracked message. Accepts optional `:error_code` and `:note`."
@spec update(GenServer.server(), String.t(), atom(), keyword()) ::
{:ok, Record.t()} | {:error, term()}
def update(server, message_id, stage, opts \\ []) do
GenServer.call(server, {:update, message_id, stage, opts})
end
@doc "Return all non-terminal records, optionally filtered by `:in` or `:out` direction."
@spec get_unacked(GenServer.server(), atom() | nil) :: [Record.t()]
def get_unacked(server, direction \\ nil), do: GenServer.call(server, {:get_unacked, direction})
@doc "Return records whose age exceeds the configured `:ack_timeout_seconds`."
@spec reconcile_stale(GenServer.server()) :: [Record.t()]
def reconcile_stale(server), do: GenServer.call(server, :reconcile_stale)
@doc "Remove a tracked message by ID. Returns the record or `nil`."
@spec remove(GenServer.server(), String.t()) :: Record.t() | nil
def remove(server, message_id), do: GenServer.call(server, {:remove, message_id})
@doc "Return the total number of tracked messages."
@spec count(GenServer.server()) :: non_neg_integer()
def count(server), do: GenServer.call(server, :count)
# -- Callbacks --
@impl true
def init(opts) do
agent_id = Keyword.fetch!(opts, :agent_id)
timeout_s = Keyword.get(opts, :ack_timeout_seconds, 10)
{:ok, %{agent_id: agent_id, pending: %{}, timeout_s: timeout_s}}
end
@impl true
def handle_call({:track_outgoing, mid}, _from, s) do
if Map.has_key?(s.pending, mid) do
{:reply, {:error, {:already_tracked, mid}}, s}
else
record = %Record{
message_id: mid,
stage: :unspecified,
error_code: :unspecified,
direction: :out,
timestamp_ms: System.system_time(:millisecond),
note: ""
}
{:reply, {:ok, record}, %{s | pending: Map.put(s.pending, mid, record)}}
end
end
def handle_call({:track_incoming, mid}, _from, s) do
if Map.has_key?(s.pending, mid) do
{:reply, {:error, {:already_tracked, mid}}, s}
else
record = %Record{
message_id: mid,
stage: :received,
error_code: :unspecified,
direction: :in,
timestamp_ms: System.system_time(:millisecond),
note: ""
}
{:reply, {:ok, record}, %{s | pending: Map.put(s.pending, mid, record)}}
end
end
def handle_call({:update, mid, stage, opts}, _from, s) do
case Map.get(s.pending, mid) do
nil ->
{:reply, {:error, {:not_tracked, mid}}, s}
record ->
updated =
record
|> Map.put(:stage, stage)
|> then(fn r ->
case Keyword.get(opts, :error_code) do
nil -> r
ec -> Map.put(r, :error_code, ec)
end
end)
|> then(fn r ->
case Keyword.get(opts, :note) do
nil -> r
n -> Map.put(r, :note, n)
end
end)
{:reply, {:ok, updated}, %{s | pending: Map.put(s.pending, mid, updated)}}
end
end
def handle_call({:get_unacked, direction}, _from, s) do
result =
s.pending
|> Map.values()
|> Enum.filter(fn r ->
r.stage not in @terminal_stages and
(direction == nil or r.direction == direction)
end)
{:reply, result, s}
end
def handle_call(:reconcile_stale, _from, s) do
now = System.system_time(:millisecond)
timeout_ms = s.timeout_s * 1000
stale =
s.pending
|> Map.values()
|> Enum.filter(fn r ->
r.stage not in @terminal_stages and now - r.timestamp_ms > timeout_ms
end)
{:reply, stale, s}
end
def handle_call({:remove, mid}, _from, s) do
{val, pending} = Map.pop(s.pending, mid)
{:reply, val, %{s | pending: pending}}
end
def handle_call(:count, _from, s), do: {:reply, map_size(s.pending), s}
end