One agent as one :gen_statem process, its asynchronous turns run as Oban
jobs. Part of the experimental agent layer (see ObanClaude.Agent).
The process never blocks on claude: a prompt enqueues an ObanClaude.Worker
job and the machine parks in :running until the worker's callbacks route
the outcome back as a {:job_finished, payload} cast (via
ObanClaude.Agent.job_finished/2). States:
:idle-- ready for a prompt:running-- an Oban job is in flight; further prompts are:postponed and a:state_timeoutwatchdog guards against a turn that never reports back. A retryable failed attempt (ObanClaude.Agent.job_retrying/2) keeps the machine here and re-arms the watchdog, so:job_timeoutshould exceed one attempt's backoff plus its execution:waiting_for_user-- the last turn asked a question (structured-output directive"ask_user"); the next prompt is treated as the answer and resumes the claude session:awaiting_permission-- the last turn requested approval (directive"request_permission");approveresumes the session with the action (under:approved_args, see below),rejectrecords the denial and returns to:idle, and prompts are:postponed until the gate clears. An approved turn that fails or hits the watchdog RE-GATES (same description, fresh id,{:approval_incomplete, reason}in history): the work was approved but not completed, and a re-approval resumes it:paused-- lockdown via:emergency_pause; every call is refused until an explicitresume
Every state change atomically synchronizes the registry value -- the state
atom, paired with the pending action or question in the gated states -- so
ObanClaude.Agent.status/1 reads both without messaging the process. Each
change also emits [:oban_claude, :agent, :transition] telemetry with
%{agent_id, from, to} metadata (state atoms).
The claude session id is read off each turn's payload (including rail-stop
errors) and threaded into the next turn as resume, so one agent is one
persistent claude conversation. Turn count and accumulated cost ride in the
data (see ObanClaude.Agent.info/1).
Config
start_agent/2 takes a keyword list or map:
:args-- default claude args merged under every turn's prompt (build withObanClaude.Args.defaults/1; string keys); default%{}:approved_args-- claude args merged over:argsfor approve continuations ONLY, so conversational approval actually unlocks something -- e.g.%{"permission_mode" => "accept_edits"}or anallowed_toolsgrant. Normal turns never carry these. Default%{}.:worker-- the Oban worker module for turns; defaultObanClaude.Agent.Job:oban-- the Oban instance name to insert into; defaultOban:job_timeout-- the:runningwatchdog in milliseconds; default 60000:max_history-- cap on retained history entries (newest win); default 500, so an always-on agent's event log cannot grow without bound:enqueue_fun-- a 2-arity(args, meta) -> {:ok, term} | {:error, term}override of the enqueue itself, for tests (no Oban, no DB)