COURSE LAB

Lab 2: Event History and Replay

Objective

Why: Learn to read the event history as the source of a Workflow’s progress.

By the end: Predict the next event, hand a run to another Worker, and compare a manual replay with the SDK’s replay.

Before you run

  • Mechanism: Workflow code emits Commands. The Service turns them into Events; replay feeds recorded Events back to the code until it reaches new work.
  • Predict: After the handoff, predict whether the second Worker starts the call_llm Activity again or reconstructs its result from history.
  • Look for: Match each Command to its Event, compare Worker identities, and check CALL_LLM_INVOCATIONS. Keep the three services from Lab 1 running; opening this lab does not start new ports.
  • 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.

The program is unchanged from lab 1 (plan() → call_llm → 20 s timer → finish). Two instruments were added to workflows.py: a CALL_LLM_INVOCATIONS counter inside the Activity, and workflow.logger lines before every await — the SDK suppresses those during replay, so the first one a Worker prints is the first Command it actually issued. Reading page: "Event History and Replay".

One model underlies every step below. The figure at the top of this lab's page draws it with the Events of 2.3's handoff (in this lab's folder: figure.svg).

Sticky queues first, because every history in this lab shows them. Each WORKFLOW_TASK_SCHEDULED row that history.py prints names the queue its Task was put on, marked [normal] or [sticky]. A Worker that has just finished a Workflow Task keeps that Workflow's state in memory, in its cache. So for the Workflow's next Task the Service normally skips the shared queue lab-02, where any Worker could take it, and uses a sticky queue that only that one Worker polls, named after it (<pid>@<host>-<hex>). That Worker applies the new Events to the state it already holds. The very first Task is always [normal], because no Worker has the Workflow yet. Stickiness is only an optimisation: if the Worker never takes the Task (you killed it, say), the Service stops using its sticky queue for this execution and offers the Task on lab-02, where any Worker can take it. In 2.3 that fallback shows up as WorkflowTaskTimedOut, 10 s after the Task was offered; the Advanced section shows when it does and does not appear.

Then the two ways a Worker handles a Workflow Task:

  • It has the Workflow cached (a sticky Worker, like Worker 1 taking event 8's Task): it applies only the new Events. Nothing is replayed.
  • It does not (a new process like Worker 2 in 2.3, replay.py, or a Worker that has evicted the Workflow from its cache): it runs the Workflow code again from line one, and at each Command the SDK asks what history holds at that position:
  • a matching Event: it takes the recorded result and calls nothing;
  • nothing, because history is exhausted: it issues the Command for real. That is the live: line Worker 2 prints in 2.3;
  • a different Event: a non-determinism error, and the Workflow Task fails (lab 3).
export TASK_QUEUE=lab-02          # every terminal; keeps this lab's Workers off other labs' queues
python worker.py                  # terminal 1
python starter.py                 # terminal 2: starts agent-42, waits, prints the history
python history.py agent-42        # terminal 3: the same history with the attributes you need

Every statement below about what history shows was read off the dev server with SDK 1.32.0.

Swipe to explore diagram →
event Worker 1 (first process) Worker 1 first process Temporal Service Temporal Service Worker 2 (started after the kill) Worker 2 started after the kill ScheduleActivityTask(call_llm)ScheduleActivityTask(call_llm)5 Activity Task: call_llm runs (#1)Activity Task: call_llm runs (#1)6 result → ActivityTaskCompletedresult → ActivityTaskCompleted7 Workflow Task on the sticky queueWorkflow Task on the sticky queue8 cached: applies 7, no replaycached: applies 7, no replay StartTimer(20 s) → TimerStartedStartTimer(20 s) → TimerStarted11 kill -9 Worker 1; the timer keeps runningkill -9 Worker 1; the timer keeps running TimerFiredTimerFired12 13 sticky offer · 14 timed out13 sticky offer · 14 timed out14 Workflow Task on lab-02 (shared)Workflow Task on lab-02 (shared)15 no cache: replay from line oneno cache: replay from line one execute_activity matches 5 → 7execute_activity matches 5 → 7 sleep(20) matches 11 → 12sleep(20) matches 11 → 12 CompleteWorkflowExecution (live)CompleteWorkflowExecution (live)18
Lab 2.3's handoff, read off a real history. Worker 1 holds AgentRun in its cache and applies only new Events; Worker 2 has no cache, replays from line one, and its first live Command is the return.

Open this lab in the browser workspace

Complete the one-time setup in Lab 1, then open Lab 02 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: read the real history of lab 1's AgentRun event by event, predict what the Service will append next, hand the execution from one Worker to another and diff what each one saw, then replay a history by hand and check yourself against the SDK.

2.1 Annotate the real history

  1. Run the Worker and the starter. When starter.py returns, run python history.py agent-42. You get one row per Event with the attributes that matter: which queue a Workflow Task was scheduled on ([normal] or [sticky]), which Worker identity started it, the Activity's input and result, the timer's duration.
  2. For every Event write the Command or external cause, in the shape of the reading's table ("Client: start_workflow", "Command ScheduleActivityTask", "Service clock", …). The reading gives the answer for the 16-event shape; do it without looking, then compare.
  3. Check three things against the reading:
  4. Events 8 and 13 are scheduled on a sticky queue named <worker identity>-<hex>; in this 16-event run only event 2 uses the shared queue lab-02, because no Worker had run this Workflow yet. That is sticky execution in the log. (A crashed Worker adds a second lab-02 scheduling; that history is in the Advanced section.)
  5. Every worker= identity is <pid>@<host> — the same process for all of 1–11 in a run with one Worker.
  6. ActivityTaskStarted (6) carries attempt=1 and scheduled_event_id=5. The Worker began running the Activity when the Service dispatched the Task; the Event is written only when the attempt reaches a terminal outcome, so history does not gain a row per retry. While an Activity is in flight and retrying, ActivityTaskScheduled is the only Activity Event you will see (lab 4 watches one retry).
  7. The invocation counter. Worker 1's log shows call_llm ... (invocation #1 in pid …) exactly once. Now run python replay.py agent-42: it replays the same history in-process with the SDK's Replayer and prints CALL_LLM_INVOCATIONS before = 0 … after = 0 and REPLAY CLEAN. The Activity body did not run; its recorded result (event 7) was handed to the code.
  8. Count. python starter.py --id agent-42-steps --steps 3 --sleep 2, then python history.py agent-42-steps | wc -l: 38 events. Four to start (1–4), eleven per step (three for the Activity, three for the Workflow Task that delivers its result, two for the timer, three for the Workflow Task that wakes after it), one to complete. Now compute the step at which an agent making one model call and one tool call per step reaches the 10,240-event warning (as of 2026-09). Its step is two Activities, each followed by the Workflow Task that delivers the result, plus the timer and the Workflow Task after it: 3 + 3 + 3 + 3 + 2 + 3 = 17 Events. The arithmetic matters less than the rule behind it: history grows with orchestration interactions — Activities, timers, signals, Workflow Tasks — not with the lines of Python you execute. A pure loop over a list costs nothing; one tool call per iteration costs about eleven Events. That rule is what makes module 6's Continue-As-New necessary.

2.2 Predict the next three Events

  1. python starter.py --no-wait, then within the 20 s: python history.py agent-42. The last row is 11 TIMER_STARTED fires_after=0:00:20.
  2. Write down Events 12, 13 and 14 — type, and who causes each (the Service clock; the Service; which Worker).
  3. After the timer: python history.py agent-42 again. Observed with the one Worker still running: 12 TIMER_FIRED, 13 WORKFLOW_TASK_SCHEDULED queue=…[sticky], 14 WORKFLOW_TASK_STARTED worker=<the same pid>, then 15 WORKFLOW_TASK_COMPLETED, 16 WORKFLOW_EXECUTION_COMPLETED whose result payload is the same bytes as event 7's result, because run() returns self.context_summary.
  4. Note what 12 and 13 have in common: no Worker identity anywhere. The Service appended both.

2.3 Two-Worker handoff, and the diff

  1. Terminal 1: python worker.py — note the pid. Terminal 2: python starter.py --no-wait.
  2. Within the 20 s, save what Worker 1 saw and crash it: python history.py agent-42 > before.txt, then kill -9 <pid>.
  3. Wait until the Web UI shows WorkflowTaskTimedOut (about 30 s after the kill: the timer fires at 20 s, and the Task offered on dead Worker 1's sticky queue times out 10 s later), then terminal 1: python worker.py — a new pid, so a new identity.
  4. When it completes: python history.py agent-42 > after.txt and diff before.txt after.txt. The diff is only additions after event 11: the prefix is byte-identical, the continuation is appended. Nothing was rewritten — history is append-only.
  5. Read Worker 2's terminal. It printed exactly one live: line — live: returning -> Command CompleteWorkflowExecution — and no call_llm line. It replayed 1–15 silently (the logger is muted while replaying), the awaits on the Activity and the timer resolved from history, and the first thing it did for real was return.

    Say precisely what that means. During Worker 2's replay, which of these ran?

    A. neither await workflow.execute_activity(...) nor the call_llm function
    B. await workflow.execute_activity(...) only
    C. call_llm only
    D. both
    

    B. Worker 2 executed the Workflow line await workflow.execute_activity(call_llm, ...) again — it had to, to rebuild self.context_summary — and the SDK matched the Command that line produced against ActivityTaskScheduled (5), then handed back the result recorded in ActivityTaskCompleted (7). The call_llm body never ran; the counter proves it. "Replay skips the Activity line" is the wrong sentence: replay re-runs the call and reuses the outcome.

2.4 Hand-replay

histories/agent-42.json is a real history, captured with the Worker kill above. Cut it after event 13 — the WorkflowTaskScheduled that follows TimerFired — and play Worker 2:

  1. python history.py --file histories/agent-42.json --first 13. With workflows.py open, walk run() from line one. For each await, name the Command the SDK builds and the Events it matches it against. Write down which await (or return) is the first with no Event to match — and therefore the first Command Worker 2 issues for real.
  2. Check: python replay.py --file histories/agent-42.json --first 13. (Cutting a history is a teaching trick, so you can watch exactly where replay stops. In production you run Replayer over complete histories, to check that new code is still compatible with them — that is lab 8.) The only live: line printed is the answer. --first 11 prints nothing live: the code is blocked on a timer that has not fired in this history, so there is no Command to issue.
  3. Variation: the 38-event three-step history, cut after event 24 (the WorkflowTaskScheduled after the second TimerFired). Which step number does plan() see, and what does it schedule? python history.py agent-42-steps --first 24 --json cut.json, then python replay.py --file cut.json.
  4. One paragraph, replacing your lab 1 paragraph: where was the program counter stored? Use the words Command, Event, Workflow Task and replay, and say which lines of run() ran on Worker 2 and which only appeared to.

Check yourself

PYTHONPATH=.. pytest tests/ runs the same program on Temporal's time-skipping test server and asserts the 16-event shape, the 11-events-per-step arithmetic, and that a replay — full or cut at event 13 — leaves CALL_LLM_INVOCATIONS untouched.

Done when

Answer these without looking anything up. They are the whole of modules 2 and 3.

  1. What is the relationship between a Command and an Event, and who writes each?
  2. What exactly re-executes during replay, and what does not?
  3. Why does call_llm not run again, even though the line that calls it does?
  4. Where does the await get its old return value from?
  5. How does the Worker know replay has caught up with the present?
  6. Why does any of this force Workflow code to be deterministic? (That is lab 3.)

Advanced: sticky execution and Workflow Task failures

Optional, and nothing here changes the model above. Sticky execution is a caching optimization: it decides whether a replay was needed for a given Workflow Task, never whether the outcome is correct. The defaults below are SDK implementation details and may change.

The sticky-to-shared footprint. This is the detail the reading told you to read off the real thing. With no Worker polling when the timer fired, histories/agent-42-sticky-timeout.json (18 events) shows:

12  +20.18s  TIMER_FIRED
13  +20.18s  WORKFLOW_TASK_SCHEDULED   queue=<worker1 identity>-<hex> [sticky]
14  +30.18s  WORKFLOW_TASK_TIMED_OUT   timeout_type=SCHEDULE_TO_START
15  +30.18s  WORKFLOW_TASK_SCHEDULED   queue=lab-02 [normal]
16  +34.62s  WORKFLOW_TASK_STARTED     worker=<worker2 identity>
The Task was offered on the dead Worker's sticky queue, nobody took it, and 10.0 s later it was timed out and re-offered on the shared queue. That 10 s is the Python Worker's sticky_queue_schedule_to_start_timeout default — not the 5 s the reading quotes from upstream; subtract event 13's time from event 14's yourself.

You will not always see the pair. histories/agent-42.json (16 events) is the same experiment with Worker 2 started 2 s after the timer fired: it took the Task 2.5 s after event 13, straight off the sticky-scheduled Task, with no timed-out Event. In a third run where the timer fired only ~6 s after the kill and a new Worker was already polling, the Task still waited out the 10 s and produced the pair. Upstream documents the reschedule, not its footprint.

Experiment note, not documentation: our reading of those three runs is that the Service bypasses a sticky queue whose Worker has been silent long enough and honours it otherwise. It is a hypothesis to test. The Events above are the facts, and neither the hypothesis nor its answer changes anything about replay.

Stalling a Worker inside a Workflow Task. Instead of killing Worker 1, stall it: add a time.sleep(15) under workflow.unsafe.sandbox_unrestricted() before the first await. You will not reach the 10 s Workflow Task Timeout the reading describes: the SDK gets there first. Observed, 2.08 s after WorkflowTaskStarted: WORKFLOW_TASK_FAILED cause=WORKFLOW_WORKER_UNHANDLED_FAILURE message="[TMPRL1101] Potential deadlock detected: workflow didn't yield within 2 second(s)." — the Python SDK's deadlock detector fails the Task itself (the Service then retries it, off-history, forever, since every attempt stalls the same way). Producing WorkflowTaskTimedOut START_TO_CLOSE by hand needs the process to die in the milliseconds between WorkflowTaskStarted and WorkflowTaskCompleted; we did not manage it.

Blocking calls inside Workflow code are lab 3's subject. There they are the determinism rule; here they are a footnote.

What you learned

  • The Event History records commands and results; replay follows it until the Worker reaches new work.
  • A Worker handoff may add Workflow Task timeout and reschedule Events without repeating a completed Activity.
  • Event IDs, task identities, and Activity attempts let you explain exactly what ran before and after a crash.

Questions to answer

  1. Which Event contains the model answer that the second Worker receives during replay?
  2. What history change tells you a timer fired while no Worker was polling?
  3. Why can two Workers run the same Workflow code without making two model calls?
  4. What is the difference between a Workflow Task retry and an Activity retry in the history?

Files

Every file in this lab, as committed. Copy or clone; nothing here is hidden.

02-event-history-and-replay/history.py
"""Print a Workflow's Event History with the facts you annotate in 2.1 — or save it as JSON.

    python history.py agent-42                  # one row per Event, with the attributes that matter
    python history.py agent-42 --json h.json    # the same history as data (Replayer input)
    python history.py agent-42 --first 13       # only Events 1..13 (2.4: cut the history mid-way)
    python history.py --file histories/x.json   # print a saved JSON history instead

The "caused by" column of the reading's table is deliberately NOT printed — writing it is the
exercise. Everything printed is read straight off the Event's attributes.
"""
from __future__ import annotations

import argparse
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, TaskQueueKind, TimeoutType, WorkflowTaskFailedCause  # noqa: E402
from temporalio.api.history.v1 import HistoryEvent  # noqa: E402
from temporalio.client import WorkflowHistory  # noqa: E402


def _payloads(payloads) -> str:
    out = []
    for p in payloads:
        try:
            out.append(p.data.decode("utf-8"))
        except UnicodeDecodeError:
            out.append(f"<{len(p.data)} bytes>")
    return ", ".join(out)


def _queue(tq) -> str:
    kind = TaskQueueKind.Name(tq.kind).removeprefix("TASK_QUEUE_KIND_").lower()
    return f"{tq.name} [{kind}]"


def describe(event: HistoryEvent) -> str:
    """The attributes that let you say what caused the Event."""
    t = event.event_type
    if t == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED:
        a = event.workflow_execution_started_event_attributes
        return (f"type={a.workflow_type.name} input=[{_payloads(a.input.payloads)}] "
                f"task_queue={a.task_queue.name} client={a.identity} attempt={a.attempt}")
    if t == EventType.EVENT_TYPE_WORKFLOW_TASK_SCHEDULED:
        a = event.workflow_task_scheduled_event_attributes
        return f"queue={_queue(a.task_queue)} attempt={a.attempt}"
    if t == EventType.EVENT_TYPE_WORKFLOW_TASK_STARTED:
        return f"worker={event.workflow_task_started_event_attributes.identity}"
    if t == EventType.EVENT_TYPE_WORKFLOW_TASK_COMPLETED:
        return f"worker={event.workflow_task_completed_event_attributes.identity}  (Commands follow)"
    if t == EventType.EVENT_TYPE_WORKFLOW_TASK_TIMED_OUT:
        a = event.workflow_task_timed_out_event_attributes
        return f"timeout_type={TimeoutType.Name(a.timeout_type).removeprefix('TIMEOUT_TYPE_')}"
    if t == EventType.EVENT_TYPE_WORKFLOW_TASK_FAILED:
        a = event.workflow_task_failed_event_attributes
        cause = WorkflowTaskFailedCause.Name(a.cause).removeprefix("WORKFLOW_TASK_FAILED_CAUSE_")
        return f"cause={cause} worker={a.identity} message={a.failure.message!r}"
    if t == EventType.EVENT_TYPE_ACTIVITY_TASK_SCHEDULED:
        a = event.activity_task_scheduled_event_attributes
        return (f"activity={a.activity_type.name} activity_id={a.activity_id} "
                f"queue={a.task_queue.name} input=[{_payloads(a.input.payloads)}]")
    if t == EventType.EVENT_TYPE_ACTIVITY_TASK_STARTED:
        a = event.activity_task_started_event_attributes
        return f"worker={a.identity} attempt={a.attempt} scheduled_event_id={a.scheduled_event_id}"
    if t == EventType.EVENT_TYPE_ACTIVITY_TASK_COMPLETED:
        a = event.activity_task_completed_event_attributes
        return f"result=[{_payloads(a.result.payloads)}] worker={a.identity}"
    if t == EventType.EVENT_TYPE_ACTIVITY_TASK_FAILED:
        a = event.activity_task_failed_event_attributes
        return f"message={a.failure.message!r}"
    if t == EventType.EVENT_TYPE_ACTIVITY_TASK_TIMED_OUT:
        a = event.activity_task_timed_out_event_attributes
        tt = TimeoutType.Name(a.failure.timeout_failure_info.timeout_type).removeprefix("TIMEOUT_TYPE_")
        return f"timeout_type={tt}"
    if t == EventType.EVENT_TYPE_TIMER_STARTED:
        a = event.timer_started_event_attributes
        return f"timer_id={a.timer_id} fires_after={a.start_to_fire_timeout.ToTimedelta()}"
    if t == EventType.EVENT_TYPE_TIMER_FIRED:
        a = event.timer_fired_event_attributes
        return f"timer_id={a.timer_id} started_event_id={a.started_event_id}"
    if t == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED:
        a = event.workflow_execution_completed_event_attributes
        return f"result=[{_payloads(a.result.payloads)}]"
    if t == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_FAILED:
        return f"message={event.workflow_execution_failed_event_attributes.failure.message!r}"
    return ""


def print_history(history: WorkflowHistory, first: int | None = None) -> None:
    t0 = None
    for event in history.events:
        if first is not None and event.event_id > first:
            break
        ts = event.event_time.ToDatetime()
        t0 = t0 or ts
        name = EventType.Name(event.event_type).removeprefix("EVENT_TYPE_")
        print(f"{event.event_id:3d}  +{(ts - t0).total_seconds():6.2f}s  {name:30s} {describe(event)}")


async def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("workflow_id", nargs="?", default=os.environ.get("WORKFLOW_ID", "agent-42"))
    ap.add_argument("--run-id", default=None)
    ap.add_argument("--file", help="print a saved JSON history instead of fetching one")
    ap.add_argument("--json", metavar="OUT", help="also write the history as JSON to OUT")
    ap.add_argument("--first", type=int, help="show (and save) only Events 1..N")
    args = ap.parse_args()

    if args.file:
        with open(args.file) as f:
            history = WorkflowHistory.from_json(args.workflow_id, f.read())
    else:
        client = await connect()
        history = await client.get_workflow_handle(args.workflow_id, run_id=args.run_id).fetch_history()

    print_history(history, args.first)
    if args.json:
        events = history.events if args.first is None else history.events[: args.first]
        with open(args.json, "w") as f:
            f.write(WorkflowHistory(workflow_id=history.workflow_id, events=events).to_json())
        print(f"wrote {len(events)} events to {args.json}")


if __name__ == "__main__":
    asyncio.run(main())
02-event-history-and-replay/replay.py
"""Replay AgentRun's code against a history, exactly as a fresh Worker would — locally,
without connecting a Worker to the Workflow's Task Queue and without running any Activity.

    python replay.py agent-42                          # fetch the live history and replay it
    python replay.py --file histories/agent-42.json    # replay a saved history
    python replay.py --file histories/agent-42.json --first 13   # 2.4: cut it, see what goes live

What to watch: the Workflow's "live:" log lines. While the SDK is replaying they are suppressed;
the first one printed is the first Command the code would issue for real. And
CALL_LLM_INVOCATIONS: the Activity function is never called during a replay.
"""
from __future__ import annotations

import argparse
import asyncio
import logging
import os
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.client import WorkflowHistory  # noqa: E402
from temporalio.worker import Replayer  # noqa: E402

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
logging.getLogger("temporalio.worker").setLevel(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("--file")
    ap.add_argument("--first", type=int, help="replay only Events 1..N (cut at a WorkflowTaskScheduled)")
    args = ap.parse_args()

    if args.file:
        with open(args.file) as f:
            history = WorkflowHistory.from_json(args.workflow_id, f.read())
    else:
        client = await connect()
        history = await client.get_workflow_handle(args.workflow_id).fetch_history()
    if args.first:
        history = WorkflowHistory(workflow_id=history.workflow_id, events=history.events[: args.first])

    # A saved file carries no Workflow ID, so name the file rather than a default ID it may not be.
    source = args.file if args.file else history.workflow_id
    print(f"replaying {len(history.events)} events of {source}; "
          f"CALL_LLM_INVOCATIONS before = {workflows.CALL_LLM_INVOCATIONS}")
    try:
        await Replayer(workflows=[workflows.AgentRun]).replay_workflow(history)
        print("REPLAY CLEAN: every Command the code issued matched the Event history")
    except Exception as e:  # temporalio.workflow.NondeterminismError, in lab 3
        print(f"REPLAY FAILED: {type(e).__name__}: {e}")
    print(f"CALL_LLM_INVOCATIONS after = {workflows.CALL_LLM_INVOCATIONS}")


if __name__ == "__main__":
    asyncio.run(main())
02-event-history-and-replay/starter.py
"""Start AgentRun with a fixed Workflow ID and wait for it.

    python starter.py                       # agent-42, one step, 20 s timer
    python starter.py --steps 3 --sleep 2   # the counting exercise: three steps, short timers
    python starter.py --no-wait             # return as soon as it is started
"""
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=20, help="timer length in seconds (kill the Worker inside it)")
    ap.add_argument("--steps", type=int, default=1, help="how many call_llm steps before finish")
    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.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())
02-event-history-and-replay/tests/test_lab.py
"""Check yourself: the history shape you annotated in 2.1, and that replay never re-runs call_llm.

Runs against Temporal's time-skipping test server (downloaded on first use), so the 20 s timer
costs nothing. Plain pytest; no plugin needed:  PYTHONPATH=.. pytest tests/
"""
from __future__ import annotations

import asyncio
import os
import sys

import pytest

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, ".."))          # workflows
sys.path.insert(0, os.path.join(HERE, "..", ".."))    # common

import workflows  # noqa: E402
from temporalio.api.enums.v1 import EventType  # noqa: E402
from temporalio.client import WorkflowHistory  # noqa: E402
from temporalio.testing import WorkflowEnvironment  # noqa: E402
from temporalio.worker import Replayer, Worker  # noqa: E402

TASK_QUEUE = "lab-02-test"

# The reading's table, minus the Worker kill: one Worker, so the last Workflow Task lands on the
# sticky queue and there is no WorkflowTaskTimedOut / reschedule pair.
EXPECTED_ONE_STEP = [
    "WORKFLOW_EXECUTION_STARTED",
    "WORKFLOW_TASK_SCHEDULED", "WORKFLOW_TASK_STARTED", "WORKFLOW_TASK_COMPLETED",
    "ACTIVITY_TASK_SCHEDULED", "ACTIVITY_TASK_STARTED", "ACTIVITY_TASK_COMPLETED",
    "WORKFLOW_TASK_SCHEDULED", "WORKFLOW_TASK_STARTED", "WORKFLOW_TASK_COMPLETED",
    "TIMER_STARTED", "TIMER_FIRED",
    "WORKFLOW_TASK_SCHEDULED", "WORKFLOW_TASK_STARTED", "WORKFLOW_TASK_COMPLETED",
    "WORKFLOW_EXECUTION_COMPLETED",
]


def _types(history: WorkflowHistory) -> list[str]:
    return [EventType.Name(e.event_type).removeprefix("EVENT_TYPE_") for e in history.events]


async def _run(steps: int, wf_id: str) -> 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", 20, steps], id=wf_id, task_queue=TASK_QUEUE
            )
            result = await handle.result()
            assert result.startswith("[model answer to:")
            return await handle.fetch_history()


def test_one_step_history_is_sixteen_events():
    history = asyncio.run(_run(1, "lab02-test-one-step"))
    assert _types(history) == EXPECTED_ONE_STEP


def test_each_step_costs_eleven_events():
    """2.1, last part: an Activity is 3 Events, the Workflow Task that delivers its result 3 more,
    the timer 2, and the Workflow Task that wakes after the timer 3 — 11 per step."""
    history = asyncio.run(_run(3, "lab02-test-three-steps"))
    assert len(history.events) == 4 + 3 * 11 + 1
    assert _types(history).count("ACTIVITY_TASK_SCHEDULED") == 3


def test_replay_does_not_invoke_call_llm():
    """The Activity function ran once, in the Worker. Replaying the history — full, or cut at the
    Workflow Task after TimerFired — issues the same Commands and never calls it again."""
    history = asyncio.run(_run(1, "lab02-test-replay"))
    before = workflows.CALL_LLM_INVOCATIONS

    async def replay(h: WorkflowHistory) -> None:
        await Replayer(workflows=[workflows.AgentRun]).replay_workflow(h)

    asyncio.run(replay(history))
    cut = WorkflowHistory(workflow_id=history.workflow_id, events=history.events[:13])
    assert _types(cut)[-1] == "WORKFLOW_TASK_SCHEDULED"
    asyncio.run(replay(cut))
    assert workflows.CALL_LLM_INVOCATIONS == before


if __name__ == "__main__":
    sys.exit(pytest.main([__file__, "-v"]))
02-event-history-and-replay/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 AgentRun, call_llm  # noqa: E402

# Show the Workflow's "live:" lines and the Activity's invocation counter.
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")


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()} identity={client.identity} polling {TASK_QUEUE!r}; "
        f"kill -9 {os.getpid()} to crash it",
        flush=True,
    )
    await worker.run()


if __name__ == "__main__":
    asyncio.run(main())
02-event-history-and-replay/workflows.py
"""Lab 2: AgentRun, stage 2 — the same program as lab 1, read through its Event History.

The code is lab 1's; the two additions are instruments, not behaviour:
  * `CALL_LLM_INVOCATIONS` counts how often the Activity function really ran in *this* process,
    so you can see that a replaying Worker never calls it.
  * `workflow.logger` lines before every await. Log lines are suppressed while the SDK is
    replaying and printed only once the code is "live" — so the first line a fresh Worker prints
    names the first Command it actually issued.
`max_steps` exists for the counting exercise (2.1, last part): every step costs one Activity
(three Events) plus the Workflow Task that delivers its result (three more).
"""
from __future__ import annotations

import asyncio
import hashlib
import os
from dataclasses import dataclass
from datetime import timedelta

from temporalio import activity, workflow


# Lives in the Worker process, outside the Workflow sandbox: a plain global.
CALL_LLM_INVOCATIONS = 0


@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. Stage 2 still fakes the model; what matters here is that
    the function body runs once per attempt and never during replay."""
    global CALL_LLM_INVOCATIONS
    CALL_LLM_INVOCATIONS += 1
    activity.logger.info(
        "call_llm key=%s  (invocation #%d in pid %d)", key, CALL_LLM_INVOCATIONS, os.getpid()
    )
    return f"[model answer to: {prompt[:40]}]"


@workflow.defn
class AgentRun:
    def __init__(self) -> None:
        self.step = 0
        self.status = "running"
        self.goal = ""
        self.context_summary = ""

    def plan(self, max_steps: int) -> Step:
        """Workflow code: a pure function of state. It never calls a model."""
        if self.step < max_steps:
            return Step("llm", prompt=f"Step {self.step} of a plan for: {self.goal}")
        return Step("finish")

    @workflow.run
    async def run(self, goal: str, sleep_seconds: int = 20, max_steps: int = 1) -> str:
        self.goal = goal
        while True:
            step = self.plan(max_steps)
            if step.kind == "finish":
                self.status = "done"
                workflow.logger.info("live: returning -> Command CompleteWorkflowExecution")
                return self.context_summary
            key = f"{workflow.info().workflow_id}:{self.step}:{hashlib.sha256(step.prompt.encode()).hexdigest()[:12]}"
            workflow.logger.info("live: step %d -> Command ScheduleActivityTask(call_llm)", self.step)
            answer = await workflow.execute_activity(
                call_llm, args=[step.prompt, key], start_to_close_timeout=timedelta(seconds=10)
            )
            self.context_summary = answer
            self.step += 1
            workflow.logger.info("live: step %d -> Command StartTimer(%ds)", self.step, sleep_seconds)
            await asyncio.sleep(sleep_seconds)

Lab source: labs/02-event-history-and-replay in the course repository. Verified against temporal server start-dev with the Python SDK 1.32 on 2026-09-11.