Skip to content
Pyrula
Pre-release: the packages are not on PyPI yet, so pip install does not resolve. Build from source to try it today.

Determinism and Replay Safety

Replay re-runs the body from the top and serves steps that already have step:done from the journal. Everything else in the body must make the same decisions from those recorded values, so route non-deterministic or side-effecting work through ctx. A call gets its recorded result only if its inputs match; otherwise the run stops with replay_input_changed.

CallOn replay
@step callRecorded result, restored to its declared type
ctx.step(id, fn, *args, kwargs={...})Recorded result; a recorded failure raises the same exception again
ctx.once(key, fn, ...)Result stored under key, across runs
ctx.run_child(name, params)Recorded child result; the child is not resubmitted
ctx.now(), ctx.uuid(), ctx.random()Recorded value
ctx.sleep() / ctx.sleep_until()Skips the wait
ctx.wait_for_signal()Recorded signal
ctx.interrupt()Recorded response, if the question matches
ctx.emit()Not re-emitted. Without id=, repeats of one kind are counted in order

Each call with inputs records a fingerprint: step arguments, child params, an interrupt’s question, and in agents a model request, tool input, sub-agent kwargs and each gather_subagents leg. Replay reuses the result only if position and fingerprint both match. Otherwise: run:error code replay_input_changed, naming the call (step:fetch#0).

  • Step arguments are compared by parameter name with defaults applied, so fetch("A") and fetch(customer_id="A") are the same call.
  • A recorded call with no fingerprint also refuses.
  • ctx.now(), ctx.uuid(), ctx.random(), sleeps and signals take no inputs and replay by position.

Import-time defaults (datetime.now(), uuid4()) differ in the resuming process, and a mutable default the step mutates differs after the first call. Both refuse on resume. Pass them explicitly or compute inside the step.

Fingerprints stay in your store and never go to Cloud (What leaves your runtime).

@workflow
async def unsafe(ctx):
now = time.time() # different every run
uid = uuid.uuid4() # different every run
data = await fetch(url) # different response on replay
@workflow
async def safe(ctx):
now = ctx.now()
uid = ctx.uuid()
data = await ctx.step("fetch", fetch, url) # runs once, replays the result

The engine only checks steps declared effect="pure": it reads their bytecode and rejects direct time, random, uuid or datetime calls, following same-module helpers. Other-module helpers slip through and default steps aren’t inspected. Keep non-deterministic work behind ctx.

A step with step:done never re-runs, but a crash between the side effect and that record runs it again. @step exposes a stable StepContext.idempotency_key (Steps, the crash window). For effects that can’t be made idempotent, gate with ctx.interrupt().

An explicit version= pins the run to workers on that version. Without one, new code adopts in-flight runs (Versioning and deploys). If new code diverges from an old journal, the run fails rather than return a wrong result.

Safe while runs are in flightNot safe
Changing a step’s implementation, keeping its id and positionRenaming or reordering steps
Adding workflows, or steps in-flight runs don’t reachRemoving a step an in-flight run already recorded
Changing a recorded call’s arguments (replay_input_changed)

Gate a structural change behind ctx.patched:

@workflow
async def charge(ctx, amount_cents: int):
await ctx.step("charge", lambda: payment_client.charge(amount_cents))
if await ctx.patched("refund-flow"):
await ctx.step("refund-hold", lambda: payment_client.hold_refund_window())
return "done"

Replaying past this point without a marker takes the old branch. Reaching it live takes the new one and writes a marker.

Check it in CI with pyrula.workflows.testing:

from pyrula.workflows.testing import assert_replay_compatible, export_run_history
history = export_run_history(store, "charge", run_id) # from a live or archived run
assert_replay_compatible(charge, history) # AssertionError on divergence

export_run_history returns JSON-able dicts with the six envelope keys, so a history can live in your repo as a fixture.

Once old runs have drained past the patch point, switch to await ctx.deprecate_patch("refund-flow") (still writes the marker, returns nothing). When nothing needs the old branch, delete it and the guard.

View this page as Markdown