Skip to main content

Task Graphs and Branching

Bosun tasks are defined as graphs. Every workflow declares its steps, the first node to start from, and the transitions between them. Bosun treats any reachable step with no outgoing edges as a terminal node, so you can branch, merge, or fan in without extra wiring when the work is finished.

Graph Manifest Structure

Every workflow manifest wires its graph with two fields alongside your steps list:

  • starts_with: the id of the first step Bosun should execute.
  • edges: an array of directed connections between step IDs. Each non-terminal node should have the transitions Bosun needs to continue the run. Each edge optionally declares an on condition.

Bosun automatically infers terminal steps by looking for reachable nodes without outgoing edges. Graphs can end on one or many nodes—once a run reaches a terminal step, the workflow exits.

Every step needs a stable id. Bosun uses these IDs in edges, templates, and run output references.

Name steps before wiring edges

Assign the id field as soon as you draft the step so your edges and templates stay readable.

name: update-dependencies
description: Run tests, try an autofix, and only open a PR if everything passes.

steps:
- id: run_tests
name: Run test suite
agent:
extends: Coding
instructions: "Execute npm test and summarise the result."

- id: try_fix
name: Attempt automatic fix
agent:
extends: Coding
instructions: "Apply straightforward fixes for the failing tests."

- id: open_pr
name: Prepare pull request
agent:
extends: pull_request

starts_with: run_tests

edges:
- from: run_tests
to: try_fix
on: failure
- from: run_tests
to: open_pr
on: success
- from: try_fix
to: run_tests
on: success

Edge Conditions

Transitions default to success when no on value is set. You can also choose:

  • failure: trigger when the source step reports a failure.
  • A custom expression: Bosun renders the string as a template in the current task context and treats any non-empty, non-false/0 value as truthy.

For example, you can branch on the contents of an earlier step output:

  - from: validate_report
to: notify_team
on: '{{ outputs.validate_report.result == "changes_required" }}'

Keep expressions short and render them to clear true/false strings. If an expression fails to render or resolves to an empty value, Bosun falls back to evaluating the next available edge.

Each step can declare at most one success edge and one failure edge. Custom conditions are evaluated before the success fallback in the order they appear in the manifest.

Graphs that include a feedback step behave the same way: the node pauses the run while Bosun waits for user input, then reports either success or failure when feedback is approved or refused. You can wire on: success and on: failure edges from the feedback step id to route approved runs to one branch and rejected runs to another. See the feedback step reference for configuration details.

Branching on structured failures

Failure edges become much more powerful when you pair them with Custom Schemas. Agents can return rich JSON through stop_schema/fail_schema, Bosun stores that data under errors.<step>[i].reason, and your graph edges decide where to go next based on those fields.

A Bosun regression-manifest fixture shows a simple version of this pattern: an agent required to call task_failed with { summary, blockers, blocking } fans out to a recovery step via an on: failure edge. You can take the same idea further by looking inside the payload.

Example: fan out work when an agent fails

steps:
- id: regression_auditor
name: Audit upgraded services
agent:
extends: Coding
instructions: |
Run the regression suite. If anything fails, stop immediately and call `task_failed` with the services that need manual intervention.
fail_schema:
type: object
required: [summary, retryable, owners]
properties:
summary:
type: string
retryable:
type: boolean
owners:
type: array
items:
type: object
required: [service, assignee]
properties:
service:
type: string
assignee:
type: string

- id: notify_success
run: echo "All services passed" > status.txt

- id: dispatch_fixers
for_each:
from: '{{ errors.regression_auditor[0].reason.owners | json_encode() }}'
parallel: true
agent:
extends: Coding
instructions: |
Address the regression in {{ for_each.value.service }} and hand it back to {{ for_each.value.assignee }}.

- id: summarize
agent:
extends: pull_request
instructions: "Open or update the PR once all owners report back."

starts_with: regression_auditor

edges:
- from: regression_auditor
to: notify_success
on: success
- from: regression_auditor
to: dispatch_fixers
on: failure
- from: dispatch_fixers
to: summarize

This graph naturally ends once either notify_success or summarize finishes because neither node has outgoing edges.

How it works:

  1. regression_auditor either succeeds and flows to notify_success, or fails with a structured payload.
  2. The failure edge routes control to dispatch_fixers, which turns the list of owners from errors.regression_auditor[0].reason into a parallel for_each run. Each iteration inherits the service/assignee details from the failure payload.
  3. When the fan-out loop finishes, the graph continues to summarize, where you can aggregate the fixes or open a PR.

Because the schema is enforced at runtime, you can rely on fields such as retryable or owners existing before you branch. If you only need to check a flag, add a conditional expression to the edge instead:

edges:
- from: regression_auditor
to: dispatch_fixers
on: '{{ errors.regression_auditor[0].reason.retryable == true }}'
- from: regression_auditor
to: escalate
on: failure
Legacy manifests with ends_with

Bosun still accepts manifests that include an ends_with field, but the value is treated as a legacy hint. Terminal steps are always inferred from the graph, so you can omit ends_with in new tasks and gradually remove it from older manifests.