Skip to content

Guides

Scale out with worker nodes

Run agent execution on multiple machines: start HTTP worker nodes on port 7999, health-check them from a coordinator, and let DistributedExecutor dispatch every task to the least-loaded healthy node — with worker-level circuit breakers handling the failover.

Who this is for: Teams whose agent workload has outgrown one machine — or one process.
What you'll accomplish: A worker on a remote host answering GET /health, a coordinator that routes submit() calls across remote and local nodes, and automatic failover when a worker's circuit opens.
Prerequisites: The Quickstart on every machine, REPLICATE_API_TOKEN exported on each worker, and the [http] extra (uvicorn) installed on worker hosts.
Estimated time: 25 minutes.

Route map

  1. Start a worker

    Launch the HTTP worker server with replicate-agent workers start or serve_worker().

  2. Health-check from the coordinator

    Confirm reachability with curl /health and replicate-agent workers ping before writing any coordinator code.

  3. Build the coordinator

    Wire RemoteWorkerNode + HttpWorkerTransport into a DistributedExecutor, with a local fallback node.

  4. Verify failover

    Understand how worker circuit breakers report state via /health and how routing skips OPEN nodes.

Coordinator and workers: the topology

The distributed layer has exactly two roles. A worker executes agent invocations — it owns an AgentExecutor and talks to the Replicate API. A coordinator owns a DistributedExecutor and decides which worker gets each task. Workers come in two flavors:

  • Local nodesWorkerNode instances that run in the coordinator's own process on asyncio queues. Required: a node_id (auto-generated if omitted). Optional: max_queue_depth (default 100) and concurrency (default 4).
  • Remote nodesRemoteWorkerNode instances that delegate over HTTP through an HttpWorkerTransport to a worker server on another machine.

Dispatch is least-loaded: every node exposes a load metric (active tasks plus queued tasks for local nodes; in-flight requests for remote ones), and submit() picks the healthy node with the lowest value across both pools. There is no separate scheduler process to deploy — the coordinator is just your Python program.

Step 1 — Start a worker

On each worker machine, export the token and start the server. The CLI route needs the [http] extra for uvicorn:

On the worker machine
export REPLICATE_API_TOKEN=r8_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
replicate-agent workers start --host 0.0.0.0 --port 7999 --node-id gpu-node-1 --concurrency 8

Expected output

Starting Worker Node
  Node ID:     gpu-node-1
  Address:     http://0.0.0.0:7999
  Concurrency: 8
  Token:       r8_x...xxxx
INFO:     Uvicorn running on http://0.0.0.0:7999 (Press CTRL+C to quit)

All flags shown are optional — --host 0.0.0.0, --port 7999, and --concurrency 8 are the defaults; only --node-id is worth setting explicitly (it defaults to worker-<pid>). The same server is available programmatically, which is how you would embed it in your own service:

import asyncio
from replicate_mcp.worker_server import serve_worker

asyncio.run(serve_worker(host="0.0.0.0", port=7999))

serve_worker() also accepts node_id, max_concurrency=8, enable_circuit_breaker=True, and an optional circuit_config. Either way the worker exposes three HTTP endpoints:

  • POST /execute — run an agent invocation, return a TaskResult as JSON.
  • GET /health — liveness probe; includes circuit-breaker state, and returns 503 when the circuit is OPEN.
  • GET /metrics — load counters: active_tasks, total_processed, plus circuit metrics.

Step 2 — Health-check from the coordinator

Before writing coordinator code, prove the network path works from the coordinator machine. Raw curl shows you exactly what the transport will see:

On the coordinator machine
curl http://gpu-node-1:7999/health

Expected output (HTTP 200; circuit breaker enabled by default)

{"status": "healthy", "node_id": "gpu-node-1", "circuit": {"state": "closed",
 "failure_count": 0, "success_count": 0, "last_failure_at": null,
 "recovery_timeout": 60.0, "half_open_max_calls": 3, "half_open_calls": 0,
 "can_execute": true}}

The friendlier CLI wrapper does the same check plus a metrics read:

Ping via the CLI
replicate-agent workers ping http://gpu-node-1:7999

Expected output

✓ Worker at http://gpu-node-1:7999 is healthy
  Active tasks:     0
  Total processed:  0

If it fails

✗ Worker at http://gpu-node-1:7999 is unreachable
# → check the worker bound 0.0.0.0 (not 127.0.0.1) and port 7999 is open

Step 3 — Build the coordinator

The coordinator combines remote and local nodes in one pool. Save as coordinator.py (this assumes the llama_chat agent from the Quickstart is registered on the workers):

import asyncio
from replicate_mcp.distributed import (
    DistributedExecutor,
    HttpWorkerTransport,
    RemoteWorkerNode,
    WorkerNode,
)

async def main():
    async with DistributedExecutor() as executor:
        # Remote GPU workers — HTTP transport, 120 s default request timeout
        for host in ("gpu-node-1", "gpu-node-2"):
            transport = HttpWorkerTransport(f"http://{host}:7999")
            executor.add_remote_node(RemoteWorkerNode(host, transport=transport))

        # Local in-process fallback node
        executor.add_node(WorkerNode("local-fallback", concurrency=4))

        handle = await executor.submit("llama_chat", {"prompt": "Say hello in 5 words"})
        result = await handle  # TaskResult

        print(result.node_id, result.status.value, f"{result.elapsed_ms:.0f}ms")
        for chunk in result.chunks:
            if chunk.get("done"):
                print(chunk.get("output"))

asyncio.run(main())
Run the coordinator
python coordinator.py

Expected output

gpu-node-1 done 824ms
Hello there, nice to meet!

submit() returns a TaskHandle — a future you can hold while submitting more work, then await for the TaskResult. The result carries task_id, agent_name, node_id (which machine ran it), chunks (the streamed output), status (pending / running / done / failed), error, and elapsed_ms. For batches, await executor.run_many([("llama_chat", {...}), ...]) submits concurrently and returns results in input order; executor.stream(...) yields chunks from a single task as an async iterator.

Two failure modes are worth knowing by name: NodeOverloadError means a local node's queue hit max_queue_depth (the executor retries on another node up to max_retries=2 times before raising), and NoHealthyNodesError means the candidate pool is empty — every node is unhealthy or circuit-open.

Step 4 — Worker circuit breakers and failover

Since v0.8.0 each worker server tracks its own reliability with a WorkerCircuitBreaker (enabled by default; same CLOSED → OPEN → HALF_OPEN machine described in Resilience & caching, applied to the whole worker rather than one model). The state is serialized as a WorkerCircuitState — the circuit object you saw in the /health response — so coordinators can make routing decisions without any shared infrastructure:

  • When the worker's circuit is OPEN, GET /health returns 503 and POST /execute rejects new tasks with 503.
  • The coordinator checks circuit state before dispatch (RemoteWorkerNode.check_circuit_state()) and skips OPEN nodes entirely, raising WorkerCircuitOpenError internally and routing to the next healthy node.
  • HALF_OPEN nodes stay in the pool but their load is multiplied by 1.5 during selection, so probe traffic trickles back instead of flooding a recovering worker.

To force a failover test, stop one worker (Ctrl+C on its host) and re-run coordinator.py: the printed node_id moves to a surviving node, and once nothing remote is healthy, to local-fallback.

Verification

You're done when

  • replicate-agent workers ping http://gpu-node-1:7999 reports healthy with task counters.
  • curl /health from the coordinator returns 200 with a circuit object whose state is closed.
  • python coordinator.py prints a remote node_id and done status.
  • Killing one worker and re-running routes the task to another node instead of raising NoHealthyNodesError.

Common mistakes

SymptomCauseFix
Worker unreachable from the coordinator (ping fails, curl times out) Worker bound to 127.0.0.1, or a firewall blocks TCP 7999 between the machines Start with --host 0.0.0.0 (the default — check you did not override it) and open port 7999 on the worker's firewall / security group
NoHealthyNodesError on every submit() All remote circuits are OPEN (workers are failing) and no local node is registered Add an in-process WorkerNode fallback; check worker logs for the underlying failures and wait out recovery_timeout (60 s)
Tasks pile up on one node while others idle A proxy or firewall in front of the workers blocks GET /metrics and GET /health, so the coordinator's load and health view is stale Allow both read endpoints end-to-end; confirm with curl /metrics that active_tasks changes while a task runs

Next steps