COURSE LAB

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 await that emitted the mismatched Command. Then confirm the corrected version replays.
  • Terminal route: From this lab directory, run source ../.venv/bin/activate before copying any python ... 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

  1. The naive version first. LAB03_BREAK=random-naive python worker.py, then python starter.py --id coinflip-naive --no-wait. The run never gets past its first Workflow Task. python failures.py coinflip-naive shows:

      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 ...
    
    That is the sandbox, not replay: RestrictedWorkflowAccessError before there is any history to disagree with. Note what the Service did with it — a Workflow Task failure, retried with backoff, the execution still RUNNING. 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 on lab-03 would adopt it and run it.

  2. Get past the sandbox. LAB03_BREAK=random uses random.Random().random() — constructing a Random is 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).

  3. 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:

    replay 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). ...
    
    Read it: the code (this replay's coin said "no jitter") proposed ScheduleActivityTask; at position 5 history has TimerStarted (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; the await that produced the Command is await asyncio.sleep(1) or execute_activity(call_llm), one line apart.

  4. 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.

  5. The kill route, to see what the Service does when a real Worker hits this: python starter.py --id coinflip-2 --no-wait, kill -9 the Worker during the timer, start it again (still LAB03_BREAK=random). The Workflow Task after TimerFired replays from event 1 with a new coin. Half the time the coin agrees and the run completes with no trace; half the time failures.py coinflip-2 shows WORKFLOW_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.
  6. Fix: workflow.random().random() — a random.Random seeded from history, so every replay draws the original coin. That is LAB03_BREAK=none. python replay.py coinflip-1 --times 8 with 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).

  1. LAB03_BREAK=clock-naive (datetime.now() in plan()): the same sandbox failure — Cannot access datetime.datetime.now.__call__ from inside a workflow ... at event 4, retried forever. Stop it and python terminate.py clock-naive.
  2. LAB03_BREAK=clock reads the wall clock under workflow.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, so plan() schedules call_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 before call_llm. Every build flips it, seeded with workflow.random(), so about half your runs have it.)
  3. 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:

    replay 1: NondeterminismError
      [TMPRL1100] Nondeterminism error: Complete workflow machine does not handle this event: HistoryEvent(id: 5, ActivityTaskScheduled)
    
    On replay plan() at step 0 sees a run that is already minutes old, returns finish, and the code proposes CompleteWorkflowExecution; history has call_llm being scheduled at 5 — or, if the jitter coin came up, TimerStarted at 5, and the message names that Event instead.

  4. Now the kill route, and watch a production incident in miniature. python starter.py --id clock-2 --no-wait; kill -9 the Worker during the timer; start it again with LAB03_BREAK=clock. It never recovers: python failures.py clock-2 shows one WORKFLOW_TASK_FAILED cause=NON_DETERMINISTIC_ERROR with the message above, followed by a WORKFLOW_TASK_SCHEDULED whose attempt= keeps climbing (we watched it reach 4) while the status stays RUNNING. 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.

  5. The fix is a redeploy: stop the broken Worker, start python worker.py (no LAB03_BREAK). Within 10–30 s (the Workflow Task retry backoff) the run completes — failures.py clock-2 now ends in WORKFLOW_EXECUTION_COMPLETED, and the retried WORKFLOW_TASK_SCHEDULED reads the attempt that finally succeeded (5, in our run). The fixed plan() uses workflow.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-2 confirms 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.

  1. LAB03_BREAK=network-naive (urllib.request.urlopen() in plan()): the sandbox again — Cannot access urllib.request.urlopen.__call__ from inside a workflow .... Stop it and python terminate.py net-naive — left running, the next build would adopt it and spend the model's one next: continue on it.
  2. python model_server.py --reset, then LAB03_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'), schedules call_llm, waits 15 s, asks again at step 1 (model call #2 -> 'next: finish') and finishes. Two model calls, two lines in terminal 3.
  3. LAB03_BREAK=network python replay.py net-1. Terminal 3 prints model call #3: 'Step 0 for: …' -> 'next: finish' — the replay paid for a model call the original run already made — and the answer is different, so plan() at step 0 now says finish:

    [TMPRL1100] Nondeterminism error: Complete workflow machine does not handle this event: HistoryEvent(id: 5, TimerStarted)
    
    (TimerStarted if the run's jitter coin came up, ActivityTaskScheduled if not — either way the code proposed CompleteWorkflowExecution where history has a Command from an llm step.) 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 like clock-2: on the kill route (python model_server.py --reset, python starter.py --id net-2 --no-wait --budget 300, kill -9 during the timer, restart with LAB03_BREAK=network) failures.py net-2 shows WORKFLOW_TASK_FAILED cause=NON_DETERMINISTIC_ERROR and terminal 3 prints a new model call for every retry of the Workflow Task — a stuck run that keeps billing. Start LAB03_MODEL_URL=http://127.0.0.1:8765 python worker.py (the fixed build) and it completes: the recorded call_llm answer already says next: finish, so the fixed plan() makes the decisions history holds.

  4. Move the read into the Activity. LAB03_MODEL_URL=http://127.0.0.1:8765 python worker.py (no LAB03_BREAK): call_llm asks the model, and plan() decides from self.context_summary — the recorded answer. python model_server.py --reset, then python starter.py --id net-fixed --budget 300 --sleep 5 runs two steps: call_llm twice (next: continue, then next: finish), then finish. python replay.py net-fixed --times 3: three clean replays and terminal 3 prints nothing. Both answers are in history as ActivityTaskCompleted results; the replay hands them to plan(), which makes the same two decisions.

  5. 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

  1. At which recorded Event does the broken clock build first disagree with its history?
  2. Why can a network read in plan() create an extra model call during replay?
  3. Which value should be recorded as an Activity result, and which decision stays in Workflow code?
  4. 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.