The default mapping from claude_wrapper's typed result/error onto Oban's
return values. Override per-worker with use ObanClaude.Worker, classifier: &MyMod.classify/1.
The mapping encodes the right Oban semantics for each claude failure mode:
| claude outcome | Oban | why |
|---|---|---|
%Result{is_error: false} | {:ok, result} | success; the worker's handle_result/2 decides the final atom |
%Result{is_error: true} | {:error, :result_error} | claude emitted an error result with valid JSON; retry, bounded by max_attempts |
%Error{kind: :timeout} | {:error, :timeout} | transient; retry with backoff, bounded by max_attempts |
%Error{kind: :auth, reason: :rate_limit} | {:error, :rate_limit} | rate/quota limit is transient; retry (bounded) rather than cancel |
%Error{kind: :command_failed/:json/:io} | {:error, kind} | likely transient infra; retry with backoff |
missing/unrunnable binary (:io + :enoent/:eacces) | {:cancel, :binary_not_found} | the CLI is absent or not executable; re-fails identically |
| a config/env fault (see below) | {:cancel, kind} | the same broken environment re-fails identically; a retry only burns budget |
%Error{kind: :budget_exceeded/:max_budget_exceeded/:max_turns_exceeded} | {:cancel, kind} | the rails stopped it; resuming is a deliberate act, not a blind retry |
any other typed %Error{} | {:error, kind} | unknown but typed; retry under max_attempts, then dead-letter |
a non-%Error{} error term | {:cancel, ...} | off-contract and unclassifiable; do not blindly re-run |
The default mapping never snoozes
Oban implements {:snooze, n} by incrementing max_attempts (the engine's
snooze bumps the attempt ceiling), so a snooze never consumes an attempt. A job
that deterministically fails the same way -- a run that always exceeds its
:timeout, a permanently rate-limited key -- would therefore snooze
forever: unbounded paid re-runs, no dead-letter, no operator signal. So
every transient outcome above maps to {:error, _} instead, letting
max_attempts + backoff bound the retries. A worker that genuinely wants
snooze semantics (e.g. {:snooze, {1, :hour}} to ride out a rate-limit
window) opts in with a :classifier override -- accepting that it bypasses
max_attempts.
Two :timeout cautions for unattended fleets: claude_wrapper's default
runner does not kill the timed-out CLI process (it can keep running and
complete its side effects -- a commit, a push, a PR -- after Oban re-queues
the job), so configure config :claude_wrapper, runner: ClaudeWrapper.Runner.Forcola
for strict termination; and a re-queued timeout is a fresh, full-price run.
Config/environment faults
The faults that cancel are :auth (every reason except :rate_limit, which
retries -- see above), :version_mismatch, :invalid_version,
:dangerous_not_allowed, :invalid_tool_pattern, plus :binary_not_found,
:not_a_git_repo, and :git_unavailable. A missing or unauthenticated
binary, an unusable CLI version, a disallowed flag, or a malformed tool
pattern fails the same way on every attempt, so retrying cannot help.
The last three (:binary_not_found, :not_a_git_repo, :git_unavailable)
are retained for custom :query_fun paths but are unreachable via the
default ClaudeWrapper.query/2: a missing binary surfaces as :io with an
:enoent/:eacces reason (cancelled as :binary_not_found by the dedicated
clause), and a non-git working_dir under :worktree surfaces as
:command_failed (retried). Do not build monitoring on those three kinds
appearing on the default path. (The :io/:enoent cancel only fires on the
no-:timeout path; with a :timeout arg the spawn raises inside the run
Task, which Oban records as a crash and retries.)
The rail-stop rows -- :budget_exceeded (a client-side ceiling),
:max_budget_exceeded (claude's own --max-budget-usd cap), and
:max_turns_exceeded -- are the genuinely app-dependent ones. An app that
resumes the run rather than restarting it reads the session_id off the
rail-stop %Error{} in ObanClaude.Worker.handle_error/3 (via
ObanClaude.session_id/1) and enqueues a follow-on job with resume: + the
same named worktree -- the "session-resume handoff" pattern in the Agent worker
patterns guide. The classifier override is for a verdict change (e.g. a
bounded snooze); the resume effect belongs in handle_error/3.
An {:error, term} that is not a typed %Error{} (off the documented
contract, e.g. a custom :query_fun routing through
ClaudeWrapper.Structured.run/3, whose parse/1 and Jason paths return
non-Error terms) is cancelled rather than retried: an unclassifiable
failure should not be blindly re-run.
Summary
Functions
Map ClaudeWrapper.query/2's return onto {oban_return, term}.
Functions
@spec classify({:ok, ClaudeWrapper.Result.t()} | {:error, term()}) :: {ObanClaude.oban_return(), ClaudeWrapper.Result.t() | ClaudeWrapper.Error.t() | term()}
Map ClaudeWrapper.query/2's return onto {oban_return, term}.