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.
| Call | On replay |
|---|---|
@step call | Recorded 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 |
When a recorded call’s inputs change
Section titled “When a recorded call’s inputs change”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")andfetch(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).
Unsafe operations
Section titled “Unsafe operations”@workflowasync def unsafe(ctx): now = time.time() # different every run uid = uuid.uuid4() # different every run data = await fetch(url) # different response on replay
@workflowasync def safe(ctx): now = ctx.now() uid = ctx.uuid() data = await ctx.step("fetch", fetch, url) # runs once, replays the resultThe 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.
Idempotent side effects
Section titled “Idempotent side effects”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().
Version skew
Section titled “Version skew”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 flight | Not safe |
|---|---|
| Changing a step’s implementation, keeping its id and position | Renaming or reordering steps |
| Adding workflows, or steps in-flight runs don’t reach | Removing a step an in-flight run already recorded |
Changing a recorded call’s arguments (replay_input_changed) |
Deploy-safe versioning
Section titled “Deploy-safe versioning”Gate a structural change behind ctx.patched:
@workflowasync 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 runassert_replay_compatible(charge, history) # AssertionError on divergenceexport_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.