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.
version = 3entry = "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
Section titled “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. 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
Section titled “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.
version = 3entry = "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
Section titled “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.
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.
version = 3entry = "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
Section titled “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.
version = 3entry = "steps.reviews"
[steps.reviews]kind = "fan_out"max_parallel = 2join = "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
Section titled “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
Section titled “Related”- 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.