# Versioning and deploys

> How a run is tied to the code that started it, and how to ship a change while runs are in flight.

Source: https://docs.pyrula.io/workflows/guides/versioning/

Every run is stamped with its workflow's version at submit: your `version=`, or by default a
12-character hash of the function body. The version decides how the run is claimed after a deploy.

- **No `version=`** is `auto_upgrade`. After a restart, a worker on the new code replays each
  in-flight run's history against the new body and adopts it. A compatible edit finishes the
  run; an incompatible one (renamed or removed step, changed inputs) ends it with
  `incompatible_upgrade`.
- **With `version=`** is `pinned`. Only a worker with the matching version claims the run.

The store ranks each version of a workflow by first sighting. A worker adopts a run when its
version ranks the same or higher, so old and new workers coexist in a rolling deploy. An old
worker leaves a newer run pending.

After a rollback, runs stamped by the newer code stay pending until a worker on that version
drains them or you cancel them. The worker logs `stranded_runs_detected` meanwhile.

A version the store has never seen ranks newest. A locally edited body, or an old pod rebuilt so
its implicit hash changed, adopts every stamped run backward, which likely ends in
`incompatible_upgrade`. Keep explicit versions stable across rebuilds.

The replay check re-runs body code outside steps, so a `print` or log line there runs again.

## Choosing a policy

```python
from pyrula.workflows import workflow

@workflow(name="charge", version="2026-10-01", version_behavior="auto_upgrade")
async def charge(ctx, amount_cents: int) -> str:
    ...
```

| | `pinned` (default with `version=`) | `auto_upgrade` (default without) |
|---|---|---|
| Who claims an old run | Same version only | Same version or newer |
| On a newer worker | n/a | Shadow-replays history; adopts if compatible, else `incompatible_upgrade` |
| Use when | A change can't be replayed safely | Changes are additive or guarded with [`ctx.patched`](https://docs.pyrula.io/workflows/concepts/determinism.md#deploy-safe-versioning) |

Changing the arguments of a recorded call is replay-incompatible: the shadow replay ends the run
with `incompatible_upgrade`, detail starting `replay_input_changed:`. See
[when a recorded call's inputs change](https://docs.pyrula.io/workflows/concepts/determinism.md#when-a-recorded-calls-inputs-change).

`@agent` takes the same `version=` and `version_behavior=`. The check replays the stored turn
journal against the new body without calling the model or running a tool. Dropping, renaming or
reordering a recorded call ends the turn with `incompatible_upgrade`. The check runs your body
up to its first new call, so that code runs twice:

- `ctx.memory.put` and `ctx.memory.delete` are skipped while verifying. Writes made another way
  (a client in `deps`) do happen.
- It is bounded by the lower of the agent's `timeout` and `max_attempt_wall_seconds`, capped at
  30 seconds. On timeout the run goes back to the queue unfailed; the third timeout ends it
  with `replay_check_timeout`. Hand-backs show as the `replay_check_deferred` outcome (metric)
  and, on Kafka, a hold with reason `replay_check`.
- A dependency failure before the first new call (`StoreUnavailable`, `Transient`,
  `ConnectionError`, `TimeoutError`, `socket.gaierror`) is an ordinary attempt error under the
  worker's retry policy. Any other error there is a divergence and ends the run with
  `incompatible_upgrade`.
- Code that blocks without awaiting can't be interrupted and stalls the worker's loop.

## Shipping a change to a pinned workflow

1. Deploy new workers alongside the old ones. New runs get the new version; old workers finish
   old runs.
2. Wait for the old version to drain, then retire it:

   ```python
   from pyrula.workflows.versioning import retire_version, wait_drained

   if wait_drained(store, "charge", old_version, timeout=3600):
       retire_version(store, "charge", old_version)   # not retired while runs are in flight (force=True overrides)
   ```

3. Stop the old workers.

Runs a worker can't serve wait by default. To fail them after a deadline, set a stranded policy:

```python
from pyrula.workflows import WorkerOptions
from pyrula.workflows.versioning import StrandedPolicy

WorkerOptions(stranded_policy=StrandedPolicy.FAIL, stranded_timeout=3600)   # seconds
```

They end with `no_compatible_worker`.

## Proving a change is safe

Replay real histories against the new code in CI with `export_run_history` and
`assert_replay_compatible` ([Deploy-safe versioning](https://docs.pyrula.io/workflows/concepts/determinism.md#deploy-safe-versioning)).
