Lab 3: Determinism
Objective
Why: Learn why Workflow decisions must replay the same way after a restart.
By the end: Cause three nondeterminism failures, locate each mismatch in the history, and apply a replay-safe fix.
Before you run
- Mechanism: A replayed Workflow must make the same decisions from the same recorded history. Use Temporal’s replay-safe clock and random helpers; put external reads in Activities.
- Predict: Before forcing each replay, predict whether the sandbox will reject the code first or history will reveal a Command mismatch.
- Look for: Record the failing event ID and the
awaitthat emitted the mismatched Command. Then confirm the corrected version replays. - Terminal route: From this lab directory, run
source ../.venv/bin/activatebefore copying anypython ...command below. The browser action cards select the Python environment for you.
workflows.py is lab 2's AgentRun plus the two things a real agent is tempted to do in Workflow
code: consult the clock in plan() (a time budget) and consult the model in plan() (the next
step). The environment variable LAB03_BREAK selects which build a Worker runs — none
(the fixed program), random, clock, network, and the three -naive variants. It is an
environment variable and not a Workflow argument on purpose: a fix is a redeploy, and the same
history has to replay under the fixed build. Reading page: "Determinism".
export TASK_QUEUE=lab-03
LAB03_BREAK=random python worker.py # terminal 1: the build under test
python starter.py --id coinflip-1 # terminal 2: 15 s timer after each step, 5 s time budget
python replay.py coinflip-1 --times 8 # replay the *current process's* build against the history
python failures.py coinflip-1 # WorkflowTaskFailed events, and the Task the Service is still retrying
Two ways to force a replay, both used below: kill -9 the Worker during the 15 s timer and start
another (the next Workflow Task is replayed from event 1), or replay.py, which runs the SDK's
Replayer over the fetched history with no Worker and no Service involvement.
Every error line quoted below was produced on the dev server with SDK 1.32.0.
Open this lab in the browser workspace
Complete the one-time setup in Lab 1, then open
Lab 03 on your local lab server (use the port
lab_server.py printed if it is not 3000). The Temporal server, Agent Runtime, and lab
server can stay running between labs.
Click Prepare this lab, then use this lab's action cards to start Workers and
clients, follow Output, and crash or stop processes. The page selects the lab directory, Python
environment, and Task Queue for you. For a variation from the exercise, paste one
python ... command into the command field. The instructions below also describe the
terminal route if you prefer it.
The workspace adds a short suffix to this lab's Task Queue, and in some labs to its Workflow ID
(lab-05-3f2a and agent-42-3f2a rather than lab-05 and
agent-42), so a Worker or run left over from another tab cannot interfere. Read the
names in the text below with that suffix; the Output panel shows the real ones.
Goal: break replay three ways — randomness, the wall clock, a network read — force a replay after
each, read the error the SDK gives you, and fix it. By the end you can read a
NondeterminismError, name the event id where history and code disagreed, and point at the
await that produced the mismatching Command.
3.1 Randomness
-
The naive version first.
LAB03_BREAK=random-naive python worker.py, thenpython starter.py --id coinflip-naive --no-wait. The run never gets past its first Workflow Task.python failures.py coinflip-naiveshows:That is the sandbox, not replay:4 WORKFLOW_TASK_FAILED cause=WORKFLOW_WORKER_UNHANDLED_FAILURE worker=<pid>@<host> Cannot access random.random.__call__ from inside a workflow. If this is code from a module not used in a workflow or known to only be used deterministically from a workflow, mark the import as pass through. 5 WORKFLOW_TASK_SCHEDULED attempt=2 (earlier attempts failed without an Event) status: RUNNING pending Workflow Task: attempt=2 ...RestrictedWorkflowAccessErrorbefore there is any history to disagree with. Note what the Service did with it — a Workflow Task failure, retried with backoff, the execution stillRUNNING. Nothing is lost; the Worker is simply wrong. Stop the Worker, then end the run:python terminate.py coinflip-naive. A stuck run costs no compute, but it is not harmless — the next Worker you start onlab-03would adopt it and run it. -
Get past the sandbox.
LAB03_BREAK=randomusesrandom.Random().random()— constructing aRandomis allowed so seeded generators keep working, and an unseeded one is seeded from the OS. That fairly stands in for a third-party library the sandbox cannot see into. Run it:LAB03_BREAK=random python worker.py,python starter.py --id coinflip-1. It completes (one Worker, no replay, nothing to disagree with). -
Force replays:
LAB03_BREAK=random python replay.py coinflip-1 --times 8. Each replay is a fresh coin. We got three clean replays and then:Read it: the code (this replay's coin said "no jitter") proposedreplay 4: NondeterminismError [TMPRL1100] Nondeterminism error: Activity machine does not handle this event: HistoryEvent(id: 5, TimerStarted) -> the code's Command did not match Event 5 (TimerStarted). ...ScheduleActivityTask; at position 5 history hasTimerStarted(the original coin said "jitter"). The Activity state machine was handed a timer Event it cannot accept. If the original run drew "no jitter" you will see the mirror image —Timer machine does not handle this event: HistoryEvent(id: 5, ActivityTaskScheduled). Open event 5 in the UI; theawaitthat produced the Command isawait asyncio.sleep(1)orexecute_activity(call_llm), one line apart. -
Count how many replays passed by luck. That number is why this bug ships: the code is wrong on every run and fails on only some replays.
- The kill route, to see what the Service does when a real Worker hits this:
python starter.py --id coinflip-2 --no-wait,kill -9the Worker during the timer, start it again (stillLAB03_BREAK=random). The Workflow Task afterTimerFiredreplays from event 1 with a new coin. Half the time the coin agrees and the run completes with no trace; half the timefailures.py coinflip-2showsWORKFLOW_TASK_FAILED cause=NON_DETERMINISTIC_ERROR— and then the Service retries the Workflow Task, the retry draws again, and the run completes anyway — about ten seconds later, after the retry backoff. A retry that passes by luck is still a bug. - Fix:
workflow.random().random()— arandom.Randomseeded from history, so every replay draws the original coin. That isLAB03_BREAK=none.python replay.py coinflip-1 --times 8with the fixed build may still fail: the fixed coin need not equal the unseeded coin the broken run drew. A code change cannot repair a history the old code produced — that is module 9's problem. Start a fresh run under the fixed build; it replays clean every time.
3.2 The wall clock
plan() finishes the run once it is older than its time budget (5 s by default).
LAB03_BREAK=clock-naive(datetime.now()inplan()): the same sandbox failure —Cannot access datetime.datetime.now.__call__ from inside a workflow ...at event 4, retried forever. Stop it andpython terminate.py clock-naive.LAB03_BREAK=clockreads the wall clock underworkflow.unsafe.sandbox_unrestricted().LAB03_BREAK=clock python worker.py,python starter.py --id clock-1. Step 0 runs at age 0 s, well inside the budget, soplan()schedulescall_llm; after the 15 s timer step 1 is over budget and the run finishes. 16 events, looks fine. (21 if the build's jitter coin came up: a one-second timer beforecall_llm. Every build flips it, seeded withworkflow.random(), so about half your runs have it.)-
LAB03_BREAK=clock python replay.py clock-1. Unlike the coin, this fails on every replay, because every replay happens later than the original run:On replayreplay 1: NondeterminismError [TMPRL1100] Nondeterminism error: Complete workflow machine does not handle this event: HistoryEvent(id: 5, ActivityTaskScheduled)plan()at step 0 sees a run that is already minutes old, returnsfinish, and the code proposesCompleteWorkflowExecution; history hascall_llmbeing scheduled at 5 — or, if the jitter coin came up,TimerStartedat 5, and the message names that Event instead. -
Now the kill route, and watch a production incident in miniature.
python starter.py --id clock-2 --no-wait;kill -9the Worker during the timer; start it again withLAB03_BREAK=clock. It never recovers:python failures.py clock-2shows oneWORKFLOW_TASK_FAILED cause=NON_DETERMINISTIC_ERRORwith the message above, followed by aWORKFLOW_TASK_SCHEDULEDwhoseattempt=keeps climbing (we watched it reach 4) while the status staysRUNNING. Only the first failure is written to history; the later attempts are tracked in the Service's mutable state, and the pending Workflow Task's attempt count is where you read them. - The fix is a redeploy: stop the broken Worker, start
python worker.py(noLAB03_BREAK). Within 10–30 s (the Workflow Task retry backoff) the run completes —failures.py clock-2now ends inWORKFLOW_EXECUTION_COMPLETED, and the retriedWORKFLOW_TASK_SCHEDULEDreads the attempt that finally succeeded (5, in our run). The fixedplan()usesworkflow.now(), the time of the Workflow Task being replayed: at step 0 it is the start time again, the run is 0 s old, and the code proposes exactly what history holds. Same history, fixed code, clean replay —python replay.py clock-2confirms it. Nothing was restarted from scratch; the execution simply continued once a correct Worker existed.
3.3 A network read in plan()
The model is a separate process, model_server.py: it answers next: continue the first time it
is asked and next: finish every time after, and prints a line per request. Terminal 3:
python model_server.py. Because it remembers, each experiment below starts by resetting it:
python model_server.py --reset (or restart it). Skip that and a run gets next: finish on its
first ask and ends before there is anything to see.
LAB03_BREAK=network-naive(urllib.request.urlopen()inplan()): the sandbox again —Cannot access urllib.request.urlopen.__call__ from inside a workflow .... Stop it andpython terminate.py net-naive— left running, the next build would adopt it and spend the model's onenext: continueon it.python model_server.py --reset, thenLAB03_BREAK=network python worker.py,python starter.py --id net-1 --budget 300.plan()asks the model at step 0 (model call #1 -> 'next: continue'), schedulescall_llm, waits 15 s, asks again at step 1 (model call #2 -> 'next: finish') and finishes. Two model calls, two lines in terminal 3.-
LAB03_BREAK=network python replay.py net-1. Terminal 3 printsmodel call #3: 'Step 0 for: …' -> 'next: finish'— the replay paid for a model call the original run already made — and the answer is different, soplan()at step 0 now saysfinish:([TMPRL1100] Nondeterminism error: Complete workflow machine does not handle this event: HistoryEvent(id: 5, TimerStarted)TimerStartedif the run's jitter coin came up,ActivityTaskScheduledif not — either way the code proposedCompleteWorkflowExecutionwhere history has a Command from anllmstep.) Even when a live model happens to answer the same thing twice, you have paid twice; when it does not, the run is stuck exactly likeclock-2: on the kill route (python model_server.py --reset,python starter.py --id net-2 --no-wait --budget 300,kill -9during the timer, restart withLAB03_BREAK=network)failures.py net-2showsWORKFLOW_TASK_FAILED cause=NON_DETERMINISTIC_ERRORand terminal 3 prints a newmodel callfor every retry of the Workflow Task — a stuck run that keeps billing. StartLAB03_MODEL_URL=http://127.0.0.1:8765 python worker.py(the fixed build) and it completes: the recordedcall_llmanswer already saysnext: finish, so the fixedplan()makes the decisions history holds. -
Move the read into the Activity.
LAB03_MODEL_URL=http://127.0.0.1:8765 python worker.py(noLAB03_BREAK):call_llmasks the model, andplan()decides fromself.context_summary— the recorded answer.python model_server.py --reset, thenpython starter.py --id net-fixed --budget 300 --sleep 5runs two steps:call_llmtwice (next: continue, thennext: finish), then finish.python replay.py net-fixed --times 3: three clean replays and terminal 3 prints nothing. Both answers are in history asActivityTaskCompletedresults; the replay hands them toplan(), which makes the same two decisions. - One sentence, written down: why does the recorded answer suffice? (The reading's version:
the model's answer is an effect; what
plan()does with it is a decision.)
Check yourself
PYTHONPATH=.. pytest tests/ runs the fixed build and asserts clean replays; runs the clock
build and asserts the replay fails at event 5 once the budget has passed, then replays clean
under the fixed build; and runs the network build against an in-process model server, asserting
the replay costs a third model call and fails, while the fixed build's replay costs none.
What you learned
- Replay requires the same Workflow commands in the same order for the same history.
- Reading randomness, wall time, or a network result in Workflow decisions can change those commands.
- Move effects into Activities and replay old histories before deploying changed Workflow code.
Questions to answer
- At which recorded Event does the broken clock build first disagree with its history?
- Why can a network read in
plan()create an extra model call during replay? - Which value should be recorded as an Activity result, and which decision stays in Workflow code?
- Why does starting a fixed Worker recover a stuck run without restarting its Workflow ID?
Files
Every file in this lab, as committed. Copy or clone; nothing here is hidden.
03-determinism/failures.py
"""What the Service recorded when a Worker's Workflow Task failed — and what it is still retrying. python failures.py agent-42 """ from __future__ import annotations import asyncio import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from common import connect # noqa: E402 from temporalio.api.enums.v1 import EventType, PendingWorkflowTaskState, TimeoutType, WorkflowTaskFailedCause # noqa: E402 async def main() -> None: wf_id = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("WORKFLOW_ID", "agent-42") client = await connect() handle = client.get_workflow_handle(wf_id) async for ev in handle.fetch_history_events(): name = EventType.Name(ev.event_type).removeprefix("EVENT_TYPE_") if ev.HasField("workflow_task_failed_event_attributes"): a = ev.workflow_task_failed_event_attributes cause = WorkflowTaskFailedCause.Name(a.cause).removeprefix("WORKFLOW_TASK_FAILED_CAUSE_") print(f"{ev.event_id:3d} {name} cause={cause} worker={a.identity}\n {a.failure.message}") elif ev.HasField("workflow_task_timed_out_event_attributes"): a = ev.workflow_task_timed_out_event_attributes print(f"{ev.event_id:3d} {name} timeout_type={TimeoutType.Name(a.timeout_type).removeprefix('TIMEOUT_TYPE_')}") elif ev.HasField("workflow_task_scheduled_event_attributes"): a = ev.workflow_task_scheduled_event_attributes if a.attempt > 1: print(f"{ev.event_id:3d} {name} attempt={a.attempt} (earlier attempts failed without an Event)") elif name in ("WORKFLOW_EXECUTION_COMPLETED", "WORKFLOW_EXECUTION_FAILED", "WORKFLOW_EXECUTION_TERMINATED"): print(f"{ev.event_id:3d} {name}") desc = await handle.describe() raw = desc.raw_description print(f"status: {desc.status.name}") if raw.HasField("pending_workflow_task"): t = raw.pending_workflow_task print(f"pending Workflow Task: attempt={t.attempt} " f"state={PendingWorkflowTaskState.Name(t.state).removeprefix('PENDING_WORKFLOW_TASK_STATE_')}") if __name__ == "__main__": asyncio.run(main())
03-determinism/model_server.py
"""A model with state: the fake LLM behind lab 3.3. python model_server.py # http://127.0.0.1:8765 python model_server.py --reset # tell the running server to start its count over GET /decide?prompt=... answers "next: continue" the first time it is asked and "next: finish" every time after that, and prints one line per request — so you can count how often the model was really called. GET /reset starts the count over. That is all a real model shares with this one for our purposes: asking it twice does not give the same answer twice, and every ask is billed. """ from __future__ import annotations import argparse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse REQUESTS = 0 class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: # noqa: N802 global REQUESTS url = urlparse(self.path) if url.path == "/reset": REQUESTS = 0 body = "reset" elif url.path == "/decide": REQUESTS += 1 body = "next: continue" if REQUESTS == 1 else "next: finish" prompt = parse_qs(url.query).get("prompt", [""])[0] print(f"model call #{REQUESTS}: {prompt[:50]!r} -> {body!r}", flush=True) else: self.send_response(404) self.end_headers() return data = body.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def log_message(self, *_args) -> None: # quiet: only our own lines pass if __name__ == "__main__": ap = argparse.ArgumentParser() ap.add_argument("--port", type=int, default=8765) ap.add_argument("--reset", action="store_true", help="reset the running server's count, then exit") args = ap.parse_args() if args.reset: from urllib.request import urlopen with urlopen(f"http://127.0.0.1:{args.port}/reset", timeout=5) as r: print(f"model server: {r.read().decode()} (the next ask answers 'next: continue')") raise SystemExit(0) print(f"model server on http://127.0.0.1:{args.port} (GET /decide, GET /reset)", flush=True) ThreadingHTTPServer(("127.0.0.1", args.port), Handler).serve_forever()
03-determinism/replay.py
"""Force a replay without killing anything: run the Worker's current code against a history. python replay.py agent-42 # replay 5 times, stop at the first mismatch python replay.py agent-42 --times 20 # the coin flip: count how many replays pass by luck The code that replays is whatever LAB03_BREAK selects in *this* process — so `LAB03_BREAK=clock python replay.py x` replays the broken build and `python replay.py x` replays the fixed build against the same history. """ from __future__ import annotations import argparse import asyncio import logging import os import re import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import workflows # noqa: E402 from common import connect # noqa: E402 from temporalio.runtime import LoggingConfig, Runtime, TelemetryConfig, TelemetryFilter # noqa: E402 from temporalio.worker import Replayer # noqa: E402 # The SDK core logs a WARN for every failed replay, a long line that lands above this script's own # summary. The summary below says the same thing more readably, so only errors get through. QUIET = Runtime(telemetry=TelemetryConfig(logging=LoggingConfig( filter=TelemetryFilter(core_level="ERROR", other_level="ERROR")))) logging.basicConfig(level=logging.WARNING) async def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("workflow_id", nargs="?", default=os.environ.get("WORKFLOW_ID", "agent-42")) ap.add_argument("--times", type=int, default=5) args = ap.parse_args() client = await connect() history = await client.get_workflow_handle(args.workflow_id).fetch_history() print(f"code: LAB03_BREAK={workflows.BREAK!r}; history: {len(history.events)} events of {args.workflow_id}") clean = 0 for i in range(1, args.times + 1): try: await Replayer(workflows=[workflows.AgentRun], runtime=QUIET).replay_workflow(history) clean += 1 print(f"replay {i}: clean") except Exception as e: msg = str(e) m = re.search(r'message: "([^"]*)"', msg) print(f"replay {i}: {type(e).__name__}") print(f" {m.group(1) if m else msg}") ev = re.search(r"HistoryEvent\(id: (\d+), (\w+)\)", msg) if ev: print(f" -> the code's Command did not match Event {ev.group(1)} ({ev.group(2)}). " f"Open that Event in the UI and find the `await` that produced the Command.") break else: print(f"{clean}/{args.times} replays clean") if __name__ == "__main__": asyncio.run(main())
03-determinism/starter.py
"""Start AgentRun with a fixed Workflow ID. python starter.py # agent-42: 15 s timer, 5 s time budget, up to 5 steps python starter.py --id coinflip-1 --no-wait # return immediately; kill the Worker during the timer python starter.py --budget 300 # 3.3: give the model-driven run room for two steps """ import argparse import asyncio import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from common import TASK_QUEUE, connect, show_history, start_workflow # noqa: E402 from workflows import AgentRun # noqa: E402 async def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--id", default=os.environ.get("WORKFLOW_ID", "agent-42")) ap.add_argument("--sleep", type=int, default=15, help="timer after each step (kill the Worker inside it)") ap.add_argument("--budget", type=int, default=5, help="plan() finishes once the run is older than this") ap.add_argument("--steps", type=int, default=5, help="hard cap on steps") ap.add_argument("--no-wait", action="store_true") args = ap.parse_args() client = await connect() handle = await start_workflow(client, AgentRun.run, args=["summarize the Temporal docs", args.sleep, args.budget, args.steps], id=args.id, task_queue=TASK_QUEUE, ) print(f"started {handle.id} run={handle.result_run_id}; open http://localhost:8233/namespaces/default/workflows/{handle.id}") if args.no_wait: return print("result:", await handle.result()) await show_history(client, handle.id) if __name__ == "__main__": asyncio.run(main())
03-determinism/terminate.py
"""End runs this lab leaves stuck, so the next Worker you start does not adopt them. python terminate.py coinflip-naive clock-naive net-naive A run whose Workflow Task keeps failing stays RUNNING. That costs no compute, but it is not harmless: every Worker on the same Task Queue polls for it, so the next build you start picks it up and runs it (in 3.3, spending the model's one `next: continue` on the wrong run). Terminating is what an operator does with an execution that has no future; the Web UI's Terminate button is the same call. """ from __future__ import annotations import asyncio import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from common import connect # noqa: E402 from temporalio.service import RPCError # noqa: E402 async def main(ids: list[str]) -> None: client = await connect() for wid in ids: try: await client.get_workflow_handle(wid).terminate(reason="lab 3: stuck on a broken build") print(f"terminated {wid}") except RPCError as e: print(f"{wid}: {e.message}") if __name__ == "__main__": if len(sys.argv) < 2: raise SystemExit(__doc__) asyncio.run(main(sys.argv[1:]))
03-determinism/tests/test_lab.py
"""Check yourself: the fixed build replays clean; the broken builds do not — and you can see why. Runs on Temporal's time-skipping test server. The build under test is selected the way the Worker selects it, through LAB03_BREAK, set before each run (the sandbox re-imports workflows.py per execution, so the variable is read fresh). PYTHONPATH=.. pytest tests/ """ from __future__ import annotations import asyncio import os import sys import threading import time from http.server import ThreadingHTTPServer import pytest HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "..")) sys.path.insert(0, os.path.join(HERE, "..", "..")) import model_server # noqa: E402 import workflows # noqa: E402 from temporalio.client import WorkflowHistory # noqa: E402 from temporalio.testing import WorkflowEnvironment # noqa: E402 from temporalio.worker import Replayer, Worker # noqa: E402 from temporalio.workflow import NondeterminismError # noqa: E402 TASK_QUEUE = "lab-03-test" async def _run(wf_id: str, budget: int = 5) -> WorkflowHistory: async with await WorkflowEnvironment.start_time_skipping() as env: async with Worker(env.client, task_queue=TASK_QUEUE, workflows=[workflows.AgentRun], activities=[workflows.call_llm]): handle = await env.client.start_workflow( workflows.AgentRun.run, args=["test goal", 15, budget, 5], id=wf_id, task_queue=TASK_QUEUE ) await handle.result() return await handle.fetch_history() def _replay(history: WorkflowHistory) -> Exception | None: async def go(): await Replayer(workflows=[workflows.AgentRun]).replay_workflow(history) try: asyncio.run(go()) return None except Exception as e: # noqa: BLE001 return e @pytest.fixture def build(monkeypatch): """Select the build: build("clock") is `LAB03_BREAK=clock python worker.py`.""" def select(mode: str | None) -> None: if mode is None: monkeypatch.delenv("LAB03_BREAK", raising=False) else: monkeypatch.setenv("LAB03_BREAK", mode) return select def test_fixed_build_replays_clean(build): build(None) history = asyncio.run(_run("lab03-test-fixed")) assert _replay(history) is None def test_clock_break_fails_replay_once_the_budget_has_passed(build): """3.2: the run passed step 0 with the wall clock inside the 1 s budget; any replay after that reads a later wall clock, plans `finish`, and proposes CompleteWorkflowExecution where history has the Activity (or the jitter timer) at event 5.""" build("clock") history = asyncio.run(_run("lab03-test-clock", budget=1)) time.sleep(1.5) err = _replay(history) assert isinstance(err, NondeterminismError) assert "HistoryEvent(id: 5" in str(err) # The fix replays the same history clean: workflow.now() is history time. build(None) assert _replay(history) is None def test_model_in_plan_fails_replay_and_pays_again(build, monkeypatch): """3.3: with the model asked inside plan(), the replay asks it again (request #3) and gets a different answer. With the model behind call_llm, the replay asks nothing.""" server = ThreadingHTTPServer(("127.0.0.1", 0), model_server.Handler) threading.Thread(target=server.serve_forever, daemon=True).start() url = f"http://127.0.0.1:{server.server_port}" try: model_server.REQUESTS = 0 monkeypatch.setenv("LAB03_MODEL_URL", url) build("network") history = asyncio.run(_run("lab03-test-network", budget=300)) assert model_server.REQUESTS == 2 # step 0: continue; step 1: finish assert isinstance(_replay(history), NondeterminismError) assert model_server.REQUESTS == 3 # the replay paid for one more model_server.REQUESTS = 0 build(None) monkeypatch.setattr(workflows, "MODEL_URL", url) # call_llm runs outside the sandbox history = asyncio.run(_run("lab03-test-network-fixed", budget=300)) assert model_server.REQUESTS == 2 assert history.events and sum( 1 for e in history.events if e.HasField("activity_task_scheduled_event_attributes") ) == 2 # two call_llm steps, model-driven assert _replay(history) is None assert model_server.REQUESTS == 2 # replay asked nothing finally: server.shutdown() if __name__ == "__main__": sys.exit(pytest.main([__file__, "-v"]))
03-determinism/worker.py
import asyncio import logging import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from common import TASK_QUEUE, connect # noqa: E402 from temporalio.worker import Worker # noqa: E402 from workflows import BREAK, MODEL_URL, AgentRun, call_llm # noqa: E402 logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") logging.getLogger("temporalio.worker._workflow_instance").setLevel(logging.ERROR) # the traceback is in history async def main() -> None: client = await connect() worker = Worker( client, task_queue=TASK_QUEUE, workflows=[AgentRun], activities=[call_llm], ) print( f"worker pid={os.getpid()} polling {TASK_QUEUE!r} with LAB03_BREAK={BREAK!r}" f"{' model=' + MODEL_URL if MODEL_URL else ''}; kill -9 {os.getpid()} to crash it", flush=True, ) await worker.run() if __name__ == "__main__": asyncio.run(main())
03-determinism/workflows.py
"""Lab 3: AgentRun, stage 3 — break replay three ways, read the error, then keep the rule. The program is lab 2's, plus the two things a real agent is tempted to do in Workflow code: consult the clock in `plan()` (a time budget) and consult the model in `plan()` (the next step). `LAB03_BREAK` selects which *version of the code* a Worker runs — think "which build is deployed", which is why it is an environment variable and not a Workflow argument: the fix is a redeploy, and the same history must replay under the fixed code. LAB03_BREAK= what the Worker runs what you get none (default) workflow.random(), workflow.now(), model via call_llm clean replays random-naive random.random() in run() sandbox: RestrictedWorkflowAccessError random random.Random().random() in run() (unseeded) NondeterminismError, on some replays clock-naive datetime.now() in plan() sandbox clock datetime.now() under sandbox_unrestricted() in plan() NondeterminismError, on every replay after the budget network-naive urllib.request.urlopen() in plan() sandbox network the same under sandbox_unrestricted() NondeterminismError, and the replay paid for a model call The model is `model_server.py` (3.3): it answers "next: continue" once, then "next: finish". """ from __future__ import annotations import asyncio import hashlib import os import random import urllib.parse import urllib.request from dataclasses import dataclass from datetime import datetime, timedelta, timezone from temporalio import activity, workflow BREAK = os.environ.get("LAB03_BREAK", "none") # Set in 3.3 only. Empty => call_llm fakes the model (one step, then finish). MODEL_URL = os.environ.get("LAB03_MODEL_URL", "") DEFAULT_MODEL_URL = "http://127.0.0.1:8765" def ask_model(prompt: str) -> str: """One HTTP GET to the model server. An effect: the answer depends on how often it was asked.""" url = f"{MODEL_URL or DEFAULT_MODEL_URL}/decide?prompt={urllib.parse.quote(prompt)}" with urllib.request.urlopen(url, timeout=5) as resp: return resp.read().decode("utf-8").strip() @dataclass class Step: kind: str # "llm" | "finish" prompt: str = "" @activity.defn async def call_llm(prompt: str, key: str) -> str: """The model call as an Activity. Its result is recorded in ActivityTaskCompleted, so on replay the Workflow gets the same answer without the model being asked again.""" activity.logger.info("call_llm key=%s", key) if MODEL_URL: return ask_model(prompt) return f"[stub model answer to: {prompt[:40]}] next: finish" @workflow.defn class AgentRun: def __init__(self) -> None: self.step = 0 self.status = "running" self.goal = "" self.context_summary = "" self.started_at: datetime | None = None self.budget_seconds = 0 self.max_steps = 0 def plan(self) -> Step: """Workflow code: a pure function of state. It never calls a model — except in the versions below that are here to be broken.""" # --- the time budget: has the run been going for too long? ------------------------- if BREAK == "clock-naive": now = datetime.now(timezone.utc) # sandbox stops this elif BREAK == "clock": with workflow.unsafe.sandbox_unrestricted(): now = datetime.now(timezone.utc) # wall clock: different on replay else: now = workflow.now() # history time: same on replay if now - self.started_at > timedelta(seconds=self.budget_seconds): return Step("finish") if self.step >= self.max_steps: return Step("finish") # --- the next action: ask the model, or decide from the recorded answer? ----------- prompt = f"Step {self.step} for: {self.goal}. Context: {self.context_summary[-60:]}" if BREAK == "network-naive": decision = ask_model(prompt) # sandbox stops this return Step("llm", prompt) if "continue" in decision else Step("finish") if BREAK == "network": with workflow.unsafe.sandbox_unrestricted(): decision = ask_model(prompt) # a different answer on replay return Step("llm", prompt) if "continue" in decision else Step("finish") if "next: finish" in self.context_summary: # the recorded answer decides return Step("finish") return Step("llm", prompt) @workflow.run async def run(self, goal: str, sleep_seconds: int = 15, budget_seconds: int = 5, max_steps: int = 5) -> str: self.goal = goal self.budget_seconds = budget_seconds self.max_steps = max_steps self.started_at = workflow.now() while True: step = self.plan() workflow.logger.info("live: step %d plan -> %s", self.step, step.kind) if step.kind == "finish": self.status = "done" return self.context_summary # Spread load: sometimes wait a second before calling the model. if BREAK == "random-naive": jitter = random.random() < 0.5 # sandbox stops this elif BREAK == "random": jitter = random.Random().random() < 0.5 # unseeded: a fresh coin on every replay else: jitter = workflow.random().random() < 0.5 # seeded from history: the same coin if jitter: await asyncio.sleep(1) key = f"{workflow.info().workflow_id}:{self.step}:{hashlib.sha256(step.prompt.encode()).hexdigest()[:12]}" self.context_summary = await workflow.execute_activity( call_llm, args=[step.prompt, key], start_to_close_timeout=timedelta(seconds=10) ) self.step += 1 await asyncio.sleep(sleep_seconds)
Lab source: labs/03-determinism in the course repository. Verified against temporal server start-dev with the Python SDK 1.32 on 2026-09-11.