Guides
Reliability & checkpoints
Most agent projects work once and then break under real load. This page documents exactly what Ballast does about that: what a checkpoint contains, when it is written, and the precise recovery behavior for every way a run can fail.
What a checkpoint is
A checkpoint is a database row the engine writes at specific moments. It holds three fields that together are enough to reconstruct a run mid-flight:
| Field | Type | Default | Description |
|---|---|---|---|
| state | object | — | The run's entire state dictionary — the run input, every node's output_key value, approval records, and internal loop counters. |
| completed | string[] | — | The ids of every node that has finished. Completed nodes never re-execute on resume. |
| queue | string[] | — | The exact continuation — which nodes run next, including any merge nodes still waiting on branches and any gate at the head. |
{
"state": {
"input": { "ticket": "Charged twice" },
"ticket": "Charged twice",
"category": "billing",
"draft_reply": "[mock:mock] ..."
},
"completed": ["classify", "draft"],
"queue": ["approve"]
}
Checkpoints are written:
- after every node completes — including each node of a parallel wave, individually (step name
after:<node_id>) - when a run pauses at a human gate (
gate:<node_id>) - when a node fails and the run stops, with the failing node at the head of the queue (
failed:<node_id>) - when a gate is approved — the decision itself is checkpointed (
approved:<node_id>)
The practical consequence: the blast radius of any crash is exactly one node. Everything up to the last checkpoint is durable.
What survives a crash and what re-runs
Resume always restores the last checkpoint and continues from its queue. That gives a simple, predictable contract:
| Field | Type | Default | Description |
|---|---|---|---|
| completed nodes | durable | never re-run | Their output_key values are in the checkpointed state and are reused as-is. |
| state up to last checkpoint | durable | restored | The exact state dict from the last node completion (or gate/failure) is loaded back. |
| the in-flight node | re-runs | from scratch | A node interrupted mid-execution was not yet checkpointed, so its partial work is discarded and it runs again on resume. |
| nodes after the failure | re-runs | as normal | They had not started; they run in order once the resumed node and its successors proceed. |
Because agent and tool nodes commit their result to state only when they finish, an interrupted node leaves no half-written value — resume sees the clean state from before it started.
Failure mode 1: a node fails
A tool raised, an LLM call errored after its retries, or an expression referenced a missing variable. The step is marked failed with the error text; the run is marked failed with Node 'label' failed: reason; and a checkpoint is written with the failing node at the head of the queue. Resume re-runs just that node against the preserved state:
# curl — 400 if the run is not failed, or has no checkpoint
$ curl -s -X POST http://localhost:8000/api/runs/af772b3602ef/resume
# Python SDK
client.resume("af772b3602ef")
This is the right recovery for transient causes — a flaky API, a rate limit, a provider blip. Fix nothing, just resume; or fix the tool/config first and then resume. Resume is for failed runs only — a paused run is not failed, so resume returns 400 pointing you to /approve instead.
Failure mode 2: the process dies mid-run
On startup the backend sweeps every run stranded in running or pending and marks it failed with Interrupted by backend restart — resume from last checkpoint. Their last checkpoint is intact, so POST /resume picks up from the last completed node. Paused runs are deliberately left alone — they were already durable and simply resume waiting.
A run with no checkpoint cannot resume
Resume requires at least one checkpoint. A run interrupted before its first node completed (it was stillpending) has none — resume returns No checkpoint available for this run. Re-trigger it instead; nothing observable had happened yet. Idempotency keys (below) make that re-trigger safe.Failure mode 3: a node hangs — timeouts
Every execution attempt runs under a hard wall-clock timeout. A node that exceeds timeout_seconds is treated as a failed attempt — no agent or tool can wedge a workflow open forever. The timeout applies per attempt, so it composes with retries below.
Retries and exponential backoff
Both knobs live on every node type, alongside its type-specific config:
| Field | Type | Default | Description |
|---|---|---|---|
| timeout_seconds | number | 120 | Hard wall-clock limit for one execution attempt of this node. |
| retry_count | int | 1 | Re-attempts after the initial failure. Total attempts = 1 + retry_count. 0 disables retries. |
After a failed attempt the engine sleeps min(2^(n-1), 30) seconds before retry n, so the backoff sequence is 1s, 2s, 4s, 8s, 16s, 30s… (capped at 30s). With the default retry_count: 1 a node that keeps failing runs twice, one second apart, then surfaces the error.
| Field | Type | Default | Description |
|---|---|---|---|
| retry_count: 0 | 1 attempt | no sleep | Fail immediately on the first error. |
| retry_count: 1 | 2 attempts | 1s | Default. One retry, 1s after the first failure. |
| retry_count: 3 | 4 attempts | 1s, 2s, 4s | Three retries with growing backoff between them. |
{
"id": "enrich",
"type": "tool",
"config": {
"label": "Enrich Lead",
"tool_name": "http_request",
"params": { "method": "GET", "url": "https://api.example.com/enrich?email={email}" },
"timeout_seconds": 30,
"retry_count": 3
}
}
A retry re-runs the whole node
A retry repeats the full attempt — for an agent node that means another LLM call (and its cost); for a tool node it re-invokes the side effect. Setretry_count: 0on nodes whose side effects must not repeat, and rely on resume-after-fix instead. For registered custom tools the node inherits the tool's own timeout_seconds and retry_count unless the node overrides them.Bounded loops
A loop node re-arms its body while an expression holds, but carries a hard max_iterations cap (default 5) that overrides the expression. The iteration counter lives in state under __loop_<node_id>and is checkpointed like everything else, so a loop's progress survives a crash too. A loop whose condition never turns false still exits at the cap — a runaway agent loop is a config error, not an outage.
Cancellation
POST /api/runs/{id}/cancel stops a running run mid-node — the in-flight node is abandoned and its work left uncommitted — or settles a paused one directly. Either way the run reads failed / Cancelled by user and keeps its checkpoints, so a run cancelled by mistake is still resumable. Cancelling an already-finished run returns 400.
$ curl -s -X POST http://localhost:8000/api/runs/af772b3602ef/cancel
# Python
client.cancel("af772b3602ef")
Idempotent triggers
Any trigger can carry an Idempotency-Key header. The first call with a given key starts a run and records the key; a repeat of the same key returns the original run instead of starting a duplicate. This is what makes retrying a trigger — from a webhook, a queue, or a crashed client — safe.
# curl — repeat this exact call and you get run af77... back, not a new run
$ curl -s -X POST http://localhost:8000/api/workflows/9bc62f209c52/runs \
-H 'content-type: application/json' \
-H 'Idempotency-Key: ticket-8841-triage' \
-d '{"input": {"ticket": "..."}}'
# Python
client.run("Support triage", {"ticket": "..."}, idempotency_key="ticket-8841-triage")
Verify it yourself — the two-minute durability test
The single most convincing check: pause a run, kill the backend outright, restart it, and finish the run. It works because the pause checkpoint is already in the database.
# 1. run a gated workflow so it pauses
$ curl -s -X POST http://localhost:8000/api/workflows/9bc62f209c52/runs \
-H 'content-type: application/json' -d '{"input": {"ticket": "test"}}'
# -> { "id": "af772b3602ef", "status": "pending" } # becomes paused within a moment
# 2. kill the backend, hard
$ pkill -9 -f "uvicorn main:app"
# 3. start it again — startup sweeps interrupted runs; the paused one is untouched
$ uvicorn main:app --port 8000 &
# 4. the run is still paused; approve it
$ curl -s -X POST http://localhost:8000/api/runs/af772b3602ef/approve \
-H 'content-type: application/json' -d '{"approved": true}'
# -> status: success