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

examples/triage_issues.exs

# examples/triage_issues.exs
#
# Dogfood: an `ObanClaude.Worker` triages real GitHub issues. Each open issue
# becomes a claude job on the `:triage` queue; the worker runs claude with a JSON
# schema and gets back a typed `{label, priority, summary}`.
#
# It is READ-ONLY. The worker prints the verdict and stops. It never writes back
# to GitHub: writing labels or comments is a *sink's* job, and the sink
# (`oban_github`) lives elsewhere. `oban_claude` returns the verdict; consuming
# it is someone else's concern. That boundary is the whole point of the package,
# and this example stays on the right side of it.
#
# mix run examples/triage_issues.exs # offline: deterministic stub, no paid calls
# mix run examples/triage_issues.exs --live # real, paid claude calls (haiku)
#
# Issues are fetched with `gh` (a read) in both modes; `--live` only controls
# whether claude is real or a keyword stub. If `gh` is unavailable, a small baked
# issue list is used so the example always runs.
live? = "--live" in System.argv()
Application.put_env(:oban_claude_triage, :live, live?)
import Ecto.Query, only: [from: 2]
# --- Fetch open issues (real via gh, baked fallback) ------------------------
repo = "genagent/oban_claude"
baked = [
%{
"number" => 901,
"title" => "Worker drops job args when default args map is large",
"body" =>
"Under load some jobs run with only the worker defaults; the per-job args go missing. Looks like a merge bug."
},
%{
"number" => 902,
"title" => "Document the :classifier override in the README",
"body" =>
"The README mentions the classifier but never shows how to pass a custom one to use ObanClaude.Worker."
},
%{
"number" => 903,
"title" => "Add a test for the unknown permission_mode raise",
"body" =>
"coerce/2 raises ArgumentError on an unknown permission_mode; there should be a test pinning the message."
}
]
# `gh` absent on this host (e.g. CI): System.cmd/3 would raise :enoent, not
# return, so guard with find_executable/1 first -- then a live host without gh
# falls back to the baked list instead of crashing, as the header promises.
issues =
if System.find_executable("gh") do
case System.cmd(
"gh",
~w(issue list --repo #{repo} --state open --json number,title,body --limit 6),
stderr_to_stdout: true
) do
{out, 0} ->
case Jason.decode(out) do
{:ok, [_ | _] = list} -> list
_ -> baked
end
_ ->
baked
end
else
baked
end
source = if issues == baked, do: "baked sample issues (gh unavailable)", else: "#{repo} (live gh)"
# --- The triage worker ------------------------------------------------------
# The schema (the typed verdict claude must return) is a module attribute so the
# `use ObanClaude.Worker, args: ...` default can reference it.
defmodule TriageJob do
@schema_json Jason.encode!(%{
"type" => "object",
"additionalProperties" => false,
"properties" => %{
"label" => %{"enum" => ~w(bug enhancement documentation test chore question)},
"priority" => %{"enum" => ~w(high medium low)},
"summary" => %{"type" => "string"}
},
"required" => ~w(label priority summary)
})
@system_prompt """
You triage GitHub issues. Given one issue, return a JSON object with:
- label: one of bug, enhancement, documentation, test, chore, question
- priority: one of high, medium, low
- summary: a one-line summary, at most 12 words
Base the verdict only on the issue text.
"""
use ObanClaude.Worker,
queue: :triage,
max_attempts: 1,
query_fun: &__MODULE__.query/2,
args: %{
"model" => "haiku",
"max_turns" => 3,
"system_prompt" => @system_prompt,
"json_schema" => @schema_json
}
alias ClaudeWrapper.Result
# The seam's :query_fun. Real claude under --live, a deterministic stub
# otherwise, so `mix run` is free and offline by default.
def query(prompt, opts) do
if Application.get_env(:oban_claude_triage, :live, false) do
ClaudeWrapper.query(prompt, opts)
else
stub(prompt)
end
end
# handle_result/2 only runs on a successful claude call. Wrap perform/1 (it is
# overridable) so a failed call surfaces its Oban verdict instead of the worker
# going silent. A real sink would route these to a dead-letter or alert path.
@impl Oban.Worker
def perform(%Oban.Job{args: %{"number" => number}} = job) do
case super(job) do
:ok ->
:ok
other ->
IO.puts(" ##{number} no verdict (claude call failed): #{inspect(other)}")
other
end
end
# Read-only dispose: print the typed verdict. No GitHub write -- that is a
# sink's job (oban_github), not oban_claude's.
@impl ObanClaude.Worker
def handle_result(result, %Oban.Job{args: %{"number" => number, "title" => title}}) do
case ObanClaude.structured(result) do
%{"label" => label, "priority" => priority, "summary" => summary} ->
tag = String.pad_trailing("[#{label}/#{priority}]", 22)
IO.puts(" ##{number} #{tag} #{summary}\n (#{title})")
_ ->
IO.puts(" ##{number} (no structured output)")
end
:ok
end
# Deterministic offline stand-in for a --json-schema run: claude would return
# this object; the stub fabricates it by keyword-matching the issue text.
defp stub(prompt) do
{label, priority} = classify(prompt)
summary =
prompt
|> String.split("\n", trim: true)
|> List.first("")
|> String.replace(~r/^Issue #\d+:\s*/, "")
|> String.slice(0, 60)
object = %{"label" => label, "priority" => priority, "summary" => summary}
{:ok, %Result{result: "", is_error: false, extra: %{"structured_output" => object}}}
end
defp classify(text) do
t = String.downcase(text)
cond do
t =~ "silently" or t =~ "crash" or t =~ "data loss" or t =~ "drops" ->
{"bug", "high"}
t =~ "fix:" or t =~ " bug" ->
{"bug", "medium"}
t =~ "test:" or t =~ " test " or t =~ "live test" ->
{"test", "medium"}
t =~ "release" or t =~ "chore:" or t =~ "automation" or t =~ "publish" ->
{"chore", "medium"}
t =~ "docs:" or t =~ "readme" or t =~ "document" ->
{"documentation", "low"}
true ->
{"enhancement", "low"}
end
end
end
# --- Boot a throwaway SQLite Oban (same harness as the other examples) -------
db_path = Path.join(System.tmp_dir!(), "oban_claude_triage.db")
for suffix <- ["", "-shm", "-wal"], do: File.rm(db_path <> suffix)
defmodule TriageRepo do
use Ecto.Repo, otp_app: :oban_claude, adapter: Ecto.Adapters.SQLite3
end
Application.put_env(:oban_claude, TriageRepo,
database: db_path,
pool_size: 1,
busy_timeout: 5_000,
log: false
)
defmodule TriageMigration do
use Ecto.Migration
def up, do: Oban.Migrations.up()
def down, do: Oban.Migrations.down()
end
{:ok, _} = TriageRepo.start_link()
Ecto.Migrator.up(TriageRepo, 1, TriageMigration, log: false)
{:ok, _} =
Oban.start_link(
repo: TriageRepo,
engine: Oban.Engines.Lite,
peer: Oban.Peers.Isolated,
plugins: [],
queues: [triage: 3]
)
# --- Enqueue one triage job per issue ---------------------------------------
mode = if live?, do: "LIVE (real haiku calls)", else: "offline (deterministic stub)"
IO.puts("\nTriaging #{length(issues)} issues from #{source}")
IO.puts("Mode: #{mode}. Read-only: prints verdicts, writes nothing back.\n")
for %{"number" => number, "title" => title} = issue <- issues do
body = Map.get(issue, "body", "") || ""
args = %{
"prompt" => "Issue ##{number}: #{title}\n\n#{body}",
"number" => number,
"title" => title
}
{:ok, _} = args |> TriageJob.new() |> Oban.insert()
end
# --- Wait for the queue to settle -------------------------------------------
# Real claude calls take seconds each; the stub is instant. Give live mode a
# real deadline so the script does not exit before the jobs reach
# handle_result/2. Note that exiting does NOT kill in-flight CLI processes under
# the default runner -- it orphans them (they keep running); use the Forcola
# runner for a fleet. See the "Process lifecycle" fleet-safety guide section.
terminal = ~w(completed cancelled discarded)
total = length(issues)
deadline_ms = if live?, do: 180_000, else: 10_000
poll_ms = 500
count_done = fn ->
TriageRepo.all(from(j in "oban_jobs", select: j.state))
|> Enum.count(&(&1 in terminal))
end
settled =
Enum.reduce_while(1..div(deadline_ms, poll_ms), 0, fn i, _ ->
Process.sleep(poll_ms)
done = count_done.()
if live? and rem(i, 10) == 0 and done < total, do: IO.puts(" ...waiting (#{done}/#{total})")
if done >= total, do: {:halt, done}, else: {:cont, done}
end)
if settled < total do
IO.puts("\n#{settled}/#{total} jobs settled before the #{div(deadline_ms, 1000)}s deadline.")
end
IO.puts("\nDone. Verdicts above are advisory only; nothing was written to GitHub.")