Skip to main content
With durable=True, a background=true submission is written to the agno_jobs table before the 202 is returned. That job row is the acceptance. The run itself is stored in your sessions database as usual (the run row). A worker on every replica polls the jobs table, claims jobs, and executes them under a lease that it refreshes with heartbeats. The client contract is unchanged: 202 with run_id, then poll or stream.

The guarantee

Every accepted run reaches a terminal, visible outcome. Poll it and you will eventually see COMPLETED, ERROR, or CANCELLED. A run never stays RUNNING indefinitely and never disappears. This is not the same as a promise that every run executes. What happens to a run whose worker dies mid-execution depends on max_attempts: At the default, a crashed run is marked failed and an operator grants one more attempt through requeue. The run’s content carries the reason:
Failures that retrying cannot cure (schema violations, guardrail refusals, a TypeError in the call) go straight to failed regardless of remaining budget.

How it stays correct

Each claim increments the job’s attempt counter. That number is recorded on every write the attempt makes: the job row, the run row, and the event stream. A write carrying an older attempt than the one currently recorded is refused. This is what makes max_attempts > 1 safe: a worker that was swept while still alive cannot corrupt the retry’s output.

Queue retries and model retries

Model retries and queue retries are independent layers: When model retries are exhausted, the run finishes with status ERROR. The worker then consults the queue budget: with attempts remaining it requeues the job after a jittered delay, otherwise the job is failed. At the defaults, a model outage fails the run on the first error with no re-execution at either layer.
Use model retries for transient provider errors. They are cheap and keep the run’s context. Use queue attempts for worker loss. A queue attempt repeats every tool call the previous attempt already made, so keep max_attempts=1 for runs with side effects. The two budgets multiply: at most max_attempts * (retries + 1) model calls per step.

Queue stores

The queue store defaults to the AgentOS db. A dedicated store isolates queue polling from your session data.
db= requires durable=True. Passing a queue store without durability raises at construction.
The queue store and the session store are separate concerns. Sessions and run rows on a non-Postgres store lose the attempt fencing described above, and the worker cannot update the run to RUNNING (queued runs poll PENDING while executing). Acceptance and terminal error persistence still work. A warning is logged at startup. Use Postgres for sessions in production.

Idempotency keys

Send an Idempotency-Key header to make a resubmission return the original run instead of enqueueing a second one:
Keys are scoped per user: the same key from two different user_id values is two runs. Anonymous submissions share one namespace. Asking is idempotent. Executing is not. A run that reached a tool with side effects has already acted, and a retry acts again. Agno does not keep a side-effect ledger. If a tool must not run twice, make the tool itself idempotent.

Session serialization

serialize_sessions=True (the default) allows at most one live run per session, executed in submission order. Two background=true submissions to the same session run one after the other instead of racing each other’s context reads and session-state writes. Different sessions still run concurrently under max_concurrency.
A PAUSED run holds its session’s line. Runs queued behind a human-in-the-loop pause wait for the approval, because their input likely refers to its outcome. Continue or cancel the paused run to release them. Watch paused in /queue/stats if a session looks stuck.
Set serialize_sessions=False to restore fully concurrent claiming. Serialization applies at durable-queue claim time only. The non-durable in-process path is not session-gated.

Latency

A submission accepted on a replica wakes that replica’s worker as soon as the row commits, so execution starts in milliseconds. poll_interval (default 1.0 seconds) bounds three other things: how quickly a replica picks up jobs enqueued by other replicas, when a retry becomes claimable, and how often the sweep runs. Lowering it below the default rarely helps a single-replica deployment.

Configuration

The timing fields (lock_grace_seconds, stop_timeout_seconds, retention_seconds, max_attempts, timeout_seconds) must be identical on every replica sharing a queue table. See Fleet-wide settings.

Next Steps