ObanClaude (oban_claude v0.3.0)

Copy Markdown View Source

Run a Claude Code job (via claude_wrapper) on an Oban queue.

oban_claude is the thin seam between Oban and claude:

It owns no state and runs no daemon, and takes no position on what consumes a result. Whether the agent effects its own writes (full-auto) or returns structured data for a downstream effector is encoded in the job's args (prompt + permission mode), never here, so oban_claude supports both.

Example

Build the job's args with ObanClaude.Args.new/1 (atom keys, validated) rather than a hand-written string map:

defmodule MyApp.ClaudeJob do
  use ObanClaude.Worker, queue: :claude, max_attempts: 3
end

ObanClaude.Args.new(prompt: "summarize the repo", working_dir: "/path/to/repo")
|> MyApp.ClaudeJob.new()
|> Oban.insert()

The raw string-keyed map is still accepted as the low-level escape hatch:

%{"prompt" => "summarize the repo", "working_dir" => "/path/to/repo"}
|> MyApp.ClaudeJob.new()
|> Oban.insert()

Telemetry

run/2 emits the following events via :telemetry:

[:oban_claude, :run, :stop]

Emitted after a successful ClaudeWrapper.query/2 call. This includes is_error: true results, which are returned without raising.

  • Measurements:
    • :duration -- wall time of the query in native time units (convert with System.convert_time_unit/3)
    • :cost_usd -- the reported cost in USD from the result (0.0 when the result carries no cost)
  • Metadata:
    • :result -- the %ClaudeWrapper.Result{} struct
    • :args -- the string-keyed args map passed to run/2
    • :job -- a slim map %{id, queue, worker, attempt, max_attempts, meta} for the Oban.Job, or nil when run/2 was called without :job

[:oban_claude, :run, :exception]

Emitted when the query returns {:error, _} -- both a typed %ClaudeWrapper.Error{} and an off-contract error term (which the classifier cancels, so it still surfaces here).

  • Measurements:
    • :duration -- wall time of the query in native time units
    • :cost_usd -- the run's spend when the error carries one (a rail-stop :max_budget_exceeded / :max_turns_exceeded reason map does; 0.0 otherwise, including every off-contract term). Summing :cost_usd across both events gives a complete spend total.
  • Metadata:
    • :error -- the %ClaudeWrapper.Error{} struct, or the raw error term on the off-contract path
    • :args -- the string-keyed args map passed to run/2
    • :job -- as above

Data handling

:args (prompt, system prompt, :meta), :result, and :error (which can embed raw CLI stdout/stderr) all ride on these events. Redact before shipping telemetry to a log aggregator.

Summary

Types

Maps a wrapper_outcome/0 onto the {oban_return, payload} envelope. The first element MUST be a valid oban_return/0; run/2 raises otherwise. A common mistake is a flat {:cancel, reason} -- that is a bare verdict, not the {verdict, payload} envelope the ObanClaude.Worker path unwraps.

An Oban.Worker.perform/1 return value. :snooze accepts Oban's full Oban.Period.t/0 -- a pos_integer of seconds or a {n, unit} tuple. The default classifier (ObanClaude.Outcome) never returns :snooze (it would bypass max_attempts); a :classifier override may.

The claude entrypoint: (prompt, query_opts) -> t:wrapper_outcome/0.

What ClaudeWrapper.query/2 (or a custom :query_fun) returns: a typed %Result{}, or a typed %Error{} (an arbitrary term() only off the documented contract).

Functions

Read the run's cost in USD from a run's payload, or nil if unavailable.

Read the "outcome" string from a result's structured_output, when a --json-schema run produced one. Returns nil otherwise.

Run a claude job from a string-keyed args map.

Read the claude session id from a run's payload, or nil if there is none.

Return the full schema-validated structured_output from a result (a --json-schema run), or nil when the run produced none.

Types

classifier()

@type classifier() :: (wrapper_outcome() -> {oban_return(), term()})

Maps a wrapper_outcome/0 onto the {oban_return, payload} envelope. The first element MUST be a valid oban_return/0; run/2 raises otherwise. A common mistake is a flat {:cancel, reason} -- that is a bare verdict, not the {verdict, payload} envelope the ObanClaude.Worker path unwraps.

oban_return()

@type oban_return() ::
  :ok
  | {:ok, term()}
  | {:error, term()}
  | {:cancel, term()}
  | {:snooze, Oban.Period.t()}

An Oban.Worker.perform/1 return value. :snooze accepts Oban's full Oban.Period.t/0 -- a pos_integer of seconds or a {n, unit} tuple. The default classifier (ObanClaude.Outcome) never returns :snooze (it would bypass max_attempts); a :classifier override may.

query_fun()

@type query_fun() :: (String.t(), keyword() -> wrapper_outcome())

The claude entrypoint: (prompt, query_opts) -> t:wrapper_outcome/0.

wrapper_outcome()

@type wrapper_outcome() ::
  {:ok, ClaudeWrapper.Result.t()} | {:error, ClaudeWrapper.Error.t() | term()}

What ClaudeWrapper.query/2 (or a custom :query_fun) returns: a typed %Result{}, or a typed %Error{} (an arbitrary term() only off the documented contract).

Functions

cost_usd(arg1)

@spec cost_usd(ClaudeWrapper.Result.t() | ClaudeWrapper.Error.t() | term()) ::
  float() | nil

Read the run's cost in USD from a run's payload, or nil if unavailable.

Like session_id/1, it hides the success-vs-rail-stop shape split: result.cost_usd on a %Result{}, error.reason.cost_usd on a rail-stop %Error{}. Use it in ObanClaude.Worker.handle_error/3 to record spend before enqueuing a resume job.

outcome(result)

@spec outcome(ClaudeWrapper.Result.t()) :: String.t() | nil

Read the "outcome" string from a result's structured_output, when a --json-schema run produced one. Returns nil otherwise.

Useful inside ObanClaude.Worker.handle_result/2 to branch on a typed outcome (e.g. "done" vs "blocked").

run(args, opts \\ [])

@spec run(ObanClaude.Args.t(),
  classifier: classifier(),
  query_fun: query_fun(),
  job: Oban.Job.t()
) ::
  {oban_return(), ClaudeWrapper.Result.t() | ClaudeWrapper.Error.t() | term()}

Run a claude job from a string-keyed args map.

Returns {oban_return, %Result{} | %Error{}} so a caller can both act on the Oban verdict and inspect the underlying run (cost, session id, structured output). :prompt is required; a curated subset of ClaudeWrapper.query/2 options (the keys in the ObanClaude.Args options table) is forwarded, and any other key in the map is silently ignored.

Options:

  • :classifier -- a classifier/0: a 1-arity fun mapping the claude call's outcome onto the {oban_return, payload} envelope. The first element is the Oban verdict; run/2 raises ArgumentError if it is not a valid oban_return/0 (guarding the flat-{:cancel, reason} mistake that Oban would otherwise silently treat as success). Defaults to &ObanClaude.Outcome.classify/1.
  • :query_fun -- a query_fun/0, the claude entrypoint. Defaults to &ClaudeWrapper.query/2. Override to stub claude in tests, or to route through a different wrapper entrypoint.
  • :job -- the Oban.Job this run belongs to. Its identity (id, queue, worker, attempt, meta) rides along in both telemetry events' :job metadata for cost attribution. ObanClaude.Worker passes it automatically; bare run/2 callers may omit it (:job is then nil).

session_id(arg1)

@spec session_id(ClaudeWrapper.Result.t() | ClaudeWrapper.Error.t() | term()) ::
  String.t() | nil

Read the claude session id from a run's payload, or nil if there is none.

The handle lives in two different places depending on the outcome, and this reader hides that split so callers never touch claude_wrapper internals:

  • on a success (or is_error %Result{}), it is result.session_id;
  • on a rail-stop %Error{} (:max_turns_exceeded / :max_budget_exceeded), it is error.reason.session_id.

Use it inside ObanClaude.Worker.handle_error/3 to enqueue a resume: job after a rail stop. Any other error shape (an atom reason, a missing field) yields nil.

structured(result)

@spec structured(ClaudeWrapper.Result.t()) :: map() | list() | nil

Return the full schema-validated structured_output from a result (a --json-schema run), or nil when the run produced none.

Use inside ObanClaude.Worker.handle_result/2 to branch on a typed result object. outcome/1 is the convenience for the common "outcome" key.