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

examples/agent_routine_live.exs

# examples/agent_routine_live.exs
#
# A real cron routine against REAL claude calls -- this script COSTS MONEY
# (two haiku turns, a few cents) and takes ~2-3 minutes: it waits for TWO real
# minute-boundary Cron ticks.
#
# Nothing is stubbed and nothing is pre-started. The crontab entry is the
# whole routine spec: `if_offline: "start"` means the FIRST beat boots the
# agent (model, watchdog) from the tick's own args, and `session: "fresh"`
# gives every beat a clean claude session, Routines-style. Oban owns the
# schedule, the Tick adapter delivers the prompt through the facade, the
# state machine owns the turn.
#
# mix run examples/agent_routine_live.exs
import Ecto.Query, only: [from: 2]
# ---------------------------------------------------------------------------
# 1. Throwaway SQLite repo.
# ---------------------------------------------------------------------------
db_path = Path.join(System.tmp_dir!(), "oban_claude_agent_routine_live.db")
for suffix <- ["", "-shm", "-wal"], do: File.rm(db_path <> suffix)
defmodule RoutineLiveRepo do
use Ecto.Repo, otp_app: :oban_claude, adapter: Ecto.Adapters.SQLite3
end
Application.put_env(:oban_claude, RoutineLiveRepo,
database: db_path,
pool_size: 1,
busy_timeout: 5_000,
log: false
)
defmodule RoutineLiveMigration do
use Ecto.Migration
def up, do: Oban.Migrations.up()
def down, do: Oban.Migrations.down()
end
# ---------------------------------------------------------------------------
# 2. Boot: the crontab entry IS the routine. No agent is started here.
# ---------------------------------------------------------------------------
tick_args = %{
"agent_id" => "routine-live",
"prompt" => "Reply with one short haiku about job queues. Nothing else.",
"session" => "fresh",
"if_offline" => "start",
"start" => %{"args" => %{"model" => "haiku"}, "job_timeout" => 120_000}
}
{:ok, _} = RoutineLiveRepo.start_link()
Ecto.Migrator.up(RoutineLiveRepo, 1, RoutineLiveMigration, log: false)
{:ok, _} =
Oban.start_link(
repo: RoutineLiveRepo,
engine: Oban.Engines.Lite,
notifier: Oban.Notifiers.PG,
peer: Oban.Peers.Isolated,
plugins: [
{Oban.Plugins.Cron,
crontab: [{"* * * * *", ObanClaude.Agent.Tick, args: tick_args, queue: :ticks}]}
],
# ticks on their own queue so a beat observes the agent, not its queue slot
queues: [agents: 1, ticks: 1]
)
{:ok, _} = ObanClaude.Agent.Supervisor.start_link()
:telemetry.attach(
"routine-live-transitions",
[:oban_claude, :agent, :transition],
fn _e, _m, meta, _c ->
IO.puts(" [transition] #{meta.agent_id}: #{meta.from} -> #{meta.to}")
end,
nil
)
:telemetry.attach(
"routine-live-runs",
[:oban_claude, :run, :stop],
fn _e, meas, meta, _c ->
IO.puts(" [turn] $#{Float.round(meas.cost_usd, 4)} session=#{meta.result.session_id}")
IO.puts(" [turn] #{String.trim(meta.result.result || "")}")
end,
nil
)
IO.puts("No agent started. Waiting for Cron to fire the first beat (auto-start)...")
IO.puts("Status now: #{inspect(ObanClaude.Agent.status("routine-live"))}\n")
# ---------------------------------------------------------------------------
# 3. Wait for two beats' worth of finished turns (~2-3 minutes).
# ---------------------------------------------------------------------------
wait_for_turns = fn target, budget_ms ->
deadline = System.monotonic_time(:millisecond) + budget_ms
Enum.reduce_while(Stream.cycle([:tick]), nil, fn _t, _acc ->
with {:ok, :idle} <- ObanClaude.Agent.status("routine-live"),
{:ok, %{turns: turns}} when turns >= target <- ObanClaude.Agent.info("routine-live") do
{:halt, :ok}
else
_not_yet ->
if System.monotonic_time(:millisecond) > deadline do
{:halt, {:error, :timeout}}
else
Process.sleep(1_000)
{:cont, nil}
end
end
end)
end
:ok = wait_for_turns.(1, 150_000) || raise "first beat never landed"
IO.puts("\nbeat 1 landed (agent was auto-started by the tick). Waiting for beat 2...\n")
:ok = wait_for_turns.(2, 150_000) || raise "second beat never landed"
# ---------------------------------------------------------------------------
# 4. The ledger: two independent sessions, two beats, real spend.
# ---------------------------------------------------------------------------
{:ok, info} = ObanClaude.Agent.info("routine-live")
{:ok, history} = ObanClaude.Agent.history("routine-live")
IO.puts("\nAgent: turns=#{info.turns} cost=$#{Float.round(info.cost_usd, 4)}")
IO.puts("History (oldest first):")
for entry <- history, do: IO.puts(" #{inspect(entry, printable_limit: 140)}")
rows =
RoutineLiveRepo.all(
from(j in "oban_jobs", select: {j.id, j.worker, j.state}, order_by: [asc: j.id])
)
IO.puts("\nJob rows (Tick beats + the agent turns they delivered):")
for {id, worker, state} <- rows,
do: IO.puts(" ##{id} #{String.pad_trailing(state, 10)} #{worker}")