Skip to content
Orbital

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.

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.

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

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.

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.

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.

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.

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.

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.

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.

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.
  • Add a loop: recovery and exhaustion in a complete workflow.
  • Troubleshooting: how each state appears in the app.
  • Runs: what each run standing means.
  • Workflow file: the route keys that handle failures and spend budgets.