# `ObanClaude.Outcome`
[🔗](https://github.com/genagent/oban_claude/blob/v0.10.2/lib/oban_claude/outcome.ex#L1)

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}` without consuming an attempt: Oban 2.23
increments `max_attempts`, while 2.24 rolls back `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 `c: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.

# `classify`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
