> For the index of every page in Orbital's docs, read https://docs.runorbital.dev/llms.txt.

# Error handling

> Route failed steps, bound repair loops, and see what happens when nothing matches

Route a failed step to a repair prompt with a route that tests `outcome`, and decide how each way of ending the run is reported.

```toml title="step-failure.toml"
version = 3
entry = "steps.implement"

[steps.implement]
prompt = "Make the change the goal describes."
routes = [{ if = "outcome == 'failed'", to = "steps.recover" }]
next = "steps.done"

[steps.recover]
prompt = "The last step failed: {{ context.failure_reason }}. Fix the cause, then finish the change."
next = "steps.done"

[steps.done]
kind = "terminal"
```

If `implement` fails, the run goes to `recover`, whose prompt says why. If it succeeds, the failure route does not hold and `next` takes it to `done`.

Orbital has two separate failure vocabularies, and mixing them up is the most common surprise. A step outcome describes one step. A terminal outcome describes the whole run.

## Step failure

Orbital writes `outcome` as `succeeded` or `failed` after each step, with `failure_reason` empty on success. A harness error, output that stays malformed after correction, or a failed script fails a step.

A failed step takes the first of its `routes` whose `if` holds. It never takes `next`. With no matching route, the run is Paused and its badge reads "Waiting on you". The run has not ended: fix the cause and resume it. See [Runs](/runs/). Add `{ if = "outcome == 'failed'", to = "steps.repair" }` to a step's `routes` to send work to a repair prompt, and read `{{ context.failure_reason }}` in that prompt. A step that succeeds never takes that route, because its condition does not hold.

A terminal's run outcome uses `success`, `failed` or `aborted`, a different vocabulary from the step outcome. A failed terminal ends the run Failed rather than pausing it.

## What fails a step

A command fails on a timeout, a non-zero exit or a malformed final JSON line, and sets `<id>.failed` to true either way. Malformed agent output triggers up to two correction turns in the same session; if correction fails, the step fails. Context changes only after acceptance, so a rejected output leaves nothing behind.

Failed probe attempts retry inside the same step and record retry progress. They spend no new step visits and take no failure route per attempt. A probe that keeps retrying is still inside its step, so inspect its diagnostic and credentials rather than the workflow's routes. A `github.pr` probe that finds a repository no retry can fix does not retry: when the worktree folder is missing, it has no origin remote, or its origin is not a usable GitHub repository, the run pauses on the probe step with a reason that names the folder and the problem. Restore the folder or its origin, then resume the run to measure again.

A `worktree.remove` action that would lose dirty files or unpushed work removes nothing and writes `git.worktree.clean` false with the detail in `git.worktree.status`. Route on `git.worktree.clean` and give the refusal its own terminal.

```toml title="worktree-removal.toml"
version = 3
entry = "steps.checkout"

[steps.checkout]
kind = "action"
do = "worktree.create"
next = "steps.implement"

[steps.implement]
prompt = "Make the change the goal describes, commit it and push it."
next = "steps.cleanup"

[steps.cleanup]
kind = "action"
do = "worktree.remove"
routes = [{ if = "git.worktree.clean", to = "steps.done" }]
next = "steps.kept"

[steps.done]
kind = "terminal"

[steps.kept]
kind = "terminal"
outcome = "failed"
label = "The worktree still holds work"
```

If `cleanup` finds unpushed work, it removes nothing and the run ends at `kept`, so the work stays where you can find it.

## Bounded repair

A budget stops a repair loop after a set number of rounds. Declare it once as `[budgets.<name>]`, with `rounds` defaulting to 5, and name it from routes as `"budgets.<name>"`. See [Budgets](/reference/workflow-file/#budgets).

A route with `spends` uses one round each time it is taken. When the budget is empty, a route that spends it is skipped, so the run takes the next route whose `if` holds. Put a route with the same condition after the spending one to catch the run when the rounds run out. A route with `resets`, or `next` written as a table with `resets`, fills the budget again; place it on demonstrated progress, such as a passing review, or where a new piece of work starts. Ordinary waiting spends no rounds.

```toml title="bounded-retries.toml"
version = 3
entry = "steps.review"
max_visits = 30

[budgets.answer]
rounds = 5

[steps.review]
prompt = "Review the goal and any previous repair. Choose pass or reject."
outputs.verdict = { kind = "choice", options = ["pass", "reject"] }
routes = [
  { if = "verdict == 'pass'",   to = "steps.done",      resets = "budgets.answer" },
  { if = "verdict == 'reject'", to = "steps.repair",    spends = "budgets.answer" },
  { if = "verdict == 'reject'", to = "steps.exhausted" },
]

[steps.repair]
prompt = "Revise the proposed answer to the goal."
next = "steps.review"

[steps.done]
kind = "terminal"

[steps.exhausted]
kind = "terminal"
outcome = "failed"
label = "Five repairs did not pass review"
```

Each reject sends the answer to `repair` and spends one round of the `answer` budget. After five repairs the budget is empty, so the sixth reject skips the spending route, takes the next one to `exhausted`, and the run ends Failed. A pass takes the resetting route, which fills the budget again.

Budgets are shared by name across the whole workflow, imports included, and survive restart, resume and loop restart. Visit limits are separate from budgets. When the workflow's `max_visits`, 200 agent turns by default, is spent, the run ends Failed. When a step reaches its own `max_visits`, that visit fails and the run is Paused with "Waiting on you", whatever failure routes the step has. A budget counts rounds of one piece of work, refills on progress and runs out into a route you wrote; `max_visits` counts every visit and stops the run.

## Partial parallel failure

Successful collection of every branch enters the join. A partial failure takes a matching route from the fan-out's `routes`. With none, the run is Paused and names the failed branches.

```toml title="partial-parallel-failure.toml"
version = 3
entry = "steps.reviews"

[steps.reviews]
kind = "fan_out"
max_parallel = 2
join = "steps.collect"
routes = [{ if = "outcome == 'failed'", to = "steps.rescue" }]

[steps.reviews.clarity]
prompt = "Review the goal for clarity."
next = "steps.collect"

[steps.reviews.risks]
prompt = "Review the goal for risks."
next = "steps.collect"

[steps.collect]
kind = "fan_in"
next = "steps.done"

[steps.rescue]
prompt = "A review failed: {{ context.failure_reason }}. Review the goal yourself."
next = "steps.done"

[steps.done]
kind = "terminal"
```

If either review fails, the run skips `collect` and goes to `rescue`, whose prompt names the failed branch.

## Quick reference

| What happens | Result |
| --- | --- |
| A step fails and has a route with `if = "outcome == 'failed'"` | The run follows that route. |
| A step fails with no matching route | The run is Paused with "Waiting on you". |
| The run reaches a terminal with `outcome = "failed"` | The run ends Failed. |
| A route's budget is empty | The route is skipped and the next route that holds is taken. |
| The workflow's `max_visits` is spent | The run ends Failed. |
| A step reaches its own `max_visits` | That visit fails, and the run is Paused. |
| A parallel branch fails with no matching route on the fan-out | The run is Paused and names the failed branches. |

## Related

- [Add a loop](/workflows/add-a-loop/): recovery and exhaustion in a complete workflow.
- [Troubleshooting](/troubleshooting/): how each state appears in the app.
- [Runs](/runs/): what each run standing means.
- [Workflow file](/reference/workflow-file/#routes): the route keys that handle failures and spend budgets.
