Packages

Run Claude Code jobs on an Oban queue: an Oban.Worker over claude_wrapper that maps claude's typed result/error onto Oban return values.

Current section

Files

Jump to
oban_claude examples agent_lifecycle.exs
Raw

examples/agent_lifecycle.exs

# examples/agent_lifecycle.exs
#
# The agent lifecycle spike end to end, offline: one long-lived
# ObanClaude.Agent (:gen_statem) whose turns run as REAL Oban jobs on the
# throwaway SQLite (Lite) engine, with only the claude call stubbed via the
# worker's :query_fun seam.
#
# Walks the whole state matrix: a plain turn, a request_permission turn
# approved by the "human" (note the :approved_args elevation on that turn
# only), an ask_user turn answered by the "human", and the emergency pause.
# Transitions print live off the telemetry event.
#
# mix run examples/agent_lifecycle.exs
# ---------------------------------------------------------------------------
# 1. Throwaway SQLite repo + Oban schema, as in examples/playground.exs.
# ---------------------------------------------------------------------------
db_path = Path.join(System.tmp_dir!(), "oban_claude_agent_lifecycle.db")
for suffix <- ["", "-shm", "-wal"], do: File.rm(db_path <> suffix)
defmodule LifecycleRepo do
use Ecto.Repo, otp_app: :oban_claude, adapter: Ecto.Adapters.SQLite3
end
Application.put_env(:oban_claude, LifecycleRepo,
database: db_path,
pool_size: 1,
busy_timeout: 5_000,
log: false
)
defmodule LifecycleMigration do
use Ecto.Migration
def up, do: Oban.Migrations.up()
def down, do: Oban.Migrations.down()
end
# ---------------------------------------------------------------------------
# 2. The turn worker: the REAL ObanClaude.Worker pipeline with a scripted
# claude. Routing back to the agent delegates to ObanClaude.Agent.Job, the
# canonical router -- this module only swaps the claude entrypoint.
# ---------------------------------------------------------------------------
defmodule LifecycleWorker do
use ObanClaude.Worker,
queue: :agents,
max_attempts: 1,
query_fun: &__MODULE__.fake_query/2
import ObanClaude.Testing, only: [result: 1, structured_result: 2]
@impl ObanClaude.Worker
def handle_result(result, job), do: ObanClaude.Agent.Job.handle_result(result, job)
@impl ObanClaude.Worker
def handle_error(verdict, payload, job),
do: ObanClaude.Agent.Job.handle_error(verdict, payload, job)
def fake_query(prompt, query_opts) do
# permission_mode only appears on the approve continuation (:approved_args)
IO.puts(
" [claude] resume=#{inspect(query_opts[:resume])} " <>
"permission_mode=#{inspect(query_opts[:permission_mode])} prompt=#{inspect(prompt)}"
)
{:ok, scenario(prompt)}
end
# The prompt doubles as the scenario selector. Every result carries the same
# session id, so the resume handle visibly threads turn to turn.
defp scenario("plan a refactor" <> _rest) do
structured_result(
%{"directive" => "request_permission", "action" => "rewrite lib/core.ex"},
session_id: "sess-demo"
)
end
defp scenario("Approved:" <> _rest),
do: result(result: "refactor complete", session_id: "sess-demo")
defp scenario("pick a deploy env" <> _rest) do
structured_result(
%{"directive" => "ask_user", "question" => "which environment?"},
session_id: "sess-demo"
)
end
defp scenario("staging" <> _rest),
do: result(result: "deployed to staging", session_id: "sess-demo")
defp scenario(other),
do: result(result: "done: #{other}", session_id: "sess-demo")
end
# ---------------------------------------------------------------------------
# 3. Boot: repo, migration, Oban (agents queue), the agent supervision tree.
# ---------------------------------------------------------------------------
{:ok, _} = LifecycleRepo.start_link()
Ecto.Migrator.up(LifecycleRepo, 1, LifecycleMigration, log: false)
{:ok, _} =
Oban.start_link(
repo: LifecycleRepo,
engine: Oban.Engines.Lite,
notifier: Oban.Notifiers.PG,
peer: Oban.Peers.Isolated,
plugins: [],
queues: [agents: 1]
)
{:ok, _} = ObanClaude.Agent.Supervisor.start_link()
:telemetry.attach(
"agent-lifecycle-demo",
[:oban_claude, :agent, :transition],
fn _event, _measurements, meta, _config ->
IO.puts(" [transition] #{meta.agent_id}: #{meta.from} -> #{meta.to}")
end,
nil
)
{:ok, _pid} =
ObanClaude.Agent.start_agent("demo",
worker: LifecycleWorker,
approved_args: %{"permission_mode" => "accept_edits"}
)
# ---------------------------------------------------------------------------
# 4. Walk the matrix. `await/3` blocks on the registry until the state lands,
# returning the gated payload atomically with the state.
# ---------------------------------------------------------------------------
IO.puts("\n== turn 1: a plain prompt (idle -> running -> idle) ==")
:processing = ObanClaude.Agent.submit_prompt("demo", "summarize the repo")
{:ok, :idle} = ObanClaude.Agent.await("demo", :idle, 10_000)
IO.puts("\n== turn 2: request_permission (running -> awaiting_permission) ==")
:processing = ObanClaude.Agent.submit_prompt("demo", "plan a refactor")
{:ok, {:awaiting_permission, %{id: action_id, description: description}}} =
ObanClaude.Agent.await("demo", :awaiting_permission, 10_000)
IO.puts(" [human] pending action #{action_id}: #{description} -- approving")
:processing = ObanClaude.Agent.approve_action("demo", action_id)
{:ok, :idle} = ObanClaude.Agent.await("demo", :idle, 10_000)
IO.puts("\n== turn 3: ask_user (running -> waiting_for_user) ==")
:processing = ObanClaude.Agent.submit_prompt("demo", "pick a deploy env")
{:ok, {:waiting_for_user, question}} =
ObanClaude.Agent.await("demo", :waiting_for_user, 10_000)
IO.puts(" [human] agent asks: #{question} -- answering \"staging\"")
:processing = ObanClaude.Agent.submit_prompt("demo", "staging")
{:ok, :idle} = ObanClaude.Agent.await("demo", :idle, 10_000)
IO.puts("\n== emergency pause and resume ==")
:ok = ObanClaude.Agent.emergency_pause("demo")
{:ok, :paused} = ObanClaude.Agent.await("demo", :paused, 10_000)
{:error, :paused} = ObanClaude.Agent.submit_prompt("demo", "anything")
IO.puts(" [human] prompt while paused refused; resuming")
:resumed = ObanClaude.Agent.resume_agent("demo")
{:ok, info} = ObanClaude.Agent.info("demo")
{:ok, history} = ObanClaude.Agent.history("demo")
IO.puts("\nInfo: #{inspect(Map.take(info, [:state, :turns, :cost_usd, :session_id]))}")
IO.puts("History (oldest first):")
for entry <- history, do: IO.puts(" #{inspect(entry)}")