ObanClaude.Outcome (oban_claude v0.2.0)

Copy Markdown View Source

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 outcomeObanwhy
%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

classify(arg)

@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}.