The experimental Agent layer keeps conversational state in a :gen_statem while every model turn remains an ordinary, durable Oban job.

It is intentionally aligned with oban_claude. Provider sessions live in bounded, host-named conversation arcs; the provider-specific resume handle is Codex's thread_id, placed in the next job as "session_id".

Supervision

Add the Agent supervisor after Oban:

children = [
  MyApp.Repo,
  {Oban, Application.fetch_env!(:my_app, Oban)},
  {ObanCodex.Agent.Supervisor, []}
]

The supervisor owns a Registry and DynamicSupervisor. Agents exist only while their processes are alive. Oban durably retains queued jobs and retry attempts, but it does not restore an Agent's arcs, history, pending gate, or current turn after that process stops. Seed persisted arc handles when starting the replacement. It has a new generation and rejects late callbacks from the earlier process.

Start an agent

{:ok, _pid} =
  ObanCodex.Agent.start_agent("triage-7",
    args:
      ObanCodex.Args.defaults(
        working_dir: "/srv/checkouts/project",
        sandbox: :read_only,
        approval_policy: :never,
        output_schema: "/srv/my_app/priv/agent.schema.json"
      ),
    approved_args: %{
      "sandbox" => "workspace_write",
      "approval_policy" => "never"
    },
    job_timeout: :timer.minutes(12),
    max_history: 500,
    session_arcs: %{"operator" => persisted_thread_id},
    max_session_arcs: 32
  )

Configuration:

  • args — defaults under every turn.
  • approved_args — temporary overrides for an approved continuation only.
  • worker — turn worker, default ObanCodex.Agent.Job.
  • oban — named Oban instance, default Oban.
  • job_timeout — watchdog for one attempt plus expected retry backoff.
  • max_history — bounded in-process event history.
  • session_arcs — optional %{arc_id => thread_id} restore seed.
  • max_session_arcs — bounded retained handle count, default 32.
  • enqueue_fun — offline test seam.

States

StateMeaning
idleready for a prompt
runningan Oban turn is in flight
waiting_for_userstructured directive asked a question
awaiting_permissionstructured directive requested an action
pausedemergency lockdown
offlineno registered process
:processing = ObanCodex.Agent.submit_prompt("triage-7", "triage the new issues")
{:ok, :running} = ObanCodex.Agent.status("triage-7")

{:ok, settled} =
  ObanCodex.Agent.await(
    "triage-7",
    [:idle, :waiting_for_user, :awaiting_permission],
    :timer.minutes(10)
  )

cast_prompt/3 is the non-blocking form. Prompts sent while a turn or approval is active are postponed by the state machine.

Directive schema

The lifecycle interprets two conventional structured directives:

{
  "type": "object",
  "additionalProperties": false,
  "required": ["directive", "summary"],
  "properties": {
    "directive": {
      "type": "string",
      "enum": ["none", "ask_user", "request_permission"]
    },
    "summary": {"type": "string"},
    "question": {"type": "string"},
    "action": {"type": "string"}
  }
}
  • ask_user parks in waiting_for_user; the next operator prompt is the answer.
  • request_permission parks in awaiting_permission; approve or reject using the action id returned by status/1.
  • anything else returns to idle.
{:ok, {:awaiting_permission, %{id: action_id, description: description}}} =
  ObanCodex.Agent.status("triage-7")

:processing =
  ObanCodex.Agent.approve_action("triage-7", action_id,
    args: %{"sandbox" => "workspace_write"}
  )

Only the continuation turn receives approved_args and the optional string-keyed :args overrides passed to approve_action/3. If that turn fails or hits the watchdog, the action re-gates with a fresh id rather than silently losing its elevation.

Session threading

Each completed result supplies:

ObanCodex.session_id(result)

The next turn in the same arc receives that value under "session_id". Omitting arc_id uses the backward-compatible "default" arc:

ObanCodex.Agent.submit_prompt("triage-7", "continue the issue",
  arc_id: "issue-651"
)

ObanCodex.Agent.submit_prompt("triage-7", "start a new sweep",
  arc_id: "daily-sweep",
  session: :fresh
)

Freshness clears only the selected arc. session: :fresh_fallback records that the host deliberately started fresh after a failed resume. A terminal resume classified as :session_not_found, :invalid_session, :unknown_session, or :session_rejected appears in info/1 as the typed continuation outcome: :session_rejected, so the host can reconstruct a durable handoff and retry without silently selecting another local transcript.

Each job's metadata and [:oban_codex, :agent, :turn_completed] telemetry identify the arc, input session, continuation decision and reason, and final outcome. Least-recently used inactive handles are evicted at the configured bound. Durable persistence and rotation policy belong to the host.

The provider-neutral fork_arc/5 API currently returns {:error, :fork_unsupported} because codex_wrapper does not expose a stable codex exec fork command contract yet.

Never put ephemeral: true in an Agent's default args; there would be no session file to resume.

Retry semantics

ObanCodex.Agent.Job distinguishes a retryable attempt from a finished logical turn. While Oban will retry, the state stays running, records a :retrying entry, and re-arms the watchdog. A terminal result/error feeds ObanCodex.Agent.job_finished/3.

Every job carries an opaque instance generation and logical turn id in its metadata. The instance checks both inside the state machine before changing state, session, approval, counters, or watchdogs. Late outcomes, duplicate callbacks, and callbacks from an earlier same-id process are retained only as bounded diagnostics. Custom workers that delegate their result and error callbacks to ObanCodex.Agent.Job inherit this behavior automatically.

Tune job_timeout above one command timeout plus the largest expected backoff.

Scheduling

ObanCodex.Agent.Tick adapts Oban.Plugins.Cron to the lifecycle by delivering a prompt through the Agent facade. It does not enqueue a turn behind the state machine:

{Oban.Plugins.Cron,
 crontab: [
   {"0 9 * * *", ObanCodex.Agent.Tick,
    args: %{
      "agent_id" => "standup",
      "arc_id" => "daily-sweep",
      "prompt" => "Summarize overnight CI failures.",
      "session" => "fresh",
      "if_offline" => "start",
      "start" => %{"args" => %{"sandbox" => "read_only"}}
    }}
 ]}

Tick policies are if_busy ("skip" by default or "queue"), if_offline ("skip" by default or "start"), and session ("resume" by default or "fresh"). Run ticks on a dedicated queue such as queues: [agents: 2, ticks: 1]; a tick on the Agent turn queue can wait behind the work whose busy state it is meant to observe.

Emergency pause

:ok = ObanCodex.Agent.emergency_pause("triage-7")
{:ok, :paused} = ObanCodex.Agent.status("triage-7")
:resumed = ObanCodex.Agent.resume_agent("triage-7")

Pause drops pending question/action scopes. A late result is recorded but cannot unlock the agent or trigger a directive while paused.

Inspection

{:ok, info} = ObanCodex.Agent.info("triage-7")
{:ok, history} = ObanCodex.Agent.history("triage-7")

info includes state, the default session id, all retained session_arcs, the active arc, the current or most recent continuation, turns, pending scopes, and cost_usd. Codex doesn't report price, so cost stays 0.0 unless a custom error payload provides one.

Offline tests

Inject enqueue rather than starting Oban:

test_pid = self()

enqueue = fn args, meta ->
  send(test_pid, {:enqueued, args, meta})
  {:ok, :queued}
end

{:ok, _} = ObanCodex.Agent.start_agent("test-agent", enqueue_fun: enqueue)
:processing = ObanCodex.Agent.submit_prompt("test-agent", "work")

assert_receive {:enqueued, %{"prompt" => "work"}, %{"agent_id" => "test-agent"} = meta}

:ok =
  ObanCodex.Agent.job_finished(
    "test-agent",
    {:ok, ObanCodex.Testing.result("done", session_id: "thread-1")},
    meta
  )