COURSE LAB

Lab 5: Signals, Queries, and Updates

Objective

Why: Choose the right control operation for an agent that may be offline.

By the end: Use Signals, Queries, and Updates; then explain which operations write history and how to avoid duplicate or unfinished handler work.

Before you run

  • Mechanism: A Signal records an incoming message. A Query reads current Workflow state through a Worker. An Update can validate a request and return a result.
  • Predict: Predict which operations still leave evidence when all Workers are down, and which need a Worker to answer.
  • Look for: Compare history before and after a Signal, Query, accepted Update, and rejected Update; then inspect the deduplicated note count.
  • 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.

Same program as labs 1–4. The one new line that matters is the first line of the loop in workflows.py: await workflow.wait_condition(lambda: not self.paused or self.status != "running"). Reading page: "Signals, Queries, and Updates".

Swipe to explore diagram →
Signal, Query, and Update message paths A Signal is recorded by the Service and handled later. A Query reads state through a live Worker without a history Event. An Update reaches a Worker for validation; accepted Updates enter history and rejected Updates leave no Event. Signal Query Update send pauseService logsmessage Worker handleslater ask statuslive Workerreads state return valueno Event change_goalWorkervalidates accept: Event reject: none
Signals, Queries, and Updates follow different paths. Predict which requests leave an Event before you run the experiment.

Open this lab in the browser workspace

Complete the one-time setup in Lab 1, then open Lab 05 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: give AgentRun (stage 5) a control surface — pause / resume / cancel_run / inject_context Signals, a status Query, a change_goal Update — then prove four things by reading history: a Signal is durable even when no Worker exists, a Query and a rejected Update leave nothing behind, a message delivered twice is applied once, and a handler that is still awaiting an Activity when the run ends is lost unless the main loop drains it first.

Setup

cd 05-signals-queries-updates      # from the temporal-course-labs folder
export TASK_QUEUE=lab-05             # each lab gets its own queue on the shared dev server
python worker.py                     # terminal 1 — prints its pid; you will kill -9 it in 5.2
python starter.py                    # terminal 2 — starts agent-42 and waits for the result
python control.py status             # terminal 3 — the client you drive by hand

starter.py --id my-agent (or WORKFLOW_ID=my-agent) if agent-42 is already running on the shared server; control.py reads the same flag/variable. A run finishes on its own after MAX_STEPS steps of ~2 s each (about 45 s); pause it whenever you want more time.

5.1 Add the Signals and the Query

The handlers are already in workflows.py; read them before you run anything and answer: which of pause, status, change_goal can be called while no Worker is alive? Then check:

  1. python control.py status a few times. Output is a dict — {'step': 2, 'status': 'running', 'paused': False, ...}. A Query is answered by a Worker from the Workflow's reconstructed state, and is never recorded in Event History; you will count that in step 2, once the run is paused and its own steps stop adding Events. "From the Worker's memory" only holds while the Workflow is still cached there; an evicted or never-seen Workflow is replayed from history first, then the handler runs.
  2. python control.py pause, then status again: 'paused': True, 'status': 'running', and step stops changing. Whatever Activity or Timer was in flight when the Signal landed still finishes — the loop checks the flag at its top, not mid-step. Now count: python control.py history | wc -l, then status three times, then count again. The number does not move — the three Queries left no trace. (While the run was still stepping, each step added about eleven Events, which is why this waits for the pause.)
  3. python control.py history. Find the Signal and what followed it (this is the run we recorded):

      22  TIMER_STARTED
      23  WORKFLOW_EXECUTION_SIGNALED            pause
      24  WORKFLOW_TASK_SCHEDULED
      25  WORKFLOW_TASK_STARTED                  identity=3775121@lab-host
      26  WORKFLOW_TASK_COMPLETED
      27  TIMER_FIRED
      28  WORKFLOW_TASK_SCHEDULED
      29  WORKFLOW_TASK_STARTED                  identity=3775121@lab-host
      30  WORKFLOW_TASK_COMPLETED
    

    The Signal is an Event (23). The Worker ran a Workflow Task to apply it (24–26) — the handler is Workflow code. When the pending timer fired (27) the Worker ran another Workflow Task (28–30) that issued no command: the loop reached wait_condition and blocked. That is what a paused Workflow looks like — the last Event is a completed Workflow Task with nothing after it.

  4. python control.py cancel. The cancel_run handler sets self.status = "cancelled"; the loop exits at its next check and run() returns. In the UI the execution is Completed, not Cancelled — this is your Signal, not Temporal's cancellation (lab 7).

5.2 Pause, kill every Worker, resume later

  1. Start a fresh run (python starter.py), let it take a couple of steps, python control.py pause.
  2. Kill the Worker: kill -9 <pid> (from terminal 1's banner). Confirm nothing is polling: pgrep -af worker.py lists no lab-05 Worker, and the UI shows no Workers on lab-05.
  3. python control.py status. It hangs and then fails — query failed: CANCELLED: Timeout expired after the 10 s client timeout (we also saw FAILED_PRECONDITION once, when a Workflow Task was already sitting in the queue). A Query is a question to a Worker; with none alive there is no answer, and nothing about the Query is recorded.
  4. python control.py resume. It returns immediately: the Service recorded the Signal and needs no Worker to do so. python control.py history now ends like this:

      31  WORKFLOW_EXECUTION_SIGNALED            resume
      32  WORKFLOW_TASK_SCHEDULED
    

    A Workflow Task is scheduled and nobody picks it up. Leave it as long as you like — that gap is the "a day later" of the lecture; the run consumes no Worker compute while it waits.

  5. Start a Worker again (python worker.py). Within a second, status answers and step climbs. History continues from Event 32 with a different identity:

      33  WORKFLOW_TASK_STARTED                  identity=3776231@lab-host
      34  WORKFLOW_TASK_COMPLETED
      35  ACTIVITY_TASK_SCHEDULED                call_llm
    

    In the UI, compare the timestamps of Events 32 and 33: that is exactly how long no process existed for this execution. The new Worker replayed Events 1–32 (module 2), rebuilt paused = False from the resume Signal, and issued the next call_llm command.

5.3 change_goal as an Update with a validator

Pause the run first so the loop holds still (control.py pause), then:

  1. A rejected call: python control.py change-goal " " --command-id cmd-9f20

    send 1: rejected: ApplicationError: ValueError: goal must not be empty
    

    The validator raised; the client got WorkflowUpdateFailedError with the ValueError as its cause — an error in the caller, not a log line on the Worker. Run history | wc -l before and after: the count is unchanged (19 → 19 in our run). A rejected Update leaves no Event, not even the Workflow Task that ran the validator.

  2. An accepted call, sent twice to simulate a client that lost the response and retried: python control.py change-goal "write the tests first" --command-id cmd-9f1e --twice

    send 1: accepted; handler returned 'write the tests first'
    send 2: accepted; handler returned 'write the tests first'
    status: {'goal': 'write the tests first', 'notes': ['Operator: goal changed to: write the tests first'], ...}
    

    Both deliveries were accepted — each got its own Update ID, so history shows two WORKFLOW_EXECUTION_UPDATE_ACCEPTED / ..._COMPLETED pairs — but the handler found cmd-9f1e in processed_command_ids the second time and returned the current goal without writing again: one goal changed to note.

  3. The Service's own dedupe, for comparison: add --reuse-update-id, which uses command_id as the Temporal Update ID too. Now history shows one accepted/completed pair (update_id=cmd-9f1e) for the two sends: the Service answered the retry from the first result without running the handler. That dedupe holds only within one run (module 6) and only when the client reuses the ID; processed_command_ids is what covers the rest.

5.4 Retry a Signal; watch it dedupe

python control.py inject "Prefer sources after 2025" --command-id cmd-7f3a --twice

send 1: inject_context('Prefer sources after 2025', 'cmd-7f3a') recorded
send 2: inject_context('Prefer sources after 2025', 'cmd-7f3a') recorded
status: {..., 'notes': [..., 'Operator: Prefer sources after 2025'], 'processed_command_ids': ['cmd-7f3a', 'cmd-9f1e'], ...}

Both sends were recorded — history shows two WORKFLOW_EXECUTION_SIGNALED inject_context Events, each followed by its own Workflow Task — and the note appears once. A Signal has no identity beyond its payload, so the Service cannot do this for you; the command_id in Workflow state can, and because it is Workflow state a Worker that replays this history makes the same decision. Resume the run and read the result the starter prints: the note is there exactly once.

5.5 Handlers interleave: two Updates, a pause, and a continue-as-new

enrich_context(text, command_id) is an Update whose handler awaits an Activity (expand_note, ~6 s): the model expands an operator's note before it is folded in. While it awaits, the handler has yielded — the main loop, a pause, a second enrich_context and a continue-as-new can all happen underneath it. request_continue_as_new is a Signal that asks the main loop to checkpoint into a fresh run (module 6 does this properly; here it exists so you can end a run under a live handler). Note that the handler never calls continue_as_new itself — it sets a flag and the loop acts on it at the top of the next iteration.

control.py interleave sends the whole scenario back to back: two enrich_context Updates (waiting only for accepted, not for the result), a pause, and a continue-as-new request. Then it waits for the two Update results and reads the new run.

  1. Reproduce the bug. Start a fresh run and send the scenario with draining switched off:

    python starter.py --no-wait
    python control.py interleave --no-drain
    
    update 1: enrich_context('Prefer primary sources') accepted; handler now awaiting expand_note
    update 2: enrich_context('Cite line numbers') accepted; handler now awaiting expand_note
    pause signalled
    request_continue_as_new(drain=False) signalled
    update 'Prefer primary sources': LOST — WorkflowUpdateFailedError: AcceptedUpdateCompletedWorkflow: Workflow Update failed because the Workflow completed before the Update completed.
    update 'Cite line numbers': LOST — WorkflowUpdateFailedError: AcceptedUpdateCompletedWorkflow: Workflow Update failed because the Workflow completed before the Update completed.
    old run 01a091a0 closed as CONTINUED_AS_NEW; latest run 947a4ce2 is RUNNING
    status of the new run: {'goal': ..., 'notes': [], 'paused': True, 'processed_command_ids': [], 'status': 'running', 'step': 2}
    

    Read what happened. Both Updates were accepted — history --run-id <old run> (the script prints the full command) shows two WORKFLOW_EXECUTION_UPDATE_ACCEPTED enrich_context Events, then the pause Signal, then request_continue_as_new, then WORKFLOW_EXECUTION_CONTINUED_AS_NEW and nothing else: no ..._UPDATE_COMPLETED. The first handler was awaiting expand_note and the second was waiting on the lock when the loop continued-as-new underneath them. Both callers got an error, and the new run has no notes and an empty processed_command_ids — so a client that retries those two Updates with the same command_id will not be deduplicated either; the ids died with the handlers. The Worker's log says the same thing: UnfinishedUpdateHandlersWarning: [TMPRL1102] Workflow finished while update handlers are still running, followed by Activity not found on completion when expand_note finished ~6 s later and tried to report to a run that no longer existed. The Activity ran to completion; its result was thrown away.

  2. Fix it. The fix is one line in the main loop, before continue_as_new (and before any return): await workflow.wait_condition(workflow.all_handlers_finished). It is already in workflows.py, guarded by the flag the Signal sets. Start a fresh run and send the scenario with draining on:

    python starter.py --no-wait --id agent-42-drain
    python control.py interleave --id agent-42-drain
    

    text update 'Prefer primary sources': handler returned 'Prefer primary sources (expanded)' update 'Cite line numbers': handler returned 'Cite line numbers (expanded)' old run 01a091a0 closed as CONTINUED_AS_NEW; latest run b0e4237e is RUNNING status of the new run: {..., 'notes': ['Operator: Prefer primary sources (expanded)', 'Operator: Cite line numbers (expanded)'], 'paused': True, 'processed_command_ids': ['cmd-01a0-1', 'cmd-01a0-2'], 'status': 'running', 'step': 2}

    The loop saw the continue-as-new request, waited about twelve seconds for both handlers (the lock serialized them, so the second expand_note started when the first finished), and only then continued. Both callers got their result; the new run carries both notes and both ids in its snapshot — the WORKFLOW_EXECUTION_STARTED Event of the new run has them as run()'s second argument. The pause carried over too ('paused': True); python control.py resume lets it finish.

  3. Why the lock. Read enrich_context: the dedupe check happens before the await and the id is recorded after it. Without async with self.lock, two deliveries of the same command_id arriving together would both pass the check, both await, and both write — the command_id discipline of 5.4 only holds when the check-await-write sequence cannot interleave with itself. The lock serializes the handlers with each other; it does not serialize them with the main loop, which is why the loop drains handlers explicitly rather than taking the lock.

Three rules, then: handlers interleave with the loop and with each other; the main method drains them before it returns or continues-as-new; nothing calls continue_as_new from a handler.

The recurring question

If every Worker process dies right now, what survives? Every Signal and every accepted Update, as Events; processed_command_ids, because replay re-applies those handlers in order. What does not: Query answers, and rejected Updates — nothing was written. When a Worker comes back it replays to the wait_condition it was blocked on and continues; a resume sent during the gap is already waiting in history. A handler that was awaiting an Activity replays into the same await — a Worker death does not lose it; only ending the run underneath it does (5.5).

Check yourself

python -m pytest tests/ -q runs the same five claims against the time-skipping test server: the pause holds across ten skipped minutes, the rejected Update leaves no Update Event, the retried Update returns the same goal without a second write, the retried Signal is recorded twice and applied once, and a continue-as-new under a live handler loses it without the drain and keeps it with the drain.

What you learned

  • Signals are recorded by the Service even when the Worker is absent; Queries need a Worker and do not add history.
  • An Update validator can reject a request before it is recorded, while a handler can deduplicate a retried command ID.
  • A handler still awaiting an Activity must be drained before continue-as-new closes its run.

Questions to answer

  1. Which control operation still succeeds with every Worker stopped, and where is it recorded?
  2. Why does status time out without a Worker while resume returns?
  3. What appears in history for a rejected change_goal Update?
  4. What state must be carried across continue-as-new so retried commands remain idempotent?

Files

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

05-signals-queries-updates/control.py
"""Send messages to a running AgentRun from the command line.

    python control.py pause|resume|cancel|status|history [--id agent-42] [--run-id RUN]
    python control.py inject "Prefer sources after 2025" --command-id cmd-7f3a [--twice]
    python control.py change-goal "write the tests first" --command-id cmd-9f1e [--twice]
    python control.py interleave [--no-drain]          # 5.5: two slow Updates + pause + continue-as-new

`--twice` simulates a client whose connection broke after the first send: it sends the
same message again with the same command_id, exactly as a retry loop would.
"""
from __future__ import annotations

import argparse
import asyncio
import os
import sys
from datetime import timedelta

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from common import connect  # noqa: E402
from temporalio.client import Client, WorkflowUpdateFailedError, WorkflowUpdateStage  # noqa: E402
from temporalio.api.enums.v1 import EventType  # noqa: E402
from temporalio.service import RPCError  # noqa: E402
from workflows import AgentRun  # noqa: E402


async def history(client: Client, workflow_id: str, run_id: str | None = None) -> None:
    """Like common.show_history, plus the detail the lab reads: which Signal, which Update, which Activity.
    Without run_id: the latest run of the ID (after continue-as-new, that is the new one)."""
    handle = client.get_workflow_handle(workflow_id, run_id=run_id)
    async for ev in handle.fetch_history_events():
        name = EventType.Name(ev.event_type).removeprefix("EVENT_TYPE_")
        detail = ""
        if ev.HasField("workflow_execution_signaled_event_attributes"):
            detail = ev.workflow_execution_signaled_event_attributes.signal_name
        elif ev.HasField("workflow_execution_update_accepted_event_attributes"):
            a = ev.workflow_execution_update_accepted_event_attributes
            detail = f"{a.accepted_request.input.name} update_id={a.protocol_instance_id}"
        elif ev.HasField("workflow_execution_update_completed_event_attributes"):
            detail = f"update_id={ev.workflow_execution_update_completed_event_attributes.meta.update_id}"
        elif ev.HasField("activity_task_scheduled_event_attributes"):
            detail = ev.activity_task_scheduled_event_attributes.activity_type.name
        elif ev.HasField("workflow_task_started_event_attributes"):
            detail = f"identity={ev.workflow_task_started_event_attributes.identity}"
        print(f"{ev.event_id:4d}  {name:38s} {detail}")


async def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("verb", choices=["pause", "resume", "cancel", "status", "history", "inject", "change-goal",
                                     "interleave"])
    ap.add_argument("text", nargs="?", default="", help="the note (inject) or the new goal (change-goal)")
    ap.add_argument("--id", default=os.environ.get("WORKFLOW_ID", "agent-42"))
    ap.add_argument("--run-id", default=None, help="history only: a specific run (e.g. the old run after continue-as-new)")
    ap.add_argument("--command-id", default=None, help="idempotency key chosen once per command; reused on retry")
    ap.add_argument("--twice", action="store_true", help="send the same message twice (simulated retry)")
    ap.add_argument("--reuse-update-id", action="store_true",
                    help="change-goal only: also reuse command_id as the Temporal Update ID, so the Service dedupes it")
    ap.add_argument("--no-drain", action="store_true",
                    help="interleave only: continue-as-new without waiting for handlers (reproduces the lost handler)")
    args = ap.parse_args()

    client = await connect()
    handle = client.get_workflow_handle_for(AgentRun.run, args.id)
    sends = 2 if args.twice else 1

    try:
        await dispatch(ap, args, client, handle, sends)
    except RPCError as e:
        if e.status.name == "NOT_FOUND":
            print(f"{e.message}: {args.id!r} is not running — a Signal or Update needs a live execution")
        else:
            raise


async def dispatch(ap, args, client: Client, handle, sends: int) -> None:
    if args.verb == "pause":
        await handle.signal(AgentRun.pause)                     # returns once the Service recorded it
        print("pause signalled (recorded in history; no Worker needed to accept it)")
    elif args.verb == "resume":
        await handle.signal(AgentRun.resume)
        print("resume signalled")
    elif args.verb == "cancel":
        await handle.signal(AgentRun.cancel_run)
        print("cancel_run signalled — the loop exits at its next check; the execution closes as Completed")
    elif args.verb == "status":
        try:
            print(await handle.query(AgentRun.status, rpc_timeout=timedelta(seconds=10)))
        except RPCError as e:
            print(f"query failed: {e.status.name}: {e.message} — a Query is answered by a Worker; is one running?")
    elif args.verb == "history":
        await history(client, args.id, args.run_id)
    elif args.verb == "inject":
        if not args.command_id:
            ap.error("inject needs --command-id")
        for i in range(sends):
            await handle.signal(AgentRun.inject_context, args=[args.text, args.command_id])
            print(f"send {i + 1}: inject_context({args.text!r}, {args.command_id!r}) recorded")
        print("status:", await handle.query(AgentRun.status))
    elif args.verb == "change-goal":
        if not args.command_id:
            ap.error("change-goal needs --command-id")
        for i in range(sends):
            try:
                new_goal = await handle.execute_update(
                    AgentRun.change_goal, args=[args.text, args.command_id],
                    id=args.command_id if args.reuse_update_id else None,
                )
                print(f"send {i + 1}: accepted; handler returned {new_goal!r}")
            except WorkflowUpdateFailedError as e:
                print(f"send {i + 1}: rejected: {type(e.cause).__name__}: {e.cause}")
        print("status:", await handle.query(AgentRun.status))
    elif args.verb == "interleave":
        await interleave(client, handle, drain=not args.no_drain)


async def interleave(client: Client, handle, drain: bool) -> None:
    """5.5: two `enrich_context` Updates (each awaits a slow Activity), a `pause`, and a
    continue-as-new request, sent back to back. Then wait for the Updates and read the new run."""
    old_run = (await handle.describe()).run_id
    tag = old_run[:4]
    updates = []
    for n, text in enumerate(["Prefer primary sources", "Cite line numbers"], start=1):
        u = await handle.start_update(                          # accepted = the Service has it; not finished
            AgentRun.enrich_context, args=[text, f"cmd-{tag}-{n}"], wait_for_stage=WorkflowUpdateStage.ACCEPTED,
        )
        updates.append((text, u))
        print(f"update {n}: enrich_context({text!r}) accepted; handler now awaiting expand_note")
    await handle.signal(AgentRun.pause)
    print("pause signalled")
    await handle.signal(AgentRun.request_continue_as_new, drain)
    print(f"request_continue_as_new(drain={drain}) signalled")
    for text, u in updates:
        try:
            print(f"update {text!r}: handler returned {await u.result()!r}")
        except Exception as e:                                  # noqa: BLE001 — print what the caller sees
            cause = getattr(e, "cause", None)
            print(f"update {text!r}: LOST — {type(e).__name__}: {cause or e}")
    old = await client.get_workflow_handle(handle.id, run_id=old_run).describe()
    latest = await handle.describe()                                # the handle has no run_id: latest run
    print(f"old run {old_run[:8]} closed as {old.status.name}; latest run {latest.run_id[:8]} is {latest.status.name}")
    print("status of the new run:", await handle.query(AgentRun.status))
    print(f"hint: `python control.py history --run-id {old_run}` reads the old run; plain `history` the new one.")
    print("      `python control.py resume` when you are done reading.")


if __name__ == "__main__":
    asyncio.run(main())
05-signals-queries-updates/starter.py
"""Start AgentRun and wait for its result. Talk to it from another terminal with control.py."""
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, 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"), help="Workflow ID (= agent ID)")
    ap.add_argument("--goal", default="summarize the Temporal docs")
    ap.add_argument("--no-wait", action="store_true", help="start and return; do not wait for the result")
    args = ap.parse_args()

    client = await connect()
    handle = await start_workflow(client, AgentRun.run, args.goal, 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:")
    print(await handle.result())


if __name__ == "__main__":
    asyncio.run(main())
05-signals-queries-updates/tests/test_lab.py
"""Lab 5 self-check. Runs against the time-skipping test server (no dev server needed):

    python -m pytest tests/ -q
"""
from __future__ import annotations

import asyncio
import os
import sys
import uuid

import pytest

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

from temporalio.client import WorkflowUpdateFailedError, WorkflowUpdateStage  # noqa: E402
from temporalio.service import RPCError  # noqa: E402
from temporalio.testing import WorkflowEnvironment  # noqa: E402
from temporalio.worker import Worker  # noqa: E402
from workflows import AgentRun, call_llm, expand_note  # noqa: E402


async def _update_events(handle) -> list[str]:
    """Update IDs of every accepted Update in history. A rejected Update writes no Update Event
    (the dev server writes nothing at all; the time-skipping test server records the Workflow
    Task that ran the validator, which is why this counts Update Events and not all Events)."""
    return [
        ev.workflow_execution_update_accepted_event_attributes.protocol_instance_id
        async for ev in handle.fetch_history_events()
        if ev.HasField("workflow_execution_update_accepted_event_attributes")
    ]


async def _signal_names(handle) -> list[str]:
    return [
        ev.workflow_execution_signaled_event_attributes.signal_name
        async for ev in handle.fetch_history_events()
        if ev.HasField("workflow_execution_signaled_event_attributes")
    ]


async def _start(env: WorkflowEnvironment, task_queue: str):
    return await env.client.start_workflow(
        AgentRun.run, "summarize the Temporal docs", id=f"lab05-test-{uuid.uuid4()}", task_queue=task_queue
    )


def test_pause_blocks_the_loop_and_resume_releases_it() -> None:
    async def go() -> None:
        async with await WorkflowEnvironment.start_time_skipping() as env:
            tq = f"lab-05-test-{uuid.uuid4()}"
            async with Worker(env.client, task_queue=tq, workflows=[AgentRun], activities=[call_llm]):
                handle = await _start(env, tq)
                await handle.signal(AgentRun.pause)
                status = await handle.query(AgentRun.status)
                assert status["paused"] is True and status["status"] == "running"
                await env.sleep(60)                      # the step in flight finishes; the loop blocks
                step_when_paused = (await handle.query(AgentRun.status))["step"]
                assert step_when_paused <= 1
                await env.sleep(600)                     # ten minutes pass; nothing moves while paused
                assert (await handle.query(AgentRun.status))["step"] == step_when_paused
                await handle.signal(AgentRun.resume)
                await handle.result()                    # time-skipping runs the 20 timers instantly
                final = await handle.query(AgentRun.status)
                assert final["status"] == "done" and final["paused"] is False
                assert (await _signal_names(handle)) == ["pause", "resume"]

    asyncio.run(go())


def test_rejected_update_leaves_no_event_and_accepted_update_returns_the_goal() -> None:
    async def go() -> None:
        async with await WorkflowEnvironment.start_time_skipping() as env:
            tq = f"lab-05-test-{uuid.uuid4()}"
            async with Worker(env.client, task_queue=tq, workflows=[AgentRun], activities=[call_llm]):
                handle = await _start(env, tq)
                await handle.signal(AgentRun.pause)
                await env.sleep(60)                      # the step in flight finishes; the loop blocks
                assert (await handle.query(AgentRun.status))["paused"] is True
                assert await _update_events(handle) == []
                with pytest.raises(WorkflowUpdateFailedError) as exc:
                    await handle.execute_update(AgentRun.change_goal, args=["   ", "cmd-9f20"])
                assert "goal must not be empty" in str(exc.value.cause)
                assert await _update_events(handle) == []         # rejected: no Update Event, ever

                goal = await handle.execute_update(AgentRun.change_goal, args=["write the tests first", "cmd-9f1e"])
                assert goal == "write the tests first"
                again = await handle.execute_update(AgentRun.change_goal, args=["write the tests first", "cmd-9f1e"])
                assert again == "write the tests first"           # retried: same answer, no second write
                assert len(await _update_events(handle)) == 2     # both deliveries accepted; one write
                status = await handle.query(AgentRun.status)
                assert status["goal"] == "write the tests first"
                assert status["notes"] == ["Operator: goal changed to: write the tests first"]
                await handle.signal(AgentRun.cancel_run)
                await handle.result()

    asyncio.run(go())


def test_retried_signal_is_recorded_twice_and_applied_once() -> None:
    async def go() -> None:
        async with await WorkflowEnvironment.start_time_skipping() as env:
            tq = f"lab-05-test-{uuid.uuid4()}"
            async with Worker(env.client, task_queue=tq, workflows=[AgentRun], activities=[call_llm]):
                handle = await _start(env, tq)
                await handle.signal(AgentRun.pause)
                for _ in range(2):                       # the client's connection "broke"; it re-sends
                    await handle.signal(AgentRun.inject_context, args=["Prefer sources after 2025", "cmd-7f3a"])
                status = await handle.query(AgentRun.status)
                assert status["notes"] == ["Operator: Prefer sources after 2025"]
                assert status["processed_command_ids"] == ["cmd-7f3a"]
                assert (await _signal_names(handle)).count("inject_context") == 2
                await handle.signal(AgentRun.cancel_run)
                result = await handle.result()
                assert "Operator: Prefer sources after 2025" in result.splitlines()
                assert (await handle.query(AgentRun.status))["status"] == "cancelled"

    asyncio.run(go())


@pytest.mark.parametrize("drain", [False, True])
def test_continue_as_new_under_a_live_handler_loses_it_unless_drained(drain: bool) -> None:
    async def go() -> None:
        async with await WorkflowEnvironment.start_time_skipping() as env:
            tq = f"lab-05-test-{uuid.uuid4()}"
            async with Worker(env.client, task_queue=tq, workflows=[AgentRun], activities=[call_llm, expand_note]):
                handle = await _start(env, tq)
                first_run = (await handle.describe()).run_id
                updates = [
                    await handle.start_update(AgentRun.enrich_context, args=[text, f"cmd-{n}"],
                                              wait_for_stage=WorkflowUpdateStage.ACCEPTED)
                    for n, text in enumerate(["Prefer primary sources", "Cite line numbers"])
                ]
                await handle.signal(AgentRun.pause)
                await handle.signal(AgentRun.request_continue_as_new, drain)
                outcomes = []
                for u in updates:
                    try:
                        outcomes.append(await u.result())
                    except (WorkflowUpdateFailedError, RPCError) as e:
                        # dev server 1.31: WorkflowUpdateFailedError (AcceptedUpdateCompletedWorkflow);
                        # the time-skipping test server: RPCError "workflow execution already completed"
                        outcomes.append(f"lost: {type(e).__name__}")
                status = await handle.query(AgentRun.status)           # the new run
                assert (await handle.describe()).run_id != first_run
                assert status["paused"] is True                         # the pause rode along
                if drain:
                    assert outcomes == ["Prefer primary sources (expanded)", "Cite line numbers (expanded)"]
                    assert status["processed_command_ids"] == ["cmd-0", "cmd-1"]
                    assert len(status["notes"]) == 2
                else:
                    assert all(o.startswith("lost:") for o in outcomes)                # both lost
                    assert status["notes"] == [] and status["processed_command_ids"] == []
                await handle.signal(AgentRun.cancel_run)
                await handle.result()

    asyncio.run(go())
05-signals-queries-updates/worker.py
import asyncio
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, expand_note  # noqa: E402


async def main() -> None:
    client = await connect()
    worker = Worker(
        client,
        task_queue=TASK_QUEUE,
        workflows=[AgentRun],
        activities=[call_llm, expand_note],
    )
    print(f"worker pid={os.getpid()} polling {TASK_QUEUE!r}; kill -9 {os.getpid()} to crash it")
    await worker.run()


if __name__ == "__main__":
    asyncio.run(main())
05-signals-queries-updates/workflows.py
"""Lab 5: AgentRun, stage 5 — the control surface.

Stage 4 gave AgentRun timeouts, retries and idempotency keys. Stage 5 lets a
client talk to it while it runs: Signals `pause`, `resume`, `cancel_run`,
`inject_context(text, command_id)`; the Query `status()`; the Update
`change_goal(goal, command_id)` with a validator. Handlers are Workflow code —
same determinism rules, and they interleave with the main loop.

The loop itself is unchanged in spirit: plan a step, call the model, fold the
answer into `context_summary`, wait a durable timer, repeat. The one new line
that matters is the `wait_condition` at the top of the loop: that is where a
paused agent waits, and while it waits no Worker needs to exist.

Exercise 5.5 adds the interleaving case: `enrich_context` is an Update whose
handler awaits an Activity, so it runs concurrently with the main loop and with
other handlers; `request_continue_as_new` makes the loop checkpoint into a fresh
run (module 6 does this properly) so you can see what happens to a handler that
is still awaiting when the run ends.
"""
from __future__ import annotations

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

from temporalio import activity, workflow

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from common import TASK_QUEUE  # noqa: E402,F401  (re-exported for worker.py / starter.py)

MAX_STEPS = 20      # the loop finishes on its own after this many steps (~45 s)
STEP_SECONDS = 2    # durable timer between steps — long enough to send messages by hand
NOTE_SECONDS = 6    # how long `expand_note` takes: long enough to continue-as-new underneath it (5.5)


@dataclass
class AgentState:
    """The snapshot continue-as-new carries into the next run (5.5). Module 6 makes this the
    real thing; here it exists so the new run keeps its step, its notes and its dedupe set."""
    step: int
    context_summary: str = ""
    paused: bool = False
    processed_command_ids: list[str] = field(default_factory=list)


@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 1 fakes the model; stage 4 put a
    cache behind `key`). Kept trivial so the lab is about the control surface."""
    activity.logger.info("call_llm key=%s", key)
    return f"[model answer to: {prompt[:48]}]"


@activity.defn
async def expand_note(text: str) -> str:
    """A slow Activity an Update handler awaits (5.5): the model expands an operator's note."""
    activity.logger.info("expand_note %r", text)
    await asyncio.sleep(NOTE_SECONDS)
    return f"{text} (expanded)"


def fold(summary: str, answer: str) -> str:
    """Workflow code: pure string work. Keep every operator note, replace the
    model's last answer with the new one. (A real agent would summarize.)"""
    notes = [line for line in summary.splitlines() if line.startswith("Operator:")]
    return "\n".join(notes + [answer])


@workflow.defn
class AgentRun:
    def __init__(self) -> None:
        self.step = 0
        self.status = "running"          # running | done | cancelled
        self.goal = ""
        self.context_summary = ""
        self.attempts = 0
        self.paused = False
        self.processed_command_ids: set[str] = set()   # Workflow state: rebuilt by replay
        self.lock = asyncio.Lock()                     # 5.5: one mutating handler at a time
        self.continue_requested = False                # 5.5: set by a Signal; acted on by the loop
        self.drain_before_continue = True              # 5.5: the fix; the lab turns it off first

    def snapshot(self) -> AgentState:
        return AgentState(
            step=self.step, context_summary=self.context_summary, paused=self.paused,
            processed_command_ids=sorted(self.processed_command_ids),
        )

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

    @workflow.run
    async def run(self, goal: str, state: AgentState | None = None) -> str:
        self.goal = goal
        if state is not None:                           # 5.5: the run after a continue-as-new
            self.step = state.step
            self.context_summary = state.context_summary
            self.paused = state.paused
            self.processed_command_ids = set(state.processed_command_ids)
        while self.status == "running":
            # pause: the main loop blocks here. Nothing polls; no Worker is required.
            await workflow.wait_condition(
                lambda: not self.paused or self.status != "running" or self.continue_requested
            )
            if self.continue_requested:                 # 5.5: checkpoint into a fresh run
                if self.drain_before_continue:
                    # The fix: a handler still awaiting an Activity finishes before the run ends.
                    await workflow.wait_condition(workflow.all_handlers_finished)
                workflow.continue_as_new(args=[self.goal, self.snapshot()])   # nothing after this runs
            if self.status != "running":
                break                                   # cancel_run: exit cleanly (true Cancellation is lab 7)
            step = self.plan()
            if step.kind == "finish":
                self.status = "done"
                break
            key = f"{workflow.info().workflow_id}:{self.step}:{hashlib.sha256(step.prompt.encode()).hexdigest()[:12]}"
            answer = await workflow.execute_activity(
                call_llm, args=[step.prompt, key], start_to_close_timeout=timedelta(seconds=10)
            )
            self.context_summary = fold(self.context_summary, answer)
            self.step += 1
            await asyncio.sleep(STEP_SECONDS)           # TimerStarted / TimerFired
        # Drain: an `async` handler still awaiting something must not be cut off by our return.
        await workflow.wait_condition(workflow.all_handlers_finished)
        return self.context_summary

    # ---- Signals: asynchronous writes, recorded as WorkflowExecutionSignaled ----

    @workflow.signal
    def pause(self) -> None:
        self.paused = True

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

    @workflow.signal
    def cancel_run(self) -> None:
        self.status = "cancelled"                       # the loop exits at its next check

    @workflow.signal
    def request_continue_as_new(self, drain: bool = True) -> None:
        """5.5: ask the main loop to continue-as-new. Never call continue_as_new from a handler;
        the loop does it. `drain=False` reproduces the bug (the run ends under a live handler)."""
        self.drain_before_continue = drain
        self.continue_requested = True

    @workflow.signal
    def inject_context(self, text: str, command_id: str) -> None:
        if command_id in self.processed_command_ids:    # the client retried after a broken connection
            return                                      # the first delivery already did the work
        self.processed_command_ids.add(command_id)
        self.context_summary += f"\nOperator: {text}"

    # ---- Query: a synchronous read; answered by a live Worker; never in history ----

    @workflow.query
    def status(self) -> dict:
        return {
            "step": self.step,
            "status": self.status,
            "paused": self.paused,
            "goal": self.goal,
            "notes": [l for l in self.context_summary.splitlines() if l.startswith("Operator:")],
            "processed_command_ids": sorted(self.processed_command_ids),
        }

    # ---- Update: a synchronous, tracked write; validated first ----

    @workflow.update
    async def change_goal(self, goal: str, command_id: str) -> str:
        if command_id in self.processed_command_ids:    # a retried Update: same answer, no second write
            return self.goal
        self.processed_command_ids.add(command_id)
        self.goal = goal
        self.context_summary += f"\nOperator: goal changed to: {goal}"
        return self.goal

    @change_goal.validator
    def _check_goal(self, goal: str, command_id: str) -> None:
        if not goal.strip():
            raise ValueError("goal must not be empty")  # rejected: never enters history

    # ---- Update whose handler awaits an Activity (5.5): it interleaves with everything else ----

    @workflow.update
    async def enrich_context(self, text: str, command_id: str) -> str:
        async with self.lock:                           # a second enrich_context waits here
            if command_id in self.processed_command_ids:
                return "ignored"                        # a retry after the first one finished
            expanded = await workflow.execute_activity(  # the handler yields: the loop, a pause, a
                expand_note, text, start_to_close_timeout=timedelta(seconds=30)   # continue-as-new can all happen here
            )
            self.processed_command_ids.add(command_id)  # after the await, under the lock: a retry
            self.context_summary += f"\nOperator: {expanded}"   # that arrived meanwhile is deduped
            return expanded

    @enrich_context.validator
    def _check_note(self, text: str, command_id: str) -> None:
        if not text.strip():
            raise ValueError("note must not be empty")

Lab source: labs/05-signals-queries-updates in the course repository. Verified against temporal server start-dev with the Python SDK 1.32 on 2026-09-11.