The previous article, Agent Pattern Design, covered control-flow patterns inside a run: tool use, parallelism, planning, HITL, retry, and reflection.
This article has a different job. It is not how to implement a production agent backend, but how to split responsibilities and boundaries so later implementation articles have a shared map.
1. When agent runs get long, the problem changes
At first, running an agent inside a FastAPI handler is fine.
When a run takes three minutes instead of thirty seconds, the story changes. The user may close the browser, a Pod may restart, or the same request may arrive twice. The backend stops being a simple LLM app and becomes a small distributed system.
Production questions follow quickly:
- Should an HTTP handler hold the run until it finishes?
- When the user closes a tab, should execution stop?
- Where do progress and checkpoints live, and what do we show in the browser?
- If an API Pod restarts, how do we rediscover in-flight runs?
The mental model below is the starting point.
API = accept work
Queue = preserve execution intent
Worker = execute the run
PostgreSQL = durable state / source of truth
Redis = runtime state / coordination / short-lived events
SSE = deliver progress to the user
The main questions this article answers:
Why do we need a queue?
Why does duplicate execution happen?
Why is the gap between DB and queue dangerous?
Why must SSE reconnect be designed for?
| Layer | This article | Follow-ups |
|---|---|---|
| Concurrency | async/thread/process vs worker role | Why Agent Systems Need Async, Async, Threads, and Processes |
| Accept & execute | 202, queue, worker ownership | Worker Is Not a Thread |
| State | PostgreSQL vs Redis | Where Agent State Lives, State Is Not an Event |
| Delivery | SSE and observation | Agent SSE Streaming |
| Side effects | Surviving duplicates | Can an Agent Tool Execute Exactly Once? |
2. The architecture map
Solid arrows are how the user commands execution. Dashed arrows are how the browser observes progress.
Browser → API → Queue → Worker
↓
PostgreSQL · Redis → SSE → Browser
- Solid arrow — Command: user → API → Queue → Worker
- Dashed arrow — Observation: Worker → stores → SSE → user
- POST auth, validation, create run
- GET read PostgreSQL snapshot
- SSE relay events after cursor
- Own this attempt with attempt_id and lease
- Run agent graph, LLM calls, and tools
- Persist checkpoint/result before ack
A disconnect does not stop the run; reconnect recovers from snapshot + last event cursor.
Command: Browser ──POST──> API ──publish──> Queue ──claim──> Worker
Observation: Browser <──SSE── API <──read──── Redis / PostgreSQL <──write── Worker
The worker runs LLM calls, tools, and checkpoints. The API mostly accepts runs, publishes to the queue, serves reads, and relays SSE. That split lets API and worker replica counts track different resources.
Command path vs observation path
| Path | Flow | Remember |
|---|---|---|
| Command | Browser → API → Queue → Worker | The HTTP handler does not own run lifetime |
| Observation | Worker → state/event store → SSE → Browser | A disconnect does not have to stop the run |
3. The API accepts; it does not execute
Demos often start with:
asyncio.create_task(run_agent(request))
return {"status": "started"}
From a pattern view, a loop is running. From a backend view, run_id, failure lookup, and survival across deploy are still undefined.
Starting a long run usually follows:
- Validate and authorize
- Insert a run row in PostgreSQL
- Publish an execution message to the queue
- Return
202 Acceptedandrun_id
@app.post("/runs", status_code=202)
async def create_run(request: RunRequest, user: User = Depends(current_user)):
authorize(user, request)
run_id = new_run_id()
await run_store.create(run_id=run_id, user_id=user.id, status="submitting", input=request)
await job_queue.publish({"run_id": run_id, "input": request.model_dump()})
await run_store.mark_queued(run_id)
return {"run_id": run_id, "status": "queued"}
This snippet is a minimal illustration of responsibility split. In production you must handle the dual-write gap between DB writes and queue publish.
202 means the request was accepted and processing continues asynchronously—not that the browser connection guarantees survival. Queues, workers, and durable state provide that.
FastAPI Background Tasks suit short post-response work; agent runs usually belong in a queue.
4. The queue separates execution lifecycle
The queue’s job is not to run work faster, but to separate the API request lifecycle from the agent execution lifecycle.
- The API submits a job.
- The worker consumes it.
- If the worker dies, the job may be redelivered.
- Duplicate execution is therefore a normal failure mode.
Steps with side effects must ask “is this the first processing of this message?” via run rows, attempts, or idempotency keys. Can an Agent Tool Execute Exactly Once? continues that thread.
Deep dive — Why can a queue lead to duplicate execution?
Brokers usually offer at-least-once delivery:
- If a consumer dies before ack, the message reappears
- After lease or visibility timeout, another worker may claim the same job
- Retries may publish twice
Do not expect the queue to dedupe; design idempotent steps and explicit duplicate detection. Ack timing, idempotency keys, and lease expiration are wired in implementation articles.
5. The worker owns the agent run
The worker role claims queue jobs and owns the current attempt lifecycle: LLM calls, tool loop, checkpoints, and terminal status commits.
Calls to external LLMs or business databases are not the same as internal state transitions—they need timeouts, retries, and idempotent side effects.
Closing a tab or dropping SSE removes the observation path only. An accepted run typically keeps going. Cancellation is product policy; the default is run lifetime ≠ HTTP connection lifetime.
6. Where does state live?
Do not put every kind of state in one store.
| Component | Main responsibility |
|---|---|
| PostgreSQL | Durable run state, checkpoints, results, audit |
| Redis | Runtime state, coordination, short-lived event history |
| Queue | Execution intent / delivery |
| Object storage | Large outputs and artifacts (when needed) |
PostgreSQL holds recoverable truth. Redis holds fast-changing progress and coordination.
Retrieval storage (embeddings, RAG indexes) is a separate concern; this article covers execution architecture only.
Deep dive — What if the DB commit succeeds but queue publish fails?
Separate run_store.create and job_queue.publish calls leave gaps:
- A run row without a queue message if the process dies after commit
- A message without a row if the transaction fails after publish
Using DB and queue separately introduces a consistency problem. Production systems often use a transactional outbox:
BEGIN;
INSERT INTO agent_runs (...);
INSERT INTO outbox (topic, payload) VALUES ('run.execute', ...);
COMMIT;
──> publisher worker reads outbox, publishes to queue, sets processed_atBefore returning 202, the system must leave execution intent you can rediscover later.
Deep dive — How do you update Redis progress and event streams atomically?
At map level, Redis is for runtime coordination. In implementation, teams often combine a Hash (current snapshot), a bounded Stream (recent events), and Lua scripts for atomic updates. TTL, stream limits, and consumer offsets connect in Agent SSE Streaming and State Is Not an Event.
Mid-article recap: The API accepts work, the queue delivers it to a worker, durable state records outcomes, and SSE shows that state to the user.
7. Separate execution from observation — SSE
SSE is independent of agent execution.
- If the browser disconnects, the worker should keep running.
- On return, the user needs the current snapshot or recent events again.
Worker → State / Event Store → SSE endpoint → Browser
The observation path sends id, event, and data per the WHATWG SSE format. When a generator sees disconnect, it stops HTTP chunks only. A broken stream is not a run rollback.
Deep dive — After SSE reconnects, how are events recovered?
Clients reconnect with Last-Event-ID; servers resume from an event-store cursor. Redis Streams, sequence numbers, replay windows, and transport vs domain idempotency are developed in Agent SSE Streaming.
8. What if two workers run the same run?
Queues and leases alone do not prove an old worker is gone. Design for multiple workers touching the same run.
Deep dive — Worker A was presumed dead; B took over—then A wakes up late?
Worker A → fencing token 41
Worker B → fencing token 42
Storage accepts only token >= current token
A write rejected · B write acceptedFencing tokens, monotonic versions, and queue leases reduce stale writers overwriting fresh progress. Comparing only updated_at does not prove a write belongs to the current attempt. See Where Agent State Lives.
9. Pod, process, worker, thread
When Kubernetes, Python, and agent vocabulary land in one sentence, designs wobble. A worker is not a Pod.
| Term | Meaning |
|---|---|
| Pod | Kubernetes scheduling / deployment unit |
| Process | OS execution unit |
| Worker | Job execution unit (queue consumer role) |
| Thread / async task | Concurrency primitive inside a process |
When a Pod restarts, its processes and asyncio tasks go with it. The worker role is designed so queue messages and PostgreSQL runs let another Pod resume. A “thread pool worker” is not the same as a queue consumer.
10. Async, thread, process — inside the worker
| Tool | When |
|---|---|
| async | Overlap many I/O waits in one process |
| thread | Isolate blocking SDK calls |
| process / separate worker | CPU-heavy or isolated execution |
Python GIL and executor details continue in Why Agent Systems Need Async and Async, Threads, and Processes.
11. Why split API and worker deployments
Locally, API and worker in one process or Pod is enough to learn. In production, split Deployments.
Development
API and worker may share one process or one Pod
- Responsibilities stay separate in code
- Deployment and failure lifetime are shared
Production
Separate Deployments and Pod sets — scale, deploy, and fail independently
| API | Worker | |
|---|---|---|
| Scale on | HTTP request volume | Execution workload, queue depth |
| Pods | agent-api | agent-worker |
| Shared | Queue · PostgreSQL · Redis |
Putting everything in one Pod makes independent scaling hard.
12. Graceful shutdown
Pod termination is not just “kill the process”—it is an in-flight run ownership problem.
- Stop accepting new jobs
- Finish or hand off running jobs
- Save checkpoints
- Decide ack vs requeue
- Exit
After SIGTERM, align lease release and ack order with Pod Lifecycle behavior. Do not ack a queue message before committing an important result to PostgreSQL.
13. Architecture recap
API → accept (202 + run_id)
Queue → separate lifecycle, redelivery possible
Worker → own attempt, run tool loop
PostgreSQL → durable truth
Redis → runtime / coordination / short observation
SSE → observation only, not execution owner
Failures on the same axes:
| Failure mode | Remember |
|---|---|
| Duplicate execution | Redelivery is normal; design for idempotency |
| Dual write (DB ↔ queue) | Tie acceptance to publish, e.g. outbox |
| Stale progress | Reject late writes with attempt/version/fencing |
| SSE disconnect | Run continues; reconnect restores snapshot/events |
Tool loops, HITL, and retry from pattern design run inside the worker. This map fixes which infrastructure owns lifecycle around that loop.
- 1Browser → APIPOST /runsAuthenticate, authorize, and validate input
- 2API → PostgreSQLsubmittingPersist run_id and recoverable execution intent
- 3API → QueuequeuedExecution intent lives outside the API process
- 4API → Browser202 + run_idSafe acceptance, not completion
- 5Queue → Workerclaim → runningStart this attempt with attempt_id and lease
- 6Worker → Storescheckpoint · progress · eventPostgreSQL is durable; Redis is short-lived observation data
- 7Worker → PostgreSQLcompleted / failedCommit terminal result before queue ack
- 8API · SSE → Browsersnapshot + cursorRead, reconnect, and ignore duplicate events
14. Next: implementation
This article defines architecture boundaries only. Follow-ups implement, in code:
- FastAPI submit endpoint
- Queue publisher (or outbox relay)
- Worker process
- PostgreSQL run state
- Redis progress state
- SSE streaming endpoint
- Retry and idempotency
Next, Why Agent Systems Need Async covers overlapping I/O waits inside the worker. The full path is on the Agent Engineering series page.
Discussion
Discussion
Have a question, correction, counterexample, or another interpretation?