COURSE LAB

Lab 10: Operating Temporal in production

Objective

Why: Operate the agent as separate Workflow, CPU, and GPU lanes.

By the end: Register search attributes, route work to Task Queues, and diagnose a stalled lane from the UI and client.

Before you run

  • Mechanism: Task Queues route work to Worker pools; they are not the Event History. Search Attributes help operators find runs, while pending Tasks and timeouts reveal a stalled lane.
  • Predict: If the GPU pool disappears, predict what stays durable and where waiting work becomes visible.
  • Look for: Check the queue backlog and pollers, schedule_to_start_timeout, and the run’s Search Attributes before and after the pool returns.
  • 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.

Nothing in the agent loop changes. What changes is one keyword argument per Activity call (task_queue=), one timeout you had probably been leaving unset (schedule_to_start_timeout), and three Search Attributes the run publishes about itself.

File What it is
lanes.py the three Task Queue names, spelled once; LANE_FOR_TOOL; LAB_PREFIX
workflows.py AgentRun stage 10: plan() → call_llm → search (cpu) → embed (gpu) → finish; pause/resume, status; _publish() upserts AgentStatus/CurrentStep
worker_workflows.py the agent-workflows pool: Workflow types only
worker_cpu.py the cpu-tools pool: call_llm, execute_tool, evaluate
worker_gpu.py the gpu-tools pool: execute_tool for GPU tools only, one slot (GPU_SLOTS)
starter.py start one run: --owner, --tier enterprise|team|batch, --id, --no-wait
setup_search_attributes.py registers AgentStatus (Keyword), CurrentStep (Int), Owner (Keyword) through the Operator Service
five_questions.py the five questions from the Python client: history, describe, list, lane, five
priority_race.py 10.3: build a GPU backlog, add one single-slot GPU Worker, print the dispatch order
tests/ self-check on the SDK's time-skipping test server: python -m pytest tests/
Swipe to explore diagram →
AgentRun routes Activities to Worker pools through Task Queues Workflow code selects a Task Queue for each Activity. The Temporal Service holds Tasks in premium-models, cpu-tools, gpu-tools, or browser-tools. A matching Worker pool polls each queue. Without pollers, the Task waits and backlog age rises. AgentRun chooses Task Queue (Service) Worker pool polls call_llm search / parse embed / vision open_page API key pool CPU pool GPU pool Chromium pool premium-models cpu-tools gpu-tools browser-tools No poller? The Task waits in its lane; backlog age rises.
Workflow code chooses the Task Queue for each Activity; a matching Worker pool polls that lane.

Open this lab in the browser workspace

Complete the one-time setup in Lab 1, then open Lab 10 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: take the AgentRun you have (stage 9) off its single agent-runs queue, run its three kinds of work on three lanes with three Worker pools, stall one pool and read the stall from the outside, then answer the operator's five questions about a run you did not start — from the UI and from the Python client. Reading page: "Operating Temporal in Production"; long form in appendices A1 and A3.

Setup

docker compose up -d && cd 10-operating-temporal-in-production      # from the temporal-course-labs folder
export LAB_PREFIX=lab10-        # only on a shared dev server: queues become lab10-agent-workflows …, IDs lab10-agent-42
python setup_search_attributes.py

setup_search_attributes.py is the registration step the reading describes as temporal operator search-attribute create --name AgentStatus --type Keyword, done from Python with client.operator_service.add_search_attributes(AddSearchAttributesRequest(namespace=..., search_attributes={...})) against the running server — no restart. Observed:

created AgentStatus  KEYWORD
created CurrentStep  INT
created Owner        KEYWORD
namespace default custom attributes: ['AgentStatus', 'CurrentStep', 'Owner']

Run it again and it prints exists for each. Skip it and the first _publish() fails every Workflow Task with search attribute AgentStatus is not defined — the run is stuck, not failed, until someone registers the key. The equivalent at server start, if you own the server, is temporal server start-dev --search-attribute AgentStatus=Keyword --search-attribute CurrentStep=Int --search-attribute Owner=Keyword (verified on a throwaway container in 10.3 below).

The temporal CLI is inside the compose container; there is no binary on the host:

alias temporal='docker compose exec -T temporal temporal'   # run from anywhere inside temporal-course-labs
temporal --version      # temporal version 1.8.3 (Server 1.31.2, UI 2.50.1)  ← the server these notes were taken on

10.1 Three lanes, three pools; stall one

Three terminals, one pool each — a pool is a process that polls one lane and registers exactly the code that lane needs:

python worker_workflows.py   # workflow worker pid=… polling 'lab10-agent-workflows' (Workflow Tasks only)
python worker_cpu.py         # cpu worker pid=… polling 'lab10-cpu-tools': call_llm, execute_tool, evaluate
python worker_gpu.py         # gpu worker pid=… polling 'lab10-gpu-tools' with 1 slot(s): execute_tool (GPU tools only)

Fourth terminal: python starter.py. The result names the lane that answered each step, and the history shows every ACTIVITY_TASK_SCHEDULED carrying its lane (python five_questions.py history lab10-agent-42):

result: {'lanes_used': ['lab10-cpu-tools', 'lab10-cpu-tools', 'lab10-gpu-tools'], 'steps': 3, …}
   5  UPSERT_WORKFLOW_SEARCH_ATTRIBUTES    AgentStatus, CurrentStep
   6  ACTIVITY_TASK_SCHEDULED              call_llm on lab10-cpu-tools
   7  ACTIVITY_TASK_STARTED                call_llm attempt=1 by cpu-3778641@lab10-cpu-tools
  13  ACTIVITY_TASK_SCHEDULED              execute_tool on lab10-cpu-tools
  26  ACTIVITY_TASK_SCHEDULED              execute_tool on lab10-gpu-tools priority_key=5

The Workflow Tasks all ran on wf-<pid>@lab10-agent-workflows; the Workflow never learned a host, only a lane. (worker_gpu.py registers its execute_tool under the same Activity name — if a CPU-only tool ever lands on the GPU lane it fails non-retryably with BadArguments instead of quietly running on the wrong hardware. Registering the same name on two lanes is the whole trick.)

Now stall the GPU pool. Stop worker_gpu.py (Ctrl-C is fine here) and start a run with a short Schedule-to-Start on the GPU step. This timeout is an application failure boundary for the experiment, not an autoscaling trigger. Normally monitor backlog age and Schedule-to-Start latency to decide when to add Workers:

python starter.py --id agent-stall --no-wait --gpu-schedule-to-start 15     # course value: 5 minutes
temporal task-queue describe --task-queue lab10-gpu-tools --task-queue-type activity

Observed while the Task sat in the lane:

Task Queue Statistics:
    BuildID    TaskQueueType  ApproximateBacklogCount  ApproximateBacklogAge  BacklogIncreaseRate  TasksAddRate  TasksDispatchRate
  UNVERSIONED  activity                             1  5.80730335s                     0.17219743    0.17219743                  0
Pollers:
    BuildID    TaskQueueType           Identity            LastAccessTime  RatePerSecond
  UNVERSIONED  activity       gpu-3777831@lab10-gpu-tools  39 seconds ago         100000

Backlog 1, age rising, dispatch rate 0. The pollers list still names the Worker you just killed — the list is who polled in the last five minutes, and a live Worker polls at least once a minute, so LastAccessTime over ~90 s means "gone", not "busy". python five_questions.py lane lab10-gpu-tools prints the same numbers from the SDK's DescribeTaskQueue (backlog, age, add and dispatch rates) and flags stale pollers.

Fifteen seconds later the run is Failed. The history (python five_questions.py history lab10-agent-stall):

  26  ACTIVITY_TASK_SCHEDULED              execute_tool on lab10-gpu-tools priority_key=5
  27  ACTIVITY_TASK_TIMED_OUT              activity ScheduleToStart timeout retry_state=3
  31  UPSERT_WORKFLOW_SEARCH_ATTRIBUTES    AgentStatus, CurrentStep
  32  WORKFLOW_EXECUTION_FAILED            lane stalled: no Worker took embed within SCHEDULE_TO_START

Two things to notice. There is no ACTIVITY_TASK_STARTED between 26 and 27 — nothing ran, so nothing can have had a side effect; that is what the timeout type tells an operator. And the RetryPolicy on that call says maximum_attempts=3, yet there is one timeout event and no second attempt: retry_state=3 is RETRY_STATE_TIMEOUT — a Schedule-to-Start timeout is not retried (retrying a Task nobody will pick up changes nothing). The Workflow catches the ActivityError, checks cause.type == TimeoutType.SCHEDULE_TO_START, publishes AgentStatus = stalled and fails with a named ApplicationError("LaneStalled") — so the fleet query in 10.2 can find every run that died this way.

Bring the pool back before the deadline. Same thing with the course-like value, and start the GPU Worker while the Task is queued:

python starter.py --id agent-drain --no-wait                     # the Worker's default: 60 s
temporal task-queue describe --task-queue lab10-gpu-tools --task-queue-type activity   # backlog 1–2, age climbing
python worker_gpu.py                                              # in the GPU terminal
temporal task-queue describe --task-queue lab10-gpu-tools --task-queue-type activity   # a few seconds later

Observed, before and after the Worker appeared:

  UNVERSIONED  activity   2  30.815259925s   0.06490294   0.06490294   0
  UNVERSIONED  activity   0  0s             -0.0037108138 0.029458467  0.03316928
  poller gpu-3779448@lab10-gpu-tools  5 seconds ago

The run completed with lanes_used = [cpu, cpu, gpu]. The backlog said 2 with only one live run queued: the already-timed-out Task from agent-stall was still counted — ApproximateBacklogCount is approximate, and a dead Task leaves the backlog when a poller pulls and discards it.

Write down: which lanes kept moving while gpu-tools was empty (both — call_llm and search completed; only the embed step waited), and which single fact told you the pool was gone before any run failed (backlog age climbing with dispatch rate 0 and no fresh poller).

10.2 The five questions, from the UI and from Python

Start a few runs so there is a fleet, and pause one right after starting it:

python starter.py --id agent-44 --owner acct_7f3a --tier team --no-wait
temporal workflow signal -w lab10-agent-44 --name pause        # or: handle.signal(AgentRun.pause)

Observed a few seconds later — the Query, the Describe and the fleet query agree, and the run is RUNNING as far as Temporal is concerned while AgentStatus says paused (a paused Workflow consumes no Worker compute while waiting; its Workflow Task completed and nothing is polling for it):

query:       {'paused': True, 'status': 'paused', 'step': 2, 'lanes_used': ['lab10-cpu-tools', 'lab10-cpu-tools'], …}
describe SA: {'AgentStatus': 'paused', 'CurrentStep': 2, 'Owner': 'acct_7f3a'}
list_workflows("WorkflowType = 'AgentRun' AND AgentStatus = 'paused' AND Owner = 'acct_7f3a'"):
  lab10-agent-44   RUNNING   AgentStatus=paused CurrentStep=2 Owner=acct_7f3a

temporal workflow signal -w lab10-agent-44 --name resume and it finishes (steps 3). One bug this lab shipped and fixed on the way: the loop republished running after the in-flight step completed, overwriting the paused the Signal handler had just published — paused: True, status: running, and the fleet query found nothing. The loop now republishes the handler's status. The Search Attribute is only as truthful as the last upsert.

Find, then read. Visibility finds; History and Describe read. In the UI filter box, and in python five_questions.py list "<filter>", and in temporal workflow list --query "<filter>" — the same string:

WorkflowType = 'AgentRun' AND AgentStatus = 'stalled'
Owner = 'acct_7f3a' AND WorkflowId STARTS_WITH 'lab10-'
WorkflowType = 'AgentRun' AND AgentStatus = 'paused' AND Owner = 'acct_7f3a'

Observed:

list_workflows("WorkflowType = 'AgentRun' AND AgentStatus = 'stalled'"):
  lab10-agent-stall                FAILED     AgentStatus=stalled CurrentStep=2 Owner=acct_7f3a
list_workflows("Owner = 'acct_7f3a' AND WorkflowId STARTS_WITH 'lab10-'"):
  lab10-agent-44                   COMPLETED  AgentStatus=done CurrentStep=3 Owner=acct_7f3a
  lab10-agent-drain                COMPLETED  AgentStatus=done CurrentStep=3 Owner=acct_7f3a
  lab10-agent-stall                FAILED     AgentStatus=stalled CurrentStep=2 Owner=acct_7f3a
  lab10-agent-42                   COMPLETED  AgentStatus=done CurrentStep=3 Owner=acct_7f3a

Owner was set by the starter on the first event (start_workflow(search_attributes=TypedSearchAttributes([SearchAttributePair(OWNER, …)]))); AgentStatus/CurrentStep are upserted by _publish() and each upsert is an UPSERT_WORKFLOW_SEARCH_ATTRIBUTES event in History — it replays like any other Command. The Workflow's status Query stays the authoritative answer for one run; the Search Attribute is the eventually-consistent fleet view of the same field.

Then the five questions, on a run you did not start. Pick one from the list; run python five_questions.py five <workflow-id> and, in parallel, open the same run in the UI. Name the surface that answered each:

# Question Python UI / CLI
1 What happened? handle.fetch_history() — every event, in order Event History / temporal workflow show -w <id>
2 What did Temporal think happened? handle.describe() — status, typed_search_attributes, raw_description.pending_activities (state, attempt, last failure, last Worker identity) summary + Pending Activities / temporal workflow describe -w <id>
3 What side effects may have occurred? history and pending attempt count show what Temporal observed; heartbeat details show what the Activity reported. Check the provider or operation ledger to establish whether an external effect occurred Activity events; Pending Activities → heartbeat details; external ledger
4 What happens next? pending_activities[].scheduled_time (next attempt), pending timers, pending children Pending Activities / Timers
5 What if every Worker disappears now? workflow_service.describe_task_queue(...) per lane: backlog count, age, pollers Task Queues page / temporal task-queue describe --task-queue <lane> --task-queue-type activity

For lab10-agent-stall the script answered: 32 events; status=FAILED, no pending Activities, AgentStatus=stalled CurrentStep=2; 0 attempts started without a terminal event (so the GPU tool never ran anywhere — the third question's answer for this run is "none"); nothing next; and for each lane the pollers with their last-poll age. The fifth question is the only one Visibility helps with, and only its "how many, which ones" half; the first four come from History and Describe, per execution, strongly consistent.

10.3 Priority on the shared GPU lane — what this server actually does

Every GPU call in workflows.py carries a priority derived from the run's tier:

priority=Priority(priority_key=PRIORITY_FOR_TIER[self.tier],       # enterprise 1, team 3, batch 5
                  fairness_key=self.owner or None,                   # one virtual queue per tenant
                  fairness_weight=FAIRNESS_WEIGHT_FOR_OWNER.get(     # one weight per key, fixed
                      self.owner, DEFAULT_FAIRNESS_WEIGHT))

It is recorded on the ACTIVITY_TASK_SCHEDULED event (priority_key=5 above), which is how you check it landed. priority_race.py runs the experiment end to end: it hosts the workflow and CPU Workers in-process, starts N batch runs one at a time, each confirmed sitting in the GPU backlog before the next starts, then one enterprise run, prints the backlog, then starts one GPU Worker with one slot and reads the ACTIVITY_TASK_STARTED order off the histories.

Stop the GPU pool from 10.1 first (Ctrl-C worker_gpu.py). With a GPU Worker polling, every Task is taken as it arrives, no backlog forms, and the order is plain FIFO; the script checks and refuses to start until that Worker's last poll is ~90 s old.

python priority_race.py --mode priority --batch 4      # 4 × priority 5 queued first, then 1 × priority 1
python priority_race.py --mode fairness --batch 3      # tenants a and b, same priority, enqueued a a a b b b

The weight belongs to the tenant, not to the tier. Upstream: "There should only be one fairness weight assigned to each fairness key within a Task Queue. Having multiple fairness weights on a fairness key will result in unspecific behavior." The tier is already expressed once, as the priority key; deriving the weight from it too would put two weights on one key as soon as a tenant runs at two tiers.

Prediction from the upstream rules (as of 2026-09): priority is strict across tiers, so the enterprise Task starts first although it was queued last. Fairness is not round-robin — upstream describes it as sequencing "probabilistically using a weighted distribution", with keys dispatched in proportion to their weights. So with equal weights and enough queued work, predict that neither tenant starves and their shares converge toward equality; do not predict a particular six-Task order. What was observed, on temporalio/temporal:1.8.3 (server 1.31.2, UI 2.50.1), SDK 1.32.0:

Server configuration priority mode (enqueued b1 b2 b3 b4 ent) fairness mode (enqueued a1 a2 a3 b1 b2 b3)
the lab's compose server, defaults b2 b3 b1 **ent** b4 — neither FIFO nor priority b2 a1 a3 b1 a2 b3 — neither
--dynamic-config-value matching.numTaskqueueReadPartitions=1 and …WritePartitions=1 **ent** b1 b2 b3 b4 — priority honoured, FIFO within the tier a1 a2 a3 b1 b2 b3 — strict FIFO, no fairness
partitions=1 and --dynamic-config-value matching.enableFairness=true **ent** b1 b2 b3 b4 a1 b1 a2 b2 a3 b3 — round-robin across tenants

So on this server: Priority needs no flag — it is on by default, exactly as upstream says — but the dev server's default of four Task Queue partitions spreads a five-Task backlog across partitions, and a single poller drains them in partition order, so a small experiment shows priority only within a partition (in the default run, ent did start before b4, which shared its partition). That is the upstream limitation stated literally: both controls are enforced within one partition. Fairness needs matching.enableFairness=true; without it dispatch within a tier is FIFO. The last two rows were taken on throwaway containers started as

docker run -d --rm --name lab10-probe -p 7234:7233 -p 8234:8233 temporalio/temporal:1.8.3 server start-dev --ip 0.0.0.0 \
  --dynamic-config-value matching.numTaskqueueReadPartitions=1 --dynamic-config-value matching.numTaskqueueWritePartitions=1 \
  --dynamic-config-value matching.enableFairness=true \
  --search-attribute AgentStatus=Keyword --search-attribute CurrentStep=Int --search-attribute Owner=Keyword
TEMPORAL_ADDRESS=localhost:7234 python priority_race.py --mode priority

(the --search-attribute form of registration, verified there: temporal operator search-attribute list showed all three). Neither temporal task-queue config get nor describe reports whether fairness is on — the only way to know is the experiment, which is the point of the exercise. Record your own row: server version, flags, observed order. In production, weights are set without a deploy (temporal task-queue config set --fairness-key-weight enterprise=5.0, upstream, as of 2026-09; not exercised here).

Self-check

python -m pytest tests/      # 4 tests: routing per lane + Search Attributes + priority on the scheduled event;
                             # stalled lane → one Schedule-to-Start timeout, no retry, AgentStatus=stalled;
                             # GPU Worker refuses a CPU tool

Run each lab's tests from that lab's directory, one lab per pytest invocation: the labs are self-contained and reuse flat module names (worker_gpu, conftest), so collecting two labs in one process imports the wrong one (observed: a combined run from labs/ hangs). The tests run on the SDK's time-skipping test server, which also refuses unregistered Search Attributes; the fixture registers them with the same Operator Service call as setup_search_attributes.py. No Docker needed for the tests.

The recurring question

If every Worker process dies right now: every lane's backlog, every pending Activity with its attempt count, every timer and every Search Attribute survive in the Service. Workers returning to any lane drain that lane; a lane with no Workers stalls only the Activities routed to it, and the task-queue describe and Schedule-to-Start latency show the growing stall; the deliberately short Schedule-to-Start timeout turns it into a named Workflow failure for this experiment.

What you learned

  • Workflow, CPU, and GPU Task Queues are separate lanes with separate Worker pools and backlogs.
  • A Schedule-to-Start timeout identifies work that no Worker picked up; Search Attributes make stalled runs findable.
  • Queue age, dispatch rate, and last poll time are stronger operational signals than a stale poller name alone.

Questions to answer

  1. Which Event proves the GPU Activity never started during a lane stall?
  2. Why does adding a CPU Worker not drain a GPU backlog?
  3. What can the five-question operator view tell you without opening Worker logs?
  4. How would you distinguish a slow GPU Activity from a GPU lane with no live Worker?

Files

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

10-operating-temporal-in-production/five_questions.py
"""The operator's five questions, from the Python client (lab 10.2).

    python five_questions.py five <workflow-id>      # all five, naming the surface that answered each
    python five_questions.py history <workflow-id>   # Q1  what happened            (fetch_history)
    python five_questions.py describe <workflow-id>  # Q2-4 what Temporal thinks     (describe)
    python five_questions.py list "<list filter>"    # fleet: which runs             (list_workflows)
    python five_questions.py lane <task-queue>       # Q5  who is polling, backlog   (DescribeTaskQueue)

CLI equivalents: `temporal workflow show|describe -w <id>`, `temporal workflow list --query`,
`temporal task-queue describe --task-queue <lane> --task-queue-type activity`.
"""
import asyncio
import os
import sys
from datetime import datetime, timezone

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from common import NAMESPACE, connect  # noqa: E402
from temporalio.api.enums.v1 import DescribeTaskQueueMode, EventType, TaskQueueType  # noqa: E402
from temporalio.api.taskqueue.v1 import TaskQueue  # noqa: E402
from temporalio.api.workflowservice.v1 import DescribeTaskQueueRequest  # noqa: E402
from temporalio.client import Client  # noqa: E402

from lanes import AGENT_WORKFLOWS, CPU_TOOLS, GPU_TOOLS  # noqa: E402

TERMINAL = {"ACTIVITY_TASK_COMPLETED", "ACTIVITY_TASK_FAILED", "ACTIVITY_TASK_TIMED_OUT", "ACTIVITY_TASK_CANCELED"}


def _name(event) -> str:
    return EventType.Name(event.event_type).removeprefix("EVENT_TYPE_")


async def q1_history(client: Client, wf_id: str) -> None:
    """Q1 What happened? — the Event History, in order (strongly consistent)."""
    history = await client.get_workflow_handle(wf_id).fetch_history()
    print(f"Q1 what happened: {len(history.events)} events (fetch_history)")
    scheduled = {}
    for ev in history.events:
        detail = ""
        if ev.HasField("activity_task_scheduled_event_attributes"):
            a = ev.activity_task_scheduled_event_attributes
            scheduled[ev.event_id] = a.activity_type.name
            detail = f"{a.activity_type.name} on {a.task_queue.name}"
            if a.priority.priority_key:
                detail += f" priority_key={a.priority.priority_key}"
        elif ev.HasField("activity_task_started_event_attributes"):
            a = ev.activity_task_started_event_attributes
            detail = f"{scheduled.get(a.scheduled_event_id, '?')} attempt={a.attempt} by {a.identity}"
        elif ev.HasField("activity_task_timed_out_event_attributes"):
            a = ev.activity_task_timed_out_event_attributes
            detail = f"{a.failure.message} retry_state={a.retry_state}"
        elif ev.HasField("workflow_task_started_event_attributes"):
            detail = ev.workflow_task_started_event_attributes.identity
        elif ev.HasField("workflow_execution_failed_event_attributes"):
            detail = ev.workflow_execution_failed_event_attributes.failure.message
        elif ev.HasField("upsert_workflow_search_attributes_event_attributes"):
            detail = ", ".join(sorted(ev.upsert_workflow_search_attributes_event_attributes.search_attributes.indexed_fields))
        elif ev.HasField("timer_started_event_attributes"):
            detail = f"fires in {ev.timer_started_event_attributes.start_to_fire_timeout.ToTimedelta()}"
        print(f"  {ev.event_id:4d}  {_name(ev):36s} {detail}")


async def q2_q4_describe(client: Client, wf_id: str) -> None:
    """Q2 What did Temporal think happened? Q3 What side effects may have occurred? Q4 What next?
    — all from DescribeWorkflowExecution (the current view, strongly consistent)."""
    d = await client.get_workflow_handle(wf_id).describe()
    sa = {k.name: v for k, v in d.typed_search_attributes}
    print(f"Q2 what Temporal thinks: status={d.status.name} task_queue={d.task_queue} history_length={d.history_length}")
    print(f"   search attributes: {sa}")
    raw = d.raw_description
    if not raw.pending_activities:
        print("   pending activities: none")
    for pa in raw.pending_activities:
        details = [p.data.decode(errors='replace') for p in pa.heartbeat_details.payloads] if pa.HasField("heartbeat_details") else []
        print(f"   pending {pa.activity_type.name} state={pa.state} attempt={pa.attempt} max={pa.maximum_attempts} "
              f"last_worker={pa.last_worker_identity!r} last_failure={pa.last_failure.message!r} heartbeat={details}")
    # Q3: any ActivityTaskStarted without a terminal event is an attempt that ran somewhere.
    history = await client.get_workflow_handle(wf_id).fetch_history()
    open_attempts = {}
    for ev in history.events:
        if ev.HasField("activity_task_started_event_attributes"):
            open_attempts[ev.activity_task_started_event_attributes.scheduled_event_id] = ev.event_id
        elif _name(ev) in TERMINAL:
            attrs = getattr(ev, _name(ev).lower() + "_event_attributes")
            open_attempts.pop(attrs.scheduled_event_id, None)
    print(f"Q3 side effects: {len(open_attempts)} attempt(s) started without a terminal event; "
          f"attempt counts above say how many times an idempotency key was presented")
    if raw.pending_activities:
        nxt = [f"{pa.activity_type.name} next attempt {pa.scheduled_time.ToDatetime().replace(tzinfo=timezone.utc).isoformat()}"
               for pa in raw.pending_activities if pa.HasField("scheduled_time")]
        print(f"Q4 what next: {nxt}")
    else:
        print("Q4 what next: no pending Activity; pending timers/children:",
              [f"timer {t.timer_id}" for t in raw.pending_timers] if hasattr(raw, "pending_timers") else "n/a",
              [c.workflow_id for c in raw.pending_children])
    print(f"   (now {datetime.now(timezone.utc).isoformat(timespec='seconds')})")


async def q5_lane(client: Client, lane: str) -> None:
    """Q5 What if every Worker disappears now? — the lane's pollers and backlog (DescribeTaskQueue)."""
    r = await client.workflow_service.describe_task_queue(DescribeTaskQueueRequest(
        namespace=NAMESPACE, task_queue=TaskQueue(name=lane),
        task_queue_types=[TaskQueueType.TASK_QUEUE_TYPE_ACTIVITY, TaskQueueType.TASK_QUEUE_TYPE_WORKFLOW],
        api_mode=DescribeTaskQueueMode.DESCRIBE_TASK_QUEUE_MODE_ENHANCED, report_stats=True, report_pollers=True))
    now = datetime.now(timezone.utc)
    for _build, vinfo in r.versions_info.items():
        for ttype, tinfo in vinfo.types_info.items():
            kind = TaskQueueType.Name(ttype).removeprefix("TASK_QUEUE_TYPE_").lower()
            age = tinfo.stats.approximate_backlog_age.ToTimedelta()
            print(f"Q5 lane {lane} [{kind}]: backlog={tinfo.stats.approximate_backlog_count} age={age} "
                  f"add_rate={tinfo.stats.tasks_add_rate:.3f}/s dispatch_rate={tinfo.stats.tasks_dispatch_rate:.3f}/s "
                  f"increase_rate={tinfo.stats.tasks_add_rate - tinfo.stats.tasks_dispatch_rate:+.3f}/s")
            for p in tinfo.pollers:
                seen = now - p.last_access_time.ToDatetime().replace(tzinfo=timezone.utc)
                print(f"   poller {p.identity} last poll {seen.total_seconds():.0f}s ago"
                      + ("  <- gone (a Worker polls every ~60 s; the list forgets it after 5 min)" if seen.total_seconds() > 90 else ""))
            if not tinfo.pollers:
                print("   no pollers")


async def live_pollers(client: Client, lane: str, kind: int = TaskQueueType.TASK_QUEUE_TYPE_ACTIVITY) -> list[str]:
    """Identities that polled `lane` in the last 90 s: Workers that are really there."""
    r = await client.workflow_service.describe_task_queue(DescribeTaskQueueRequest(
        namespace=NAMESPACE, task_queue=TaskQueue(name=lane), task_queue_types=[kind],
        api_mode=DescribeTaskQueueMode.DESCRIBE_TASK_QUEUE_MODE_ENHANCED, report_pollers=True))
    now = datetime.now(timezone.utc)
    return [p.identity for vinfo in r.versions_info.values() for tinfo in vinfo.types_info.values()
            for p in tinfo.pollers
            if (now - p.last_access_time.ToDatetime().replace(tzinfo=timezone.utc)).total_seconds() <= 90]


async def fleet(client: Client, query: str) -> None:
    print(f"list_workflows({query!r}):")
    n = 0
    async for wf in client.list_workflows(query):
        n += 1
        sa = {k.name: v for k, v in wf.typed_search_attributes}
        print(f"  {wf.id:32s} {wf.status.name:10s} AgentStatus={sa.get('AgentStatus')} CurrentStep={sa.get('CurrentStep')} Owner={sa.get('Owner')}")
    print(f"  ({n} executions; visibility is eventually consistent)")


async def main(argv: list[str]) -> None:
    client = await connect()
    cmd, arg = (argv + [None, None])[:2]
    if cmd == "history":
        await q1_history(client, arg)
    elif cmd == "describe":
        await q2_q4_describe(client, arg)
    elif cmd == "list":
        await fleet(client, arg)
    elif cmd == "lane":
        await q5_lane(client, arg)
    elif cmd == "five":
        await q1_history(client, arg)
        await q2_q4_describe(client, arg)
        for lane in (AGENT_WORKFLOWS, CPU_TOOLS, GPU_TOOLS):
            await q5_lane(client, lane)
    else:
        print(__doc__)


if __name__ == "__main__":
    asyncio.run(main(sys.argv[1:]))
10-operating-temporal-in-production/lanes.py
"""Lane names for lab 10. Spell every Task Queue name once, here, and import it on both sides.

A name mismatch between Client and Worker is not an error in Temporal: it creates two different
Task Queues, the Worker receives nothing, and the Workflow does not progress.

The course names are the defaults. When several people share one dev server, set LAB_PREFIX so your
queues and Workflow IDs do not collide with theirs:  LAB_PREFIX=lab10- python worker_cpu.py
"""
from __future__ import annotations

import os

LAB_PREFIX = os.environ.get("LAB_PREFIX", "")

AGENT_WORKFLOWS = os.environ.get("AGENT_WORKFLOWS_QUEUE", LAB_PREFIX + "agent-workflows")
CPU_TOOLS = os.environ.get("CPU_TOOLS_QUEUE", LAB_PREFIX + "cpu-tools")
GPU_TOOLS = os.environ.get("GPU_TOOLS_QUEUE", LAB_PREFIX + "gpu-tools")

# Which lane each tool needs. Read in Workflow code, so the lane a step went to is replayed like
# any other decision (moving a tool between lanes mid-run is a module 9 change).
CPU_TOOL_NAMES = frozenset({"search", "parse", "fetch"})
GPU_TOOL_NAMES = frozenset({"embed", "vision", "rerank"})
LANE_FOR_TOOL = {**{t: CPU_TOOLS for t in CPU_TOOL_NAMES}, **{t: GPU_TOOLS for t in GPU_TOOL_NAMES}}


def workflow_id(base: str) -> str:
    """`agent-42` in the course; `lab10-agent-42` on a shared server."""
    return LAB_PREFIX + base
10-operating-temporal-in-production/priority_race.py
"""Lab 10.3 — does the lane honour Priority / Fairness? Build a backlog on `gpu-tools` with no GPU
Worker, then bring up ONE single-slot GPU Worker and read the dispatch order off the histories.

    python priority_race.py                     # N batch runs (priority 5) queued first, then 1 enterprise run (priority 1)
    python priority_race.py --mode fairness     # 2 tenants, same priority, enqueued a a a a b b b b

Everything runs in this process (workflow + cpu Workers first, gpu Worker last) so the order in
which Tasks enter the backlog is exactly the order printed. Prediction (upstream rules, as of
2026-09): priority mode → the enterprise run's GPU Task starts before every batch Task that was
queued ahead of it; fairness mode → a b a b … if `matching.enableFairness` is on, a a a a b b b b
(FIFO) if it is not. Whatever you see, write it down with the server version.
"""
import argparse
import asyncio
import os
import sys
import time

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from common import connect  # noqa: E402
from temporalio.api.enums.v1 import EventType  # noqa: E402
from temporalio.client import Client, WorkflowHandle  # noqa: E402
from temporalio.common import SearchAttributePair, TypedSearchAttributes  # noqa: E402
from temporalio.worker import Worker  # noqa: E402

from lanes import AGENT_WORKFLOWS, CPU_TOOLS, GPU_TOOLS, workflow_id  # noqa: E402
from five_questions import live_pollers, q5_lane  # noqa: E402
from worker_gpu import execute_tool_on_gpu  # noqa: E402
from workflows import OWNER, AgentRun, call_llm, evaluate, execute_tool  # noqa: E402


async def start(client: Client, base: str, owner: str, tier: str) -> WorkflowHandle:
    return await client.start_workflow(
        AgentRun.run, f"race {base}", id=workflow_id(base), task_queue=AGENT_WORKFLOWS,
        search_attributes=TypedSearchAttributes([SearchAttributePair(OWNER, owner)]), memo={"tier": tier})


async def wait_gpu_pending(handle: WorkflowHandle) -> None:
    """Until the run's next Activity is the GPU tool, sitting in the backlog with nobody to take it."""
    while True:
        d = await handle.describe()
        for pa in d.raw_description.pending_activities:
            if pa.activity_type.name == "execute_tool" and (await handle.query(AgentRun.status))["step"] == 2:
                return
        await asyncio.sleep(0.2)


async def gpu_start_time(handle: WorkflowHandle) -> tuple[float, int, str]:
    """(ActivityTaskStarted time of the GPU Task, its priority_key, identity of the Worker) from history."""
    history = await handle.fetch_history()
    gpu_scheduled = {}
    for ev in history.events:
        if ev.event_type == EventType.EVENT_TYPE_ACTIVITY_TASK_SCHEDULED:
            a = ev.activity_task_scheduled_event_attributes
            if a.task_queue.name == GPU_TOOLS:
                gpu_scheduled[ev.event_id] = a.priority.priority_key
        elif ev.event_type == EventType.EVENT_TYPE_ACTIVITY_TASK_STARTED:
            a = ev.activity_task_started_event_attributes
            if a.scheduled_event_id in gpu_scheduled:
                return ev.event_time.ToDatetime().timestamp(), gpu_scheduled[a.scheduled_event_id], a.identity
    raise RuntimeError(f"{handle.id}: GPU Task never started")


async def main() -> None:
    p = argparse.ArgumentParser()
    p.add_argument("--mode", choices=["priority", "fairness"], default="priority")
    p.add_argument("--batch", type=int, default=4, help="runs queued ahead (priority mode) / per tenant (fairness mode)")
    p.add_argument("--tag", default=time.strftime("%H%M%S"), help="suffix so Workflow IDs are fresh on every run")
    a = p.parse_args()
    os.environ.setdefault("GPU_SCHEDULE_TO_START_SECONDS", "180")

    client = await connect()
    # The race needs a backlog, and a backlog needs the GPU lane to be empty: a worker_gpu.py left
    # running from 10.1 takes every Task as it arrives, and the order printed is plain FIFO.
    # (A previous race's own GPU Worker is named race-gpu-* and exited with its process; skip it.)
    others = [i for i in await live_pollers(client, GPU_TOOLS) if not i.startswith("race-gpu-")]
    if others:
        raise SystemExit(f"A GPU Worker is still polling {GPU_TOOLS}: {', '.join(others)}.\n"
                         f"Stop worker_gpu.py (Ctrl-C) and wait ~90 s for the poller to age out, then rerun.")
    wf_worker = Worker(client, task_queue=AGENT_WORKFLOWS, workflows=[AgentRun], identity=f"wf-{os.getpid()}@{AGENT_WORKFLOWS}")
    cpu_worker = Worker(client, task_queue=CPU_TOOLS, activities=[call_llm, execute_tool, evaluate], identity=f"cpu-{os.getpid()}@{CPU_TOOLS}")
    gpu_worker = Worker(client, task_queue=GPU_TOOLS, activities=[execute_tool_on_gpu], max_concurrent_activities=1,
                        identity=f"race-gpu-{os.getpid()}@{GPU_TOOLS}")
    async with wf_worker, cpu_worker:
        queued: list[tuple[str, WorkflowHandle]] = []
        if a.mode == "priority":
            plan = [(f"race-{a.tag}-batch-{i}", "acct_batch", "batch") for i in range(1, a.batch + 1)]
            plan.append((f"race-{a.tag}-enterprise", "acct_ent", "enterprise"))
        else:
            plan = [(f"race-{a.tag}-a-{i}", "acct_a", "batch") for i in range(1, a.batch + 1)]
            plan += [(f"race-{a.tag}-b-{i}", "acct_b", "batch") for i in range(1, a.batch + 1)]
        for base, owner, tier in plan:
            # Strictly one at a time: each run must be IN the GPU backlog before the next is started,
            # so enqueue order is the order of this list.
            h = await start(client, base, owner, tier)
            await wait_gpu_pending(h)
            queued.append((owner, h))
            print(f"queued  {h.id:40s} owner={owner:10s} tier={tier}")
        print("--- backlog before the GPU Worker exists:")
        await q5_lane(client, GPU_TOOLS)
        print(f"--- starting one GPU Worker with 1 slot on {GPU_TOOLS!r}")
        async with gpu_worker:
            await asyncio.gather(*(h.result() for _, h in queued))
        rows = []
        for owner, h in queued:
            t, prio, ident = await gpu_start_time(h)
            rows.append((t, h.id, owner, prio))
        t0 = min(r[0] for r in rows)
        print("--- dispatch order observed (ActivityTaskStarted on the GPU lane):")
        for i, (t, wid, owner, prio) in enumerate(sorted(rows), 1):
            print(f"  {i:2d}. +{t - t0:5.2f}s  {wid:40s} owner={owner:10s} priority_key={prio}")
        enqueue_order = [h.id for _, h in queued]
        observed = [wid for _, wid, _, _ in sorted(rows)]
        print("--- enqueue order was:", " > ".join(x.rsplit('-', 2)[-2] + "-" + x.rsplit('-', 2)[-1] for x in enqueue_order))
        print("--- observed order is:", " > ".join(x.rsplit('-', 2)[-2] + "-" + x.rsplit('-', 2)[-1] for x in observed))
        print("FIFO" if observed == enqueue_order else "NOT FIFO: the lane reordered the backlog")


if __name__ == "__main__":
    asyncio.run(main())
10-operating-temporal-in-production/setup_search_attributes.py
"""Register the custom Search Attributes AgentRun writes: AgentStatus, CurrentStep, Owner.

A Search Attribute must exist in the Namespace before a Workflow upserts it (an upsert of an
unregistered key fails the Workflow Task, forever, until someone registers it). Registration is
an operator action, once per Namespace, through the Operator Service — the same call as
`temporal operator search-attribute create --name AgentStatus --type Keyword`.

Equivalent at server start (dev server only):
    temporal server start-dev --search-attribute AgentStatus=Keyword --search-attribute CurrentStep=Int --search-attribute Owner=Keyword
"""
import asyncio
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from common import NAMESPACE, connect  # noqa: E402
from temporalio.api.enums.v1 import IndexedValueType  # noqa: E402
from temporalio.api.operatorservice.v1 import AddSearchAttributesRequest, ListSearchAttributesRequest  # noqa: E402

WANTED = {
    "AgentStatus": IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD,
    "CurrentStep": IndexedValueType.INDEXED_VALUE_TYPE_INT,
    "Owner": IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD,
}


async def main() -> None:
    client = await connect()
    listed = await client.operator_service.list_search_attributes(ListSearchAttributesRequest(namespace=NAMESPACE))
    existing = dict(listed.custom_attributes)
    missing = {k: v for k, v in WANTED.items() if k not in existing}
    for k in WANTED:
        if k in existing:
            print(f"exists  {k:12s} {IndexedValueType.Name(existing[k]).removeprefix('INDEXED_VALUE_TYPE_')}")
    if missing:
        await client.operator_service.add_search_attributes(
            AddSearchAttributesRequest(namespace=NAMESPACE, search_attributes=missing))
        for k, v in missing.items():
            print(f"created {k:12s} {IndexedValueType.Name(v).removeprefix('INDEXED_VALUE_TYPE_')}")
    listed = await client.operator_service.list_search_attributes(ListSearchAttributesRequest(namespace=NAMESPACE))
    assert all(k in listed.custom_attributes for k in WANTED), listed.custom_attributes
    print("namespace", NAMESPACE, "custom attributes:", sorted(listed.custom_attributes))


if __name__ == "__main__":
    asyncio.run(main())
10-operating-temporal-in-production/starter.py
"""Start one AgentRun. `--tier enterprise` puts its GPU step at priority 1; `--tier batch` at 5.
`Owner` is a Search Attribute from the first event; tier rides in memo."""
import argparse
import asyncio
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from common import connect, show_history, start_workflow  # noqa: E402
from temporalio.common import SearchAttributePair, TypedSearchAttributes  # noqa: E402

from lanes import AGENT_WORKFLOWS, workflow_id  # noqa: E402
from workflows import OWNER, AgentRun  # noqa: E402


async def main() -> None:
    p = argparse.ArgumentParser()
    p.add_argument("--goal", default="summarize the Temporal docs")
    p.add_argument("--id", default="agent-42", help="Workflow ID = agent id (LAB_PREFIX is prepended)")
    p.add_argument("--owner", default="acct_7f3a", help="opaque account id; never an email (Search Attributes are unencrypted)")
    p.add_argument("--tier", default="batch", choices=["enterprise", "team", "batch"])
    p.add_argument("--no-wait", action="store_true", help="start and return; do not wait for the result")
    p.add_argument("--gpu-schedule-to-start", type=int, default=0, metavar="SECONDS",
                   help="this run's Schedule-to-Start on the GPU lane (10.1's stall); default: the Worker's 60 s")
    a = p.parse_args()

    client = await connect()
    handle = await start_workflow(client,
        AgentRun.run, a.goal, id=workflow_id(a.id), task_queue=AGENT_WORKFLOWS,
        search_attributes=TypedSearchAttributes([SearchAttributePair(OWNER, a.owner)]),
        memo={"tier": a.tier, **({"gpu_schedule_to_start_seconds": a.gpu_schedule_to_start}
                                 if a.gpu_schedule_to_start else {})},
    )
    print(f"started {handle.id} run={handle.result_run_id} owner={a.owner} tier={a.tier}")
    print(f"UI: http://localhost:8233/namespaces/default/workflows/{handle.id}/{handle.result_run_id}/history")
    if a.no_wait:
        return
    print("result:", await handle.result())
    await show_history(client, handle.id)


if __name__ == "__main__":
    asyncio.run(main())
10-operating-temporal-in-production/tests/conftest.py
"""Test knobs must be in the environment before `workflows` is imported — the Worker's sandbox
re-imports the module and reads them again, so os.environ is the one channel both sides see."""
import os
import sys

LAB_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.environ.setdefault("LAB_PREFIX", "test10-")
os.environ["GPU_SCHEDULE_TO_START_SECONDS"] = "2"
os.environ["TOOL_SECONDS"] = "0.2"
sys.path.insert(0, LAB_DIR)
sys.path.insert(0, os.path.dirname(LAB_DIR))
10-operating-temporal-in-production/tests/test_lanes.py
"""Lab 10 self-check. Runs on the SDK's time-skipping test server (downloaded once; no Docker).
No pytest-asyncio: each test drives its coroutine on a module-scoped event loop."""
import asyncio

import pytest
from temporalio.api.enums.v1 import EventType, IndexedValueType
from temporalio.api.operatorservice.v1 import AddSearchAttributesRequest
from temporalio.client import WorkflowFailureError
from temporalio.common import SearchAttributePair, TypedSearchAttributes
from temporalio.exceptions import ApplicationError
from temporalio.testing import ActivityEnvironment, WorkflowEnvironment
from temporalio.worker import Worker

from lanes import AGENT_WORKFLOWS, CPU_TOOLS, GPU_TOOLS
from worker_gpu import execute_tool_on_gpu
from workflows import OWNER, AgentRun, ToolCall, call_llm, evaluate, execute_tool


@pytest.fixture(scope="module")
def loop():
    loop = asyncio.new_event_loop()
    yield loop
    loop.close()


@pytest.fixture(scope="module")
def env(loop):
    async def start():
        env = await WorkflowEnvironment.start_time_skipping()
        # The test server, like the dev server, rejects an unregistered Search Attribute
        # ("search attribute Owner is not defined"); register them the way setup_search_attributes.py does.
        await env.client.operator_service.add_search_attributes(AddSearchAttributesRequest(
            namespace="default", search_attributes={
                "AgentStatus": IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD,
                "CurrentStep": IndexedValueType.INDEXED_VALUE_TYPE_INT,
                "Owner": IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD}))
        return env
    env = loop.run_until_complete(start())
    yield env
    loop.run_until_complete(env.shutdown())


def workflow_worker(env):
    return Worker(env.client, task_queue=AGENT_WORKFLOWS, workflows=[AgentRun])


def cpu_worker(env):
    return Worker(env.client, task_queue=CPU_TOOLS, activities=[call_llm, execute_tool, evaluate])


def gpu_worker(env):
    return Worker(env.client, task_queue=GPU_TOOLS, activities=[execute_tool_on_gpu], max_concurrent_activities=1)


async def start(env, wf_id: str, tier: str):
    return await env.client.start_workflow(
        AgentRun.run, "summarize the Temporal docs", id=wf_id, task_queue=AGENT_WORKFLOWS,
        search_attributes=TypedSearchAttributes([SearchAttributePair(OWNER, "acct_test")]), memo={"tier": tier})


def gpu_scheduled_events(history):
    return [ev.activity_task_scheduled_event_attributes for ev in history.events
            if ev.event_type == EventType.EVENT_TYPE_ACTIVITY_TASK_SCHEDULED
            and ev.activity_task_scheduled_event_attributes.task_queue.name == GPU_TOOLS]


def test_each_activity_runs_on_the_lane_the_workflow_named(loop, env):
    async def body():
        async with workflow_worker(env), cpu_worker(env), gpu_worker(env):
            handle = await start(env, "agent-42", "enterprise")
            result = await handle.result()
            assert result["lanes_used"] == [CPU_TOOLS, CPU_TOOLS, GPU_TOOLS]
            desc = await handle.describe()
            sa = {k.name: v for k, v in desc.typed_search_attributes}
            assert sa["AgentStatus"] == "done" and sa["CurrentStep"] == 3 and sa["Owner"] == "acct_test"
            history = await handle.fetch_history()
            [gpu] = gpu_scheduled_events(history)
            assert gpu.priority.priority_key == 1, "enterprise tier → priority 1 on the GPU lane"
    loop.run_until_complete(body())


def test_batch_tier_schedules_gpu_work_at_priority_5(loop, env):
    async def body():
        async with workflow_worker(env), cpu_worker(env), gpu_worker(env):
            handle = await start(env, "agent-batch", "batch")
            await handle.result()
            [gpu] = gpu_scheduled_events(await handle.fetch_history())
            assert gpu.priority.priority_key == 5
    loop.run_until_complete(body())


def test_gpu_lane_without_workers_fails_at_schedule_to_start(loop, env):
    async def body():
        async with workflow_worker(env), cpu_worker(env):  # no GPU pool
            handle = await start(env, "agent-stalled", "batch")
            with pytest.raises(WorkflowFailureError) as err:
                await handle.result()
            assert isinstance(err.value.cause, ApplicationError) and err.value.cause.type == "LaneStalled"
            history = await handle.fetch_history()
            timed_out = [ev for ev in history.events if ev.event_type == EventType.EVENT_TYPE_ACTIVITY_TASK_TIMED_OUT]
            assert len(timed_out) == 1, "Schedule-to-Start is not retried: one timeout event, no second attempt"
            assert timed_out[0].activity_task_timed_out_event_attributes.failure.timeout_failure_info.timeout_type == 2  # SCHEDULE_TO_START
            sa = {k.name: v for k, v in (await handle.describe()).typed_search_attributes}
            assert sa["AgentStatus"] == "stalled" and sa["CurrentStep"] == 2
    loop.run_until_complete(body())


def test_gpu_worker_refuses_a_cpu_tool(loop):
    async def body():
        with pytest.raises(ApplicationError) as err:
            await ActivityEnvironment().run(execute_tool_on_gpu, ToolCall("k", "search", {"q": "x"}))
        assert err.value.type == "BadArguments" and err.value.non_retryable
    loop.run_until_complete(body())
10-operating-temporal-in-production/worker_cpu.py
"""The `cpu-tools` pool: cheap, autoscaled. Registers exactly the Activities that need no GPU."""
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.worker import Worker  # noqa: E402

from lanes import CPU_TOOLS  # noqa: E402
from workflows import call_llm, evaluate, execute_tool  # noqa: E402


async def main() -> None:
    client = await connect()
    worker = Worker(client, task_queue=CPU_TOOLS, activities=[call_llm, execute_tool, evaluate],
                    identity=f"cpu-{os.getpid()}@{CPU_TOOLS}")
    print(f"cpu worker pid={os.getpid()} polling {CPU_TOOLS!r}: call_llm, execute_tool, evaluate")
    await worker.run()


if __name__ == "__main__":
    asyncio.run(main())
10-operating-temporal-in-production/worker_gpu.py
"""The `gpu-tools` pool: scarce, preemptible. One Activity slot by default (GPU_SLOTS) so the
dispatch order on the lane is observable one Task at a time (lab 10.3).

The set of Activities registered here is exactly the set that needs a GPU. Any Worker polling
`gpu-tools` must register all of them, so a CPU-only tool arriving here is a routing bug — the
guard below turns it into a non-retryable failure instead of a silently wrong answer."""
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 import activity  # noqa: E402
from temporalio.exceptions import ApplicationError  # noqa: E402
from temporalio.worker import Worker  # noqa: E402

from lanes import GPU_TOOL_NAMES, GPU_TOOLS  # noqa: E402
from workflows import ToolCall, ToolResult, execute_tool  # noqa: E402

GPU_SLOTS = int(os.environ.get("GPU_SLOTS", "1"))


@activity.defn(name="execute_tool")
async def execute_tool_on_gpu(call: ToolCall) -> ToolResult:
    if call.tool not in GPU_TOOL_NAMES:
        raise ApplicationError(f"{call.tool} does not need a GPU; it was routed to {GPU_TOOLS}",
                               type="BadArguments", non_retryable=True)
    return await execute_tool(call)


async def main() -> None:
    client = await connect()
    worker = Worker(client, task_queue=GPU_TOOLS, activities=[execute_tool_on_gpu],
                    max_concurrent_activities=GPU_SLOTS, identity=f"gpu-{os.getpid()}@{GPU_TOOLS}")
    print(f"gpu worker pid={os.getpid()} polling {GPU_TOOLS!r} with {GPU_SLOTS} slot(s): execute_tool (GPU tools only)")
    await worker.run()


if __name__ == "__main__":
    asyncio.run(main())
10-operating-temporal-in-production/worker_workflows.py
"""The `agent-workflows` pool: Workflow code only. Deliberately boring, so you can run many of
these and redeploy them twice a day without touching a GPU."""
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.worker import Worker  # noqa: E402

from lanes import AGENT_WORKFLOWS  # noqa: E402
from workflows import AgentRun  # noqa: E402


async def main() -> None:
    client = await connect()
    worker = Worker(client, task_queue=AGENT_WORKFLOWS, workflows=[AgentRun],
                    identity=f"wf-{os.getpid()}@{AGENT_WORKFLOWS}")
    print(f"workflow worker pid={os.getpid()} polling {AGENT_WORKFLOWS!r} (Workflow Tasks only)")
    await worker.run()


if __name__ == "__main__":
    asyncio.run(main())
10-operating-temporal-in-production/workflows.py
"""Lab 10: AgentRun, stage 10 — the same program, split across compute lanes.

Nothing about the agent loop changes. What changes is that every Activity call names the lane it
needs (`task_queue=`), carries a Schedule-to-Start timeout so an empty lane becomes visible, and
the run publishes `AgentStatus` / `CurrentStep` Search Attributes so an operator can find it among
thousands without opening a history.
"""
from __future__ import annotations

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

from temporalio import activity, workflow
from temporalio.common import Priority, RetryPolicy, SearchAttributeKey
from temporalio.exceptions import ActivityError, ApplicationError, TimeoutError, TimeoutType

from lanes import CPU_TOOLS, GPU_TOOL_NAMES, GPU_TOOLS, LANE_FOR_TOOL

# ---------------------------------------------------------------- Search Attributes (registered
# once per Namespace by setup_search_attributes.py; the Workflow only writes them)
AGENT_STATUS = SearchAttributeKey.for_keyword("AgentStatus")
CURRENT_STEP = SearchAttributeKey.for_int("CurrentStep")
OWNER = SearchAttributeKey.for_keyword("Owner")

# Lab knobs. The course values are in the comments; the lab shrinks them so you can watch.
GPU_SCHEDULE_TO_START = timedelta(seconds=int(os.environ.get("GPU_SCHEDULE_TO_START_SECONDS", "60")))  # course: 5 min
TOOL_SECONDS = float(os.environ.get("TOOL_SECONDS", "3"))  # how long the fake tool "computes"

# Priority on the shared GPU lane: 1 is served first, 5 last, default 3 (upstream, as of 2026-09).
PRIORITY_FOR_TIER = {"enterprise": 1, "team": 3, "batch": 5}
# One weight per fairness key, and it does not move. Upstream is explicit: "There should only be
# one fairness weight assigned to each fairness key within a Task Queue. Having multiple fairness
# weights on a fairness key will result in unspecific behavior." The key here is the tenant, so the
# weight has to belong to the tenant -- deriving it from the tier breaks that rule the moment one
# tenant runs at two tiers. The tier is already expressed, once, as the priority key: priority for
# the SLA tier, fairness for equity between tenants inside it. If weights genuinely must differ by
# tier, make the key f"{tier}:{owner}" rather than moving the weight.
# Empty means every tenant is equal. In production these are set without a deploy:
# `temporal task-queue config set --fairness-key-weight <tenant>=<weight>`.
FAIRNESS_WEIGHT_FOR_OWNER = {}
DEFAULT_FAIRNESS_WEIGHT = 1.0


@dataclass
class Step:
    kind: str  # "llm" | "tool" | "finish"
    prompt: str = ""
    tool: str = ""
    args: dict = field(default_factory=dict)


@dataclass
class ToolCall:
    key: str
    tool: str
    args: dict


@dataclass
class ToolResult:
    key: str
    tool: str
    output: str
    attempt: int
    task_queue: str  # which lane answered — the Workflow never chose a host, only a lane
    worker_pid: int


@dataclass
class Verdict:
    ok: bool
    note: str


# ---------------------------------------------------------------- Activities
@activity.defn
async def call_llm(prompt: str, key: str) -> str:
    activity.logger.info("call_llm key=%s on %s", key, activity.info().task_queue)
    return f"[model answer to: {prompt[:40]}]"


@activity.defn
async def execute_tool(call: ToolCall) -> ToolResult:
    """One registration for both lanes; the Worker that polls `gpu-tools` must be the one with a GPU.
    The fake heartbeats every 0.5 s so a dead Worker is noticed at the heartbeat timeout, not
    at Start-to-Close."""
    info = activity.info()
    activity.logger.info("execute_tool %s key=%s attempt=%d on %s", call.tool, call.key, info.attempt, info.task_queue)
    ticks = max(1, int(TOOL_SECONDS / 0.5))
    for i in range(ticks):
        activity.heartbeat({"tick": i, "of": ticks})
        await asyncio.sleep(TOOL_SECONDS / ticks)
    return ToolResult(call.key, call.tool, f"[{call.tool} output for {call.args}]", info.attempt, info.task_queue, os.getpid())


@activity.defn
async def evaluate(result: ToolResult) -> Verdict:
    return Verdict(ok=bool(result.output), note=f"{result.tool} answered from {result.task_queue}")


# ---------------------------------------------------------------- The Workflow
@workflow.defn
class AgentRun:
    def __init__(self) -> None:
        self.step = 0
        self.status = "starting"
        self.goal = ""
        self.context_summary = ""
        self.attempts = 0
        self.paused = False
        self.tier = "batch"
        self.owner = ""
        self.gpu_schedule_to_start = GPU_SCHEDULE_TO_START
        self.lanes_used: list[str] = []

    # -- decisions: pure functions of state ---------------------------------------------------
    def plan(self) -> Step:
        if self.step == 0:
            return Step("llm", prompt=f"Make a three-step plan for: {self.goal}")
        if self.step == 1:
            return Step("tool", tool="search", args={"q": self.goal})       # cpu-tools
        if self.step == 2:
            return Step("tool", tool="embed", args={"text": self.context_summary[:60]})  # gpu-tools
        return Step("finish")

    def _key(self, *parts: str) -> str:
        digest = hashlib.sha256(":".join(parts).encode()).hexdigest()[:12]
        return f"{workflow.info().workflow_id}:{self.step}:{digest}"

    def _publish(self, status: str) -> None:
        """The Query reads self.status (authoritative); the fleet view reads the Search Attributes
        (eventually consistent). The upsert is a Command recorded in History and replays."""
        self.status = status
        workflow.upsert_search_attributes([AGENT_STATUS.value_set(status), CURRENT_STEP.value_set(self.step)])

    # -- control surface ---------------------------------------------------------------------
    @workflow.signal
    def pause(self) -> None:
        self.paused = True
        self._publish("paused")

    @workflow.signal
    def resume(self) -> None:
        self.paused = False
        self._publish("running")

    @workflow.query
    def status(self) -> dict:
        return {"step": self.step, "status": self.status, "goal": self.goal, "paused": self.paused,
                "tier": self.tier, "owner": self.owner, "lanes_used": self.lanes_used}

    # -- effects: each names its lane ---------------------------------------------------------
    async def _run_tool(self, step: Step) -> ToolResult:
        call = ToolCall(self._key(step.tool, repr(sorted(step.args.items()))), step.tool, step.args)
        lane = LANE_FOR_TOOL[step.tool]
        gpu = step.tool in GPU_TOOL_NAMES
        result: ToolResult = await workflow.execute_activity(
            execute_tool, call,
            task_queue=lane,
            # Queued with no Worker on the lane → this is the timeout that fires (lab 10.1).
            schedule_to_start_timeout=self.gpu_schedule_to_start if gpu else timedelta(minutes=1),
            start_to_close_timeout=timedelta(minutes=2),
            heartbeat_timeout=timedelta(seconds=10),
            retry_policy=RetryPolicy(maximum_attempts=3, non_retryable_error_types=["BadArguments"]),
            # Lab 10.3: a Task Queue property, set per call. Same lane, different service order.
            priority=Priority(
                priority_key=PRIORITY_FOR_TIER[self.tier],
                fairness_key=self.owner or None,
                fairness_weight=FAIRNESS_WEIGHT_FOR_OWNER.get(self.owner, DEFAULT_FAIRNESS_WEIGHT),
            ) if gpu else Priority(),
        )
        self.lanes_used.append(result.task_queue)
        verdict: Verdict = await workflow.execute_activity(
            evaluate, result, task_queue=CPU_TOOLS, start_to_close_timeout=timedelta(seconds=30),
        )
        self.context_summary = f"{result.output} ({verdict.note})"
        return result

    @workflow.run
    async def run(self, goal: str) -> dict:
        self.goal = goal
        # Who owns this run and how it is prioritised come in from the starter: Owner as a Search
        # Attribute (so it is queryable), tier as memo (metadata the Workflow reads, nobody queries).
        self.owner = workflow.info().typed_search_attributes.get(OWNER) or ""
        self.tier = workflow.memo_value("tier", "batch", type_hint=str)
        # The stall experiment (10.1) shortens the GPU lane's Schedule-to-Start per run, from the
        # starter: memo is fixed at start, so this is as deterministic as a constant. Without it
        # the Worker's GPU_SCHEDULE_TO_START_SECONDS applies.
        seconds = workflow.memo_value("gpu_schedule_to_start_seconds", 0, type_hint=int)
        self.gpu_schedule_to_start = timedelta(seconds=seconds) if seconds else GPU_SCHEDULE_TO_START
        self._publish("running")
        while True:
            await workflow.wait_condition(lambda: not self.paused)
            step = self.plan()
            if step.kind == "finish":
                self._publish("done")
                return {"summary": self.context_summary, "lanes_used": self.lanes_used, "steps": self.step}
            try:
                if step.kind == "llm":
                    self.context_summary = await workflow.execute_activity(
                        call_llm, args=[step.prompt, self._key(step.prompt)],
                        task_queue=CPU_TOOLS,  # production: the `premium-models` lane, rate-limited
                        schedule_to_start_timeout=timedelta(minutes=1),
                        start_to_close_timeout=timedelta(seconds=30),
                    )
                    self.lanes_used.append(CPU_TOOLS)
                else:
                    await self._run_tool(step)
            except ActivityError as err:
                cause = err.cause
                if isinstance(cause, TimeoutError) and cause.type == TimeoutType.SCHEDULE_TO_START:
                    # No Worker on that lane took the Task before the deadline. Record it where an
                    # operator will look (Search Attribute), then fail loudly.
                    self._publish("stalled")
                    raise ApplicationError(
                        f"lane stalled: no Worker took {step.tool or 'call_llm'} within {cause.type.name}",
                        type="LaneStalled", non_retryable=True) from err
                raise
            self.step += 1
            # A pause Signal may have landed while the step ran: republish *its* status, not ours.
            self._publish("paused" if self.paused else "running")

Lab source: labs/10-operating-temporal-in-production in the course repository. Verified against temporal server start-dev with the Python SDK 1.32 on 2026-09-11.