# Workers

> PyrulaWorker runs agent execution as its own tier, separate from the API.

Source: https://docs.pyrula.io/agents/serving/workers/

In separate mode the API submits and streams, and `PyrulaWorker` claims turns from the shared store, runs them, and handles timers, signals, orphan recovery and poison turns.

```python
from pyrula.agents import PyrulaWorker
from pyrula.agents.llm import OpenAILLM

worker = PyrulaWorker(
    agents=[support],          # or omit for the registry
    store="redis://localhost:6379",
    llm=OpenAILLM(model="gpt-6-luna"),
    concurrency=50,
)
worker.run()
```

`store` is a Valkey or Redis URL or a store instance, and must match the API's. With no `agents`, it serves every imported `@agent` (`ValueError` if none). If `PYRULA_CLOUD_API_KEY` is set the worker attaches to Cloud; `cloud=False` turns that off. `run()` blocks; `run_async()` is awaitable. `worker.stop()` stops claiming, lets in-flight turns finish within a 30-second grace, cancels the rest so another worker can recover them, stops the cloud connector, then returns. Call it from a signal handler.

## Tuning

```python
from pyrula.agents import WorkerOptions, PoisonOptions

WorkerOptions(
    max_concurrent_turns=50,
    orphan_scan_interval=60.0,
    timer_poll_interval_s=5.0,
    schedule_poll_interval_s=30.0,
    poison=PoisonOptions(max_retries=3, retry_ttl_s=3600.0),
)
```

## Poison turns

A turn that keeps crashing the worker is retried up to `PoisonOptions.max_retries` (3), counted for `retry_ttl_s` (1 h), then quarantined: `run:error` with a poison reason and `RunStatus.quarantined`.

A turn whose code blocks the event loop (a sync SDK called from an `async` tool, say) past `max(3 × lock_ttl, timeout)` stops renewing its lock and moves to another claim. Each of those counts toward the same budget, for the turn whose code was blocking only, not for other turns on the same worker. The quarantine has `error_type` `EventLoopBlocked` and `detail.cause` `loop_blocked`. The agent's `timeout` and a tool's `timeout=` cannot fire while the loop is blocked.

## Crash recovery

Another worker claims a dead worker's turn on its orphan scan and resumes from the first unfinished step. Sleeping and signal-waiting turns aren't orphans. Queued turns are picked up right away, and a clean `stop()` releases its shards immediately ([Scaling across shards](https://docs.pyrula.io/workflows/guides/worker.md#scaling-across-shards); [replay and recovery](https://docs.pyrula.io/workflows/concepts/replay-and-recovery.md)).

`store="redis://..."` builds a default `ValkeyStore`; pass `ValkeyStore(shard_count=...)` to configure sharding. A cluster is detected from `INFO`; `cluster=True` overrides that for one behind a proxy that hides it. The agents `PostgresStore` takes the same `shard_count`, `shard_lease_ttl` and `cluster` for its live tier, plus `live_ttl_after_archive` (default 300 s).

## Health probes

`PyrulaWorker(health_port=8081)` (or `PYRULA_WORKER_HEALTH_PORT`) serves `/livez` and `/readyz`; `health_host` / `PYRULA_WORKER_HEALTH_HOST` sets the bind address (default `127.0.0.1`). `/readyz` turns 503 once `stop()` is called. See [Health probes](https://docs.pyrula.io/workflows/guides/worker.md#health-probes) and [Deploy a worker](https://docs.pyrula.io/getting-started/deploy-a-worker.md).
