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_routine.exs
Raw

examples/agent_routine.exs

# examples/agent_routine.exs
#
# A Routines-shaped agent: `Oban.Plugins.Cron` fires `ObanClaude.Agent.Tick`
# on a schedule, and the tick delivers a PROMPT to a long-lived agent through
# the facade -- Cron keeps its worker-layer contract, the state machine keeps
# its turn-enqueueing invariant, and the Tick worker is the adapter between
# them.
#
# Offline and deterministic like the other CI-safe examples (claude stubbed
# via :query_fun), but NOT run in CI: proving the schedule fires itself means
# waiting up to ~70s for a real minute-boundary Cron tick.
#
# mix run examples/agent_routine.exs
#
# Two acts:
# 1. an immediate hand-inserted tick, to show delivery and the skip policy;
# 2. a bounded wait for the real Cron tick to prompt the agent on its own.
import Ecto.Query, only: [from: 2]
# ---------------------------------------------------------------------------
# 1. Throwaway SQLite repo + the stubbed agent turn worker.
# ---------------------------------------------------------------------------
db_path = Path.join(System.tmp_dir!(), "oban_claude_agent_routine.db")
for suffix <- ["", "-shm", "-wal"], do: File.rm(db_path <> suffix)
defmodule AgentRoutineRepo do
use Ecto.Repo, otp_app: :oban_claude, adapter: Ecto.Adapters.SQLite3
end
Application.put_env(:oban_claude, AgentRoutineRepo,
database: db_path,
pool_size: 1,
busy_timeout: 5_000,
log: false
)
defmodule AgentRoutineMigration do
use Ecto.Migration
def up, do: Oban.Migrations.up()
def down, do: Oban.Migrations.down()
end
defmodule RoutineAgentWorker do
use ObanClaude.Worker,
queue: :agents,
max_attempts: 1,
query_fun: &__MODULE__.fake_query/2
import ObanClaude.Testing, only: [result: 1]
@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
IO.puts(" [claude] resume=#{inspect(query_opts[:resume])} prompt=#{inspect(prompt)}")
# a visible busy window, so Act 1's second tick observes :running
Process.sleep(2_000)
{:ok, result(result: "routine ran: #{prompt}", session_id: "sess-routine")}
end
end
# ---------------------------------------------------------------------------
# 2. Boot: Oban with the Cron plugin scheduling a Tick every minute, then the
# agent tree and the agent itself. The tick uses session "fresh" (Routines
# style: each beat starts a clean claude session) and default skip
# policies.
# ---------------------------------------------------------------------------
tick_args = %{
"agent_id" => "routine",
"prompt" => "Do the scheduled sweep.",
"session" => "fresh"
}
{:ok, _} = AgentRoutineRepo.start_link()
Ecto.Migrator.up(AgentRoutineRepo, 1, AgentRoutineMigration, log: false)
{:ok, _} =
Oban.start_link(
repo: AgentRoutineRepo,
engine: Oban.Engines.Lite,
notifier: Oban.Notifiers.PG,
peer: Oban.Peers.Isolated,
plugins: [
{Oban.Plugins.Cron, crontab: [{"* * * * *", ObanClaude.Agent.Tick, args: tick_args}]}
],
# ticks on their own queue, so a beat can observe (and skip) a busy agent
# instead of serializing behind the agent's own turn job
queues: [agents: 1, ticks: 1]
)
{:ok, _} = ObanClaude.Agent.Supervisor.start_link()
:telemetry.attach(
"agent-routine-transitions",
[:oban_claude, :agent, :transition],
fn _e, _m, meta, _c -> IO.puts(" [transition] #{meta.from} -> #{meta.to}") end,
nil
)
{:ok, _} = ObanClaude.Agent.start_agent("routine", worker: RoutineAgentWorker)
# ---------------------------------------------------------------------------
# 3. Act 1: a hand-inserted tick delivers now; a second tick while the agent
# is busy is skipped (the default if_busy policy), visible as a cancelled
# row.
# ---------------------------------------------------------------------------
IO.puts("\nAct 1: hand-insert a tick; insert a second while the turn runs (skipped).")
{:ok, _} = Oban.insert(ObanClaude.Agent.Tick.new(tick_args, queue: :ticks))
{:ok, :running} = ObanClaude.Agent.await("routine", :running, 15_000)
# the agent is mid-turn: this beat hits the default if_busy skip policy
{:ok, skipped} = Oban.insert(ObanClaude.Agent.Tick.new(tick_args, queue: :ticks))
{:ok, :idle} = ObanClaude.Agent.await("routine", :idle, 15_000)
{:ok, history} = ObanClaude.Agent.history("routine")
IO.puts(" agent history so far: #{inspect(history, printable_limit: 200)}")
IO.puts(" skipped beat is job ##{skipped.id} (see final table)")
# ---------------------------------------------------------------------------
# 4. Act 2: wait for the real Cron tick at the next minute boundary.
# ---------------------------------------------------------------------------
IO.puts("\nAct 2: waiting up to ~70s for Cron to fire the next tick itself...")
count_ticks = fn ->
AgentRoutineRepo.one(
from(j in "oban_jobs", where: j.worker == "ObanClaude.Agent.Tick", select: count(j.id))
)
end
baseline = count_ticks.()
Enum.reduce_while(1..150, nil, fn _i, _acc ->
if count_ticks.() > baseline do
{:halt, :ok}
else
Process.sleep(500)
{:cont, nil}
end
end) || raise "Cron never fired within the wait budget"
{:ok, :idle} = ObanClaude.Agent.await("routine", :idle, 15_000)
{:ok, info} = ObanClaude.Agent.info("routine")
IO.puts("\nAfter the Cron beat: turns=#{info.turns}")
rows =
AgentRoutineRepo.all(
from(j in "oban_jobs",
where: j.worker == "ObanClaude.Agent.Tick",
select: {j.id, j.state, j.cancelled_at},
order_by: [asc: j.id]
)
)
IO.puts("Tick rows (completed = delivered, cancelled = skipped beat):")
for {id, state, _} <- rows, do: IO.puts(" ##{id} #{state}")