Current section
Files
Jump to
Current section
Files
examples/propose_dispose.exs
# examples/propose_dispose.exs
#
# Shows the shape the autonomous issue/PR worker is built around: "propose /
# dispose". A claude worker (queue :claude) runs with a JSON schema, gets back
# a typed outcome, and *disposes* by enqueuing a follow-on effector job on a
# second queue (:sink) -- the stand-in for the future, separate `oban_github`
# sink. The sink is just another Oban worker; oban_claude does not know of it.
#
# Two Oban patterns on display: multiple queues, and a worker enqueuing a
# follow-on job.
#
# The claude call is stubbed via :query_fun (offline, deterministic). In real
# use you'd either pass a `"json_schema"` arg or route :query_fun through
# `ClaudeWrapper.Structured.run/3`; the {:ok, parsed, result} it returns adapts
# to this seam as `{:ok, _parsed, result} -> {:ok, result}`.
#
# mix run examples/propose_dispose.exs
import Ecto.Query, only: [from: 2]
# --- Boot (same throwaway-SQLite setup as examples/playground.exs) ----------
db_path = Path.join(System.tmp_dir!(), "oban_claude_propose_dispose.db")
for suffix <- ["", "-shm", "-wal"], do: File.rm(db_path <> suffix)
defmodule PdRepo do
use Ecto.Repo, otp_app: :oban_claude, adapter: Ecto.Adapters.SQLite3
end
Application.put_env(:oban_claude, PdRepo,
database: db_path,
pool_size: 1,
busy_timeout: 5_000,
log: false
)
defmodule PdMigration do
use Ecto.Migration
def up, do: Oban.Migrations.up()
def down, do: Oban.Migrations.down()
end
# --- The sink: a plain Oban worker standing in for oban_github --------------
defmodule SinkJob do
use Oban.Worker, queue: :sink, max_attempts: 3
@impl Oban.Worker
def perform(%Oban.Job{args: %{"action" => action, "issue" => issue}}) do
IO.puts(" [sink] issue ##{issue}: would #{action} (stand-in for oban_github)")
:ok
end
end
# --- The claude worker: propose an outcome, dispose by enqueuing a sink job --
defmodule ClaudeJob do
use ObanClaude.Worker, queue: :claude, max_attempts: 3, query_fun: &__MODULE__.fake_query/2
alias ClaudeWrapper.Result
# The dispose step. Branch on the typed outcome; enqueue the matching sink
# action, or cancel when the agent reported it is blocked.
@impl ObanClaude.Worker
def handle_result(result, %Oban.Job{args: %{"issue" => issue}}) do
case ObanClaude.structured(result) do
%{"outcome" => "done"} ->
enqueue_sink("open a PR", issue)
:ok
%{"outcome" => "needs_review"} ->
enqueue_sink("comment for human review", issue)
:ok
%{"outcome" => "blocked"} ->
{:cancel, :blocked}
_ ->
:ok
end
end
defp enqueue_sink(action, issue) do
{:ok, _job} = %{"action" => action, "issue" => issue} |> SinkJob.new() |> Oban.insert()
end
# Stubbed claude entrypoint: the prompt doubles as the scenario selector and
# becomes the schema-validated `outcome`. (Real use: a JSON schema makes
# claude return this object; the stub just fabricates it.)
def fake_query(prompt, _opts) do
{:ok,
%Result{result: "", is_error: false, extra: %{"structured_output" => %{"outcome" => prompt}}}}
end
end
# --- Start everything -------------------------------------------------------
{:ok, _} = PdRepo.start_link()
Ecto.Migrator.up(PdRepo, 1, PdMigration, log: false)
{:ok, _} =
Oban.start_link(
repo: PdRepo,
engine: Oban.Engines.Lite,
notifier: Oban.Notifiers.PG,
peer: Oban.Peers.Isolated,
plugins: [],
queues: [claude: 2, sink: 5]
)
# --- Enqueue claude jobs for three issues with different outcomes -----------
proposals = [
{101, "done"},
{102, "needs_review"},
{103, "blocked"}
]
IO.puts("\nEnqueuing #{length(proposals)} claude jobs (one per issue)...\n")
for {issue, outcome} <- proposals do
{:ok, _} = %{"prompt" => outcome, "issue" => issue} |> ClaudeJob.new() |> Oban.insert()
end
# --- Watch both queues settle (claude jobs spawn sink jobs as they finish) --
terminal = ~w(completed cancelled discarded)
snapshot = fn ->
PdRepo.all(from(j in "oban_jobs", select: {j.id, j.worker, j.state, j.queue}, order_by: j.id))
end
IO.puts("Watching both queues:\n")
Enum.reduce_while(1..25, nil, fn i, _ ->
Process.sleep(400)
rows = snapshot.()
line =
rows
|> Enum.map(fn {id, worker, state, _q} -> "##{id}:#{worker}=#{state}" end)
|> Enum.join(" ")
IO.puts(" t=#{Float.round(i * 0.4, 1)}s #{line}")
# Settled when every job is terminal and at least one sink job has appeared
# (so we don't stop before the claude jobs have had a chance to dispose).
all_terminal? = Enum.all?(rows, fn {_, _, state, _} -> state in terminal end)
has_sink? = Enum.any?(rows, fn {_, w, _, _} -> w =~ "SinkJob" end)
if all_terminal? and has_sink?, do: {:halt, rows}, else: {:cont, rows}
end)
IO.puts("\nFinal states:")
IO.puts(" " <> String.duplicate("-", 64))
for {id, worker, state, queue} <- snapshot.() do
IO.puts(" ##{id} #{String.pad_trailing(queue, 7)} #{String.pad_trailing(worker, 9)} #{state}")
end
IO.puts("""
Issue 101 (done) -> claude completed -> sink enqueued: open a PR
Issue 102 (needs_review) -> claude completed -> sink enqueued: comment
Issue 103 (blocked) -> claude cancelled -> no sink (nothing to dispose)
""")