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/activatebefore copying anypython ...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".
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:
python control.py statusa 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.python control.py pause, thenstatusagain:'paused': True,'status': 'running', andstepstops 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, thenstatusthree 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.)-
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_COMPLETEDThe 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_conditionand blocked. That is what a paused Workflow looks like — the last Event is a completed Workflow Task with nothing after it. -
python control.py cancel. Thecancel_runhandler setsself.status = "cancelled"; the loop exits at its next check andrun()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
- Start a fresh run (
python starter.py), let it take a couple of steps,python control.py pause. - Kill the Worker:
kill -9 <pid>(from terminal 1's banner). Confirm nothing is polling:pgrep -af worker.pylists no lab-05 Worker, and the UI shows no Workers onlab-05. python control.py status. It hangs and then fails —query failed: CANCELLED: Timeout expiredafter the 10 s client timeout (we also sawFAILED_PRECONDITIONonce, 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.-
python control.py resume. It returns immediately: the Service recorded the Signal and needs no Worker to do so.python control.py historynow ends like this:31 WORKFLOW_EXECUTION_SIGNALED resume 32 WORKFLOW_TASK_SCHEDULEDA 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.
-
Start a Worker again (
python worker.py). Within a second,statusanswers andstepclimbs. 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_llmIn 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 = Falsefrom theresumeSignal, and issued the nextcall_llmcommand.
5.3 change_goal as an Update with a validator
Pause the run first so the loop holds still (control.py pause), then:
-
A rejected call:
python control.py change-goal " " --command-id cmd-9f20send 1: rejected: ApplicationError: ValueError: goal must not be emptyThe validator raised; the client got
WorkflowUpdateFailedErrorwith theValueErroras its cause — an error in the caller, not a log line on the Worker. Runhistory | wc -lbefore 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. -
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 --twicesend 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/..._COMPLETEDpairs — but the handler foundcmd-9f1einprocessed_command_idsthe second time and returned the current goal without writing again: onegoal changed tonote. -
The Service's own dedupe, for comparison: add
--reuse-update-id, which usescommand_idas 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_idsis 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.
-
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-drainupdate 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 twoWORKFLOW_EXECUTION_UPDATE_ACCEPTED enrich_contextEvents, then thepauseSignal, thenrequest_continue_as_new, thenWORKFLOW_EXECUTION_CONTINUED_AS_NEWand nothing else: no..._UPDATE_COMPLETED. The first handler was awaitingexpand_noteand 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 emptyprocessed_command_ids— so a client that retries those two Updates with the samecommand_idwill 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 byActivity not found on completionwhenexpand_notefinished ~6 s later and tried to report to a run that no longer existed. The Activity ran to completion; its result was thrown away. -
Fix it. The fix is one line in the main loop, before
continue_as_new(and before anyreturn):await workflow.wait_condition(workflow.all_handlers_finished). It is already inworkflows.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-draintext 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_notestarted 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 — theWORKFLOW_EXECUTION_STARTEDEvent of the new run has them asrun()'s second argument. The pause carried over too ('paused': True);python control.py resumelets it finish. -
Why the lock. Read
enrich_context: the dedupe check happens before the await and the id is recorded after it. Withoutasync with self.lock, two deliveries of the samecommand_idarriving together would both pass the check, both await, and both write — thecommand_iddiscipline 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
- Which control operation still succeeds with every Worker stopped, and where is it recorded?
- Why does
statustime out without a Worker whileresumereturns? - What appears in history for a rejected
change_goalUpdate? - 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.