COURSE LAB

Lab 7: Cancellation and Compensation

Objective

Why: Make a multi-step agent undo completed effects when a later step fails.

By the end: Observe the compensation order after a crash and test how cancellation reaches a running Activity.

Before you run

  • Mechanism: A saga stores enough information to compensate completed effects in reverse order. Cancellation is a request that Workflow code handles; an Activity notices it through heartbeats.
  • Predict: Predict which undo runs first when step four fails, and whether a non-heartbeating Activity stops immediately on cancel.
  • Look for: Read the compensation order in history, then compare cancel with terminate and observe whether cleanup actually ran.
  • 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 lab 6 plus, in workflows.py: gpu_lane() (four Activities, each success appending its undo to self.compensations), rollback() (the list in reverse, run from finally), and a crunch tool that is deliberately long. Reading page: "Cancellation and Compensation".

Open this lab in the browser workspace

Complete the one-time setup in Lab 1, then open Lab 07 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: make AgentRun undo what it did when step four of four fails, prove the undo list survives a Worker death, and then find out what handle.cancel() does — and does not do — to an Activity that is already running.

Setup

cd 07-cancellation-and-sagas      # from the temporal-course-labs folder
export TASK_QUEUE=lab-07
python worker.py                     # terminal 1 — the Activities narrate what they do; read this terminal
python starter.py                    # terminal 2 — starts agent-42 with the deploy_model tool

starter.py --tool crunch --cancel-after 4 [--heartbeat] [--shield] is 7.3. --id/WORKFLOW_ID if agent-42 is taken. The starter waits for the run to close, then prints the annotated history.

7.1 The saga: register_endpoint fails, three undos run in reverse

plan() turns a goal that starts with "deploy" into the deploy_model tool, and gpu_lane() runs reserve_gpu → allocate_sandbox → start_model → register_endpoint. The last one raises an ApplicationError every time; its RetryPolicy(maximum_attempts=3) ends it. Run the starter (the status() lines quoted in this lab are trimmed to the keys each step is about):

execution closed with ActivityError: Activity task failed
close status: FAILED
final status(): {'compensations': [], 'rolled_back': ['stop_model', 'release_sandbox', 'release_gpu'], 'status': 'failed', 'step': 1}

  11  ACTIVITY_TASK_SCHEDULED                reserve_gpu
  13  ACTIVITY_TASK_COMPLETED                (scheduled by event 11)
  17  ACTIVITY_TASK_SCHEDULED                allocate_sandbox
  19  ACTIVITY_TASK_COMPLETED                (scheduled by event 17)
  23  ACTIVITY_TASK_SCHEDULED                start_model
  25  ACTIVITY_TASK_COMPLETED                (scheduled by event 23)
  29  ACTIVITY_TASK_SCHEDULED                register_endpoint
  30  ACTIVITY_TASK_STARTED                  attempt=3
  31  ACTIVITY_TASK_FAILED                   endpoint registry rejected the name for model-in-sandbox-on-gpu-for-agent-42
  35  ACTIVITY_TASK_SCHEDULED                stop_model
  41  ACTIVITY_TASK_SCHEDULED                release_sandbox
  47  ACTIVITY_TASK_SCHEDULED                release_gpu
  53  WORKFLOW_EXECUTION_FAILED              Activity task failed

Read it against the code:

  1. Three successes, one ACTIVITY_TASK_FAILED. Attempts 1 and 2 are not Events — ACTIVITY_TASK_STARTED is written only when the Activity closes, and it says attempt=3. The Worker terminal has all three: register_endpoint attempt 1 ... rejected, attempt 2, attempt 3. Retry was configuration; nothing in the Workflow ran between attempts.
  2. Then the undos, in reverse: stop_model, release_sandbox, release_gpu. No unregister_endpoint — it was never appended, because its forward step never succeeded. That is the whole Saga: append after success, run backwards on failure.
  3. The run closed Failed with the original ActivityError, after the rollback. rollback() runs from finally, which is why it also runs for a cancel (7.3).
  4. The undos take their identifiers from what the forward steps returned (gpu-for-agent-42, sandbox-on-gpu-for-agent-42) — the key a second attempt of release_gpu would reuse.

7.2 Kill the Worker during the rollback

Each undo takes 3 s. Start a fresh run and watch the Worker terminal; when it prints release_sandbox ...: attempt 1, starting (the second undo), kill -9 the Worker. In the browser workspace, whose Output refreshes every 3 s — as long as one undo — click Crash Worker as soon as stop_model ... done appears; waiting for the release_sandbox line usually means missing it. Wait ten seconds — nothing moves; the UI shows release_sandbox as a pending Activity of a Running Workflow — then start the Worker again. From the run we recorded (old pid 3800398, new pid 3800679):

old Worker:  09:01:25 stop_model model-in-sandbox-on-gpu-for-agent-42: attempt 1, starting
             09:01:28 stop_model ...: done
             09:01:28 release_sandbox sandbox-on-gpu-for-agent-42: attempt 1, starting        <- kill -9 here
new Worker:  09:01:39 release_sandbox sandbox-on-gpu-for-agent-42: attempt 2, starting
             09:01:42 release_sandbox ...: done
             09:01:42 release_gpu gpu-for-agent-42: attempt 1, starting
             09:01:45 release_gpu ...: done

  35  ACTIVITY_TASK_SCHEDULED                stop_model
  37  ACTIVITY_TASK_COMPLETED                (scheduled by event 35)
  41  ACTIVITY_TASK_SCHEDULED                release_sandbox
  42  ACTIVITY_TASK_STARTED                  attempt=2
  43  ACTIVITY_TASK_COMPLETED                (scheduled by event 41)
  45  WORKFLOW_TASK_STARTED                  identity=3800679@...
  47  ACTIVITY_TASK_SCHEDULED                release_gpu
  53  WORKFLOW_EXECUTION_FAILED              Activity task failed

What happened: the new Worker replayed the history and rebuilt self.compensations from the three recorded successes, then resumed rollback() at the first undo with no result in history — release_sandbox, whose Event 41 already existed. It did not re-run stop_model; that result was in Event 37. The eleven-second gap is STEP_TIMEOUT (10 s: the Service can only learn the first attempt died when its Start-To-Close expires) plus the 1 s retry interval, and attempt=2 is the at-least-once execution the reading warned about: release_sandbox must tolerate running twice.

A saga does not guarantee rollback. rollback() is ordinary durable work: each undo is an Activity with its own retry policy, and an undo that exhausts its retries raises out of rollback() — which this Workflow calls from finally. Two consequences worth designing for before you ship one. The undos after the failing one never run (release_gpu is still owed while release_sandbox is stuck), and the exception from the rollback replaces the forward failure that caused it, so the reason you were rolling back is the one you lose. A production saga keeps both failures, retries an undo far longer than a forward step, records "compensation incomplete" as a first-class state an operator can list, and lets the independent undos proceed rather than stopping at the first one that will not. Lab 11's capstone asks you to decide this for AgentRun.

7.3 The nasty one: cancel a tool that does not heartbeat

The crunch tool runs 20 chunks of one second inside a sandbox (allocate_sandbox first, so there is one undo on the list). --cancel-after 4 makes the starter call handle.cancel() four seconds in. Three runs, and read the Worker terminal as much as the history each time.

a. No heartbeats: python starter.py --tool crunch --cancel-after 4

status before cancel: {'compensations': ['release_sandbox'], 'in_flight': 'crunch', 'status': 'running', 'step': 1}
handle.cancel() sent — a WorkflowExecutionCancelRequested Event; the Workflow sees it at its next await
execution closed with CancelledError: Workflow cancelled
close status: CANCELED
final status(): {'compensations': [], 'in_flight': 'crunch', 'rolled_back': ['release_sandbox'], 'status': 'cancelled', 'step': 1}

  17  ACTIVITY_TASK_SCHEDULED                execute_tool
  18  WORKFLOW_EXECUTION_CANCEL_REQUESTED
  22  ACTIVITY_TASK_CANCEL_REQUESTED         (scheduled by event 17)
  23  ACTIVITY_TASK_SCHEDULED                release_sandbox
  25  ACTIVITY_TASK_COMPLETED                (scheduled by event 23)
  29  WORKFLOW_EXECUTION_CANCELED

The Workflow did everything right: it saw the request at its next await (the tool), asked the Service to cancel the Activity (22), released the sandbox from finally (23–25) — no shield, the one cancellation had already been delivered — and closed Canceled. Now the Worker terminal:

09:02:27 execute_tool crunch: chunk 1/20
09:02:28 execute_tool crunch: chunk 2/20
09:02:29 release_sandbox sandbox-on-agent-42:1:crunch: attempt 1, starting
09:02:29 execute_tool crunch: chunk 3/20
...
09:02:32 release_sandbox ...: done
09:02:32 execute_tool crunch: chunk 6/20
...
09:02:46 execute_tool crunch: chunk 20/20
09:02:46 execute_tool crunch: finished all 20 chunks
WARN temporalio_sdk_core::worker::activities: Activity not found on completion. This may happen if the
     activity has already been cancelled but completed anyway. ... "workflow execution already completed"

The tool ran all twenty chunks after the cancel, past the sandbox it was running in being released, and its result was thrown away. There is no ACTIVITY_TASK_CANCELED in history because nothing ever told the Activity: it never spoke to the Service while it ran, and cancellation only travels on heartbeat responses. You paid for sixteen seconds of a tool nobody wanted.

b. Heartbeats: python starter.py --tool crunch --cancel-after 4 --heartbeat

The diff is two lines: activity.heartbeat(i) in the chunk loop, and heartbeat_timeout=3 s on the call (which also sets the heartbeat send interval to 0.8 × 3 s).

  18  WORKFLOW_EXECUTION_CANCEL_REQUESTED
  22  ACTIVITY_TASK_CANCEL_REQUESTED         (scheduled by event 17)
  23  ACTIVITY_TASK_SCHEDULED                release_sandbox
  25  ACTIVITY_TASK_CANCELED                 (scheduled by event 17)
  30  ACTIVITY_TASK_COMPLETED                (scheduled by event 23)
  34  WORKFLOW_EXECUTION_CANCELED

09:10:02 release_sandbox ...: attempt 1, starting
09:10:04 execute_tool crunch: chunk 4/20
09:10:05 execute_tool crunch: cancelled at chunk 5/20
09:10:05 release_sandbox ...: done

Now there is an ACTIVITY_TASK_CANCELED (25): the request rode back on a heartbeat, the SDK raised CancelledError inside the Activity at its next await, the except logged and re-raised. The tool stopped two to three seconds after the cancel — within one heartbeat interval — and its acknowledgement landed while the rollback was still running. Same Workflow code as (a).

c. Shield the in-flight call: python starter.py --tool crunch --cancel-after 4 --heartbeat --shield

cpu_lane() now awaits asyncio.shield(tool) and, on CancelledError, awaits the tool handle itself before letting the cancel through.

  17  ACTIVITY_TASK_SCHEDULED                execute_tool
  18  WORKFLOW_EXECUTION_CANCEL_REQUESTED
  22  ACTIVITY_TASK_STARTED                  attempt=1
  23  ACTIVITY_TASK_COMPLETED                (scheduled by event 17)
  27  ACTIVITY_TASK_SCHEDULED                release_sandbox
  29  ACTIVITY_TASK_COMPLETED                (scheduled by event 27)
  33  WORKFLOW_EXECUTION_CANCELED

09:09:49 execute_tool crunch: finished all 20 chunks
09:09:49 release_sandbox ...: attempt 1, starting

No ACTIVITY_TASK_CANCEL_REQUESTED at all: the cancel reached the await, not the Activity. The tool completed (23) sixteen seconds after the request (18), then the sandbox was released, then the run closed Canceled. Compare the three histories and say which one you want for a 40-minute fine-tune that is 90% done, and which for a runaway web crawl.

Two details in run() worth reading before you move on. The cancel that lands while an Activity is awaited arrives as ActivityError whose cause is CancelledError — the in-flight Activity's own cancellation — not as a bare asyncio.CancelledError; run() classifies both as "cancelled", and re-raising either closes the execution as Canceled. And the shield awaits the handle (workflow.start_activity(...)), never the cancelled outer future a second time — that one raises CancelledError immediately, which is the mistake our first draft made.

7.3b The three modes, side by side

Activity gets a cancel request? Workflow waits for the tool? What happens to the tool
No heartbeats yes no runs to the end, never learns
Heartbeats yes no sees the request on a heartbeat response, raises
asyncio.shield no yes, deliberately finishes normally

So "does the tool stop?" is not one question about heartbeats. It is two: does the Workflow ask for the Activity to be cancelled at all, and — if it does — does the Activity ever come back to the Service to hear the answer? Shielding answers the first no; a tool with no heartbeat answers the second no.

7.4 Cancel is cooperative. Terminate is not.

Run the crunch case once more, and this time do not cancel it. Terminate it:

python starter.py --tool crunch --no-wait         # terminal 2
docker compose exec -T temporal temporal workflow terminate --workflow-id agent-42 --reason "operator hard stop"

(The temporal CLI lives in the dev server's container. In the browser workspace, terminate from the Python console instead: from common import connect, then await (await connect()).get_workflow_handle("agent-42").terminate(reason="operator hard stop"), or press Terminate on the run in the Web UI.)

Measured on the labs' dev server (Temporal Server 1.31.2, 2026-09), the history simply stops:

17  ACTIVITY_TASK_SCHEDULED        execute_tool
18  WORKFLOW_EXECUTION_TERMINATED

Compare that with 7.3a's cancel, which ran to WORKFLOW_EXECUTION_CANCELED at event 29 through ACTIVITY_TASK_CANCEL_REQUESTED (22) and release_sandbox (23–25). Under terminate there is no cancel request, no finally, no rollback — event 18 is the last thing that will ever be written to this Workflow. Your Workflow code never runs again, so it cannot clean up; self.compensations still listed release_sandbox and nothing released it.

Then read the Worker terminal: execute_tool crunch: chunk 6/20, 7/20, and on. The Activity is a process on a Worker, and nobody told it anything. The sandbox stays allocated until something outside this Workflow reclaims it.

  • Cancel when the Workflow should clean up after itself: it is a request the code observes at its next await, and everything in this lab's finally depends on that.
  • Terminate when you need it stopped now and accept that nothing cooperative happens — then go and reclaim the resources yourself. For an agent runtime holding GPUs, that is an operational decision, not a keyboard shortcut.

The recurring question

If every Worker process dies right now: self.compensations is Workflow state, so a returning Worker rebuilds it from the recorded successes and resumes the rollback at the first undo without a result (7.2). A cancel request already in history is delivered once, at the first await after replay — it is not lost with the Worker. The Activity that was in flight is a Worker-side process: without heartbeats the Service only notices it is gone when Start-To-Close expires; with them, one Heartbeat Timeout later. Keep that apart from cancellation delivery: a cancel request is not waiting on a timeout at all — it rides back on the next heartbeat round-trip, which is why a tool that never heartbeats never hears it. Its effect may have happened either way, which is why every undo is keyed.

Check yourself

python -m pytest tests/ -q — the reverse-order rollback with no unregister_endpoint; a cancel without heartbeats leaving ACTIVITY_TASK_CANCEL_REQUESTED but no ACTIVITY_TASK_CANCELED while the tool finishes every chunk on the Worker; a cancel with heartbeats producing ACTIVITY_TASK_CANCELED and a tool that never finishes; and the shielded tool completing before release_sandbox runs.

What you learned

  • A saga records compensation decisions in Workflow state and runs completed undos in reverse order.
  • Activity cancellation is cooperative; a non-heartbeating tool may keep running after a cancel request.
  • Shielding an in-flight effect changes when compensation begins, and every undo still needs an idempotency key.

Questions to answer

  1. Why is there no unregister_endpoint compensation after registration fails?
  2. After killing the Worker during rollback, which recorded facts let a new Worker continue the undo list?
  3. Why can a cancel request appear in history while the Activity continues to do work?
  4. What difference do heartbeats and shielding make to the final effect and compensation order?

Files

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

07-cancellation-and-sagas/starter.py
"""Start AgentRun with one tool call, optionally cancel it, wait, then print the annotated history.

    python starter.py                                 # 7.1 / 7.2: goal "deploy ..." -> the GPU saga; register_endpoint fails
    python starter.py --tool crunch --cancel-after 4  # 7.3: a 20 s tool with no heartbeat; cancel while it runs
    python starter.py --tool crunch --cancel-after 4 --heartbeat
    python starter.py --tool crunch --cancel-after 4 --heartbeat --shield
"""
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 temporalio.api.enums.v1 import EventType  # noqa: E402
from temporalio.client import Client, WorkflowFailureError  # noqa: E402
from workflows import AgentRun, ToolOptions  # noqa: E402


async def history(client: Client, workflow_id: str) -> None:
    handle = client.get_workflow_handle(workflow_id)
    async for ev in handle.fetch_history_events():
        name = EventType.Name(ev.event_type).removeprefix("EVENT_TYPE_")
        detail = ""
        if ev.HasField("activity_task_scheduled_event_attributes"):
            detail = ev.activity_task_scheduled_event_attributes.activity_type.name
        elif ev.HasField("activity_task_started_event_attributes"):
            detail = f"attempt={ev.activity_task_started_event_attributes.attempt}"
        elif ev.HasField("activity_task_failed_event_attributes"):
            detail = ev.activity_task_failed_event_attributes.failure.message
        elif ev.HasField("activity_task_timed_out_event_attributes"):
            detail = ev.activity_task_timed_out_event_attributes.failure.message
        elif ev.HasField("workflow_task_started_event_attributes"):
            detail = f"identity={ev.workflow_task_started_event_attributes.identity}"
        elif ev.HasField("workflow_execution_failed_event_attributes"):
            detail = ev.workflow_execution_failed_event_attributes.failure.message
        which = ev.WhichOneof("attributes")
        if which in ("activity_task_completed_event_attributes", "activity_task_canceled_event_attributes",
                     "activity_task_failed_event_attributes", "activity_task_timed_out_event_attributes",
                     "activity_task_cancel_requested_event_attributes"):
            detail = f"{detail} (scheduled by event {getattr(ev, which).scheduled_event_id})".strip()
        print(f"{ev.event_id:4d}  {name:38s} {detail}")


async def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--id", default=os.environ.get("WORKFLOW_ID", "agent-42"))
    ap.add_argument("--tool", choices=["deploy_model", "crunch"], default="deploy_model")
    ap.add_argument("--heartbeat", action="store_true", help="execute_tool heartbeats every chunk")
    ap.add_argument("--shield", action="store_true", help="AgentRun shields the in-flight execute_tool")
    ap.add_argument("--chunks", type=int, default=20, help="length of the crunch tool in seconds")
    ap.add_argument("--cancel-after", type=float, default=0, help="seconds after start to call handle.cancel()")
    ap.add_argument("--no-wait", action="store_true")
    args = ap.parse_args()

    goal = "deploy the fine-tuned model" if args.tool == "deploy_model" else "crunch the eval set"
    options = ToolOptions(heartbeat=args.heartbeat, shield=args.shield, chunks=args.chunks)

    client = await connect()
    handle = await start_workflow(client, AgentRun.run, args=[goal, options], id=args.id, task_queue=TASK_QUEUE)
    print(f"started {handle.id} run={handle.result_run_id} tool={args.tool} options={options}")
    print(f"open http://localhost:8233/namespaces/default/workflows/{handle.id}")
    if args.no_wait:
        return

    if args.cancel_after > 0:
        await asyncio.sleep(args.cancel_after)
        print(f"status before cancel: {await handle.query(AgentRun.status)}")
        await handle.cancel()
        print("handle.cancel() sent — a WorkflowExecutionCancelRequested Event; the Workflow sees it at its next await")

    try:
        print("result:", await handle.result())
    except WorkflowFailureError as e:
        print(f"execution closed with {type(e.cause).__name__}: {e.cause}")
    desc = await handle.describe()
    print(f"close status: {desc.status.name}")
    print(f"final status(): {await handle.query(AgentRun.status)}")
    print("\nhistory:")
    await history(client, handle.id)


if __name__ == "__main__":
    asyncio.run(main())
07-cancellation-and-sagas/tests/test_lab.py
"""Lab 7 self-check against the time-skipping test server:  python -m pytest tests/ -q

Activities run in real time even under time skipping, so the fake work is scaled down
(`workflows.SPEED`) and the crunch tool is a few chunks long.
"""
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, ".."))
sys.path.insert(0, os.path.join(HERE, "..", ".."))

import workflows  # noqa: E402
from temporalio.api.enums.v1 import EventType  # noqa: E402
from temporalio.client import WorkflowFailureError  # noqa: E402
from temporalio.exceptions import ActivityError, CancelledError  # noqa: E402
from temporalio.testing import WorkflowEnvironment  # noqa: E402
from temporalio.worker import Worker  # noqa: E402
from workflows import (  # noqa: E402
    AgentRun,
    ToolOptions,
    allocate_sandbox,
    call_llm,
    execute_tool,
    register_endpoint,
    release_gpu,
    release_sandbox,
    reserve_gpu,
    start_model,
    stop_model,
    unregister_endpoint,
)

ACTIVITIES = [call_llm, execute_tool, reserve_gpu, allocate_sandbox, start_model, register_endpoint,
              unregister_endpoint, stop_model, release_sandbox, release_gpu]


@pytest.fixture(autouse=True)
def fast_activities(monkeypatch):
    monkeypatch.setattr(workflows, "SPEED", 0.05)      # 1 s of fake work -> 50 ms
    workflows.FINISHED_TOOLS.clear()


async def _annotated(handle) -> list[str]:
    """['ACTIVITY_TASK_SCHEDULED reserve_gpu', 'ACTIVITY_TASK_COMPLETED', ...] in history order."""
    out = []
    async for ev in handle.fetch_history_events():
        name = EventType.Name(ev.event_type).removeprefix("EVENT_TYPE_")
        if ev.HasField("activity_task_scheduled_event_attributes"):
            name += " " + ev.activity_task_scheduled_event_attributes.activity_type.name
        out.append(name)
    return out


def _scheduled(names: list[str]) -> list[str]:
    return [n.split(" ", 1)[1] for n in names if n.startswith("ACTIVITY_TASK_SCHEDULED ")]


def test_saga_runs_compensations_in_reverse_when_register_endpoint_fails() -> None:
    async def go() -> None:
        async with await WorkflowEnvironment.start_time_skipping() as env:
            tq = f"lab-07-test-{uuid.uuid4()}"
            async with Worker(env.client, task_queue=tq, workflows=[AgentRun], activities=ACTIVITIES):
                handle = await env.client.start_workflow(
                    AgentRun.run, args=["deploy the fine-tuned model", ToolOptions()],
                    id=f"lab07-test-{uuid.uuid4()}", task_queue=tq)
                with pytest.raises(WorkflowFailureError) as exc:
                    await handle.result()
                assert isinstance(exc.value.cause, ActivityError)          # register_endpoint, retries spent
                assert (await handle.describe()).status.name == "FAILED"
                names = await _annotated(handle)
                assert _scheduled(names) == [
                    "call_llm", "reserve_gpu", "allocate_sandbox", "start_model", "register_endpoint",
                    "stop_model", "release_sandbox", "release_gpu",          # reverse order; no unregister_endpoint
                ]
                assert names.count("ACTIVITY_TASK_FAILED") == 1              # one failure Event after 3 attempts
                assert names.index("ACTIVITY_TASK_FAILED") < names.index("ACTIVITY_TASK_SCHEDULED stop_model")
                status = await handle.query(AgentRun.status)
                assert status["status"] == "failed" and status["rolled_back"] == ["stop_model", "release_sandbox", "release_gpu"]

    asyncio.run(go())


def test_cancel_without_heartbeats_the_tool_runs_to_the_end_anyway() -> None:
    async def go() -> None:
        async with await WorkflowEnvironment.start_time_skipping() as env:
            tq = f"lab-07-test-{uuid.uuid4()}"
            async with Worker(env.client, task_queue=tq, workflows=[AgentRun], activities=ACTIVITIES):
                handle = await env.client.start_workflow(
                    AgentRun.run, args=["crunch the eval set", ToolOptions(heartbeat=False, chunks=30)],
                    id=f"lab07-test-{uuid.uuid4()}", task_queue=tq)
                while (await handle.query(AgentRun.status))["in_flight"] != "crunch":
                    await asyncio.sleep(0.05)
                await handle.cancel()
                with pytest.raises(WorkflowFailureError) as exc:
                    await handle.result()
                assert isinstance(exc.value.cause, CancelledError)
                assert (await handle.describe()).status.name == "CANCELED"
                names = await _annotated(handle)
                assert "ACTIVITY_TASK_CANCEL_REQUESTED" in names
                assert "ACTIVITY_TASK_CANCELED" not in names                 # nobody could tell the Activity
                assert _scheduled(names)[-1] == "release_sandbox"            # cleanup ran, unshielded, in `finally`
                assert (await handle.query(AgentRun.status))["rolled_back"] == ["release_sandbox"]
                # ... and the tool is still running on the Worker. Wait for it: it finishes every chunk.
                for _ in range(200):
                    if workflows.FINISHED_TOOLS:
                        break
                    await asyncio.sleep(0.05)
                assert workflows.FINISHED_TOOLS == [f"{handle.id}:1:crunch"]

    asyncio.run(go())


def test_cancel_with_heartbeats_stops_the_tool_and_cleanup_still_runs(monkeypatch) -> None:
    # The cancel rides back on the next heartbeat, up to 0.8 x HEARTBEAT_TIMEOUT later; the run must
    # still be open then for the acknowledgement to land in *its* history, so the cleanup is slow here.
    monkeypatch.setattr(workflows, "UNDO_SECONDS", 80)      # x SPEED 0.05 = 4 s

    async def go() -> None:
        async with await WorkflowEnvironment.start_time_skipping() as env:
            tq = f"lab-07-test-{uuid.uuid4()}"
            async with Worker(env.client, task_queue=tq, workflows=[AgentRun], activities=ACTIVITIES):
                handle = await env.client.start_workflow(
                    AgentRun.run, args=["crunch the eval set", ToolOptions(heartbeat=True, chunks=200)],
                    id=f"lab07-test-{uuid.uuid4()}", task_queue=tq)
                while (await handle.query(AgentRun.status))["in_flight"] != "crunch":
                    await asyncio.sleep(0.05)
                await handle.cancel()
                with pytest.raises(WorkflowFailureError):
                    await handle.result()
                assert (await handle.describe()).status.name == "CANCELED"
                names = await _annotated(handle)
                assert "ACTIVITY_TASK_CANCEL_REQUESTED" in names
                assert "ACTIVITY_TASK_CANCELED" in names                     # the Activity acknowledged the cancel
                assert _scheduled(names)[-1] == "release_sandbox"
                assert workflows.FINISHED_TOOLS == []                        # it did not run to the end

    asyncio.run(go())


def test_shielded_tool_finishes_first_then_the_run_closes_cancelled() -> None:
    async def go() -> None:
        async with await WorkflowEnvironment.start_time_skipping() as env:
            tq = f"lab-07-test-{uuid.uuid4()}"
            async with Worker(env.client, task_queue=tq, workflows=[AgentRun], activities=ACTIVITIES):
                handle = await env.client.start_workflow(
                    AgentRun.run, args=["crunch the eval set", ToolOptions(heartbeat=True, shield=True, chunks=30)],
                    id=f"lab07-test-{uuid.uuid4()}", task_queue=tq)
                while (await handle.query(AgentRun.status))["in_flight"] != "crunch":
                    await asyncio.sleep(0.05)
                await handle.cancel()
                with pytest.raises(WorkflowFailureError):
                    await handle.result()
                assert (await handle.describe()).status.name == "CANCELED"
                names = await _annotated(handle)
                assert "ACTIVITY_TASK_CANCEL_REQUESTED" not in names         # the shield kept the cancel off the Activity
                assert names.index("ACTIVITY_TASK_SCHEDULED execute_tool") < names.index("WORKFLOW_EXECUTION_CANCEL_REQUESTED")
                assert _scheduled(names)[-2:] == ["execute_tool", "release_sandbox"]
                assert names.count("ACTIVITY_TASK_COMPLETED") == 4           # llm, sandbox, the tool, the release
                assert workflows.FINISHED_TOOLS == [f"{handle.id}:1:crunch"]

    asyncio.run(go())
07-cancellation-and-sagas/worker.py
import asyncio
import logging
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from common import TASK_QUEUE, connect  # noqa: E402
from temporalio import activity  # noqa: E402
from temporalio.worker import Worker  # noqa: E402
from workflows import (  # noqa: E402
    AgentRun,
    allocate_sandbox,
    call_llm,
    execute_tool,
    register_endpoint,
    release_gpu,
    release_sandbox,
    reserve_gpu,
    start_model,
    stop_model,
    unregister_endpoint,
)


def _one_line_failures(record: logging.LogRecord) -> bool:
    """register_endpoint fails on purpose, three times; keep the SDK's line, drop its traceback."""
    if record.getMessage().startswith("Completing activity as failed"):
        record.exc_info = None
    return True


async def main() -> None:
    # The Activities narrate what they do; this lab is read from the Worker's terminal as much as from history.
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S")
    logging.getLogger("temporalio").setLevel(logging.WARNING)
    logging.getLogger("temporalio.activity").setLevel(logging.INFO)     # our activity.logger lines
    logging.getLogger("temporalio.activity").addFilter(_one_line_failures)
    activity.logger.activity_info_on_message = False                    # no context dict after every line
    client = await connect()
    worker = Worker(
        client,
        task_queue=TASK_QUEUE,
        workflows=[AgentRun],
        activities=[call_llm, execute_tool, reserve_gpu, allocate_sandbox, start_model, register_endpoint,
                    unregister_endpoint, stop_model, release_sandbox, release_gpu],
    )
    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())
07-cancellation-and-sagas/workflows.py
"""Lab 7: AgentRun, stage 7 — the GPU saga, and cancellation that actually stops things.

Two additions. (1) `execute_tool`'s GPU lane is four Activities orchestrated by
Workflow code — `reserve_gpu -> allocate_sandbox -> start_model -> register_endpoint` —
and after each success AgentRun appends the matching undo to `self.compensations`.
That list is Workflow state: when a later step fails, the undos run in reverse, and
a Worker that dies halfway through the rollback is replaced by one that replays the
list and finishes it. (2) `handle.cancel()` is Temporal's cancellation, delivered
once as `asyncio.CancelledError` at the Workflow's next await. The rollback runs in
`finally`, after that delivery, with no shield. What a cancel does to the Activity
that was in flight depends on two things: whether the Workflow propagates the cancellation to it at
all (7.3's shield does not), and, when it does, whether the Activity heartbeats — the request rides
back on a heartbeat response.
"""
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
from temporalio.common import RetryPolicy
from temporalio.exceptions import ActivityError, ApplicationError, CancelledError

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from common import TASK_QUEUE  # noqa: E402,F401

SPEED = 1.0                                   # scale for every fake Activity's sleep (tests shrink it)
STEP_TIMEOUT = timedelta(seconds=10)          # forward steps and undos; also how long a dead Worker's attempt stays "running" (7.2)
FORWARD_RETRY = RetryPolicy(initial_interval=timedelta(seconds=1), maximum_attempts=3)
UNDO_RETRY = RetryPolicy(initial_interval=timedelta(seconds=1), maximum_attempts=10)
UNDO_SECONDS = 3                              # each undo takes this long: the window to kill the Worker in 7.2
TOOL_CHUNKS = 20                              # execute_tool works in 1-second chunks
HEARTBEAT_TIMEOUT = timedelta(seconds=3)      # heartbeats are throttled to 0.8x this, so a cancel lands within ~2.4 s


@dataclass
class ToolCall:
    key: str
    tool: str                                 # "deploy_model" (GPU lane) | "crunch" (CPU lane)
    args: dict = field(default_factory=dict)


@dataclass
class ToolResult:
    key: str
    output: str


@dataclass
class ToolOptions:
    """Lab knobs for 7.3, passed to run() so the Workflow stays deterministic."""
    heartbeat: bool = False                   # execute_tool calls activity.heartbeat() each chunk
    shield: bool = False                      # AgentRun wraps the in-flight execute_tool in asyncio.shield
    chunks: int = TOOL_CHUNKS


@dataclass
class Step:
    kind: str                                 # "llm" | "tool" | "finish"
    prompt: str = ""
    tool: str = ""


async def _work(seconds: float) -> None:
    await asyncio.sleep(seconds * SPEED)


@activity.defn
async def call_llm(prompt: str, key: str) -> str:
    return f"[model answer to: {prompt[:48]}]"


# ---- the GPU lane, forward: each step returns the identifier its undo will need ----

@activity.defn
async def reserve_gpu(key: str) -> str:
    await _work(1)
    gpu = f"gpu-for-{key.split(':')[0]}"          # the reservation id; release_gpu is keyed on it
    activity.logger.info("reserve_gpu -> %s", gpu)
    return gpu


@activity.defn
async def allocate_sandbox(gpu: str) -> str:
    await _work(1)
    box = f"sandbox-on-{gpu}"
    activity.logger.info("allocate_sandbox -> %s", box)
    return box


@activity.defn
async def start_model(box: str) -> str:
    await _work(1)
    model = f"model-in-{box}"
    activity.logger.info("start_model -> %s", model)
    return model


@activity.defn
async def register_endpoint(model: str) -> str:
    await _work(0.2)
    activity.logger.info("register_endpoint attempt %d for %s: rejected", activity.info().attempt, model)
    raise ApplicationError(f"endpoint registry rejected the name for {model}")   # retryable; the policy ends it


# ---- the GPU lane, reverse: each undo may run more than once ----
# The stable resource id is what *lets* the external system make the undo idempotent; it does not
# make it idempotent. Releasing gpu-123 twice is safe only if the allocator treats the second
# release as a no-op rather than an error — or, worse, as releasing a gpu-123 that has since been
# handed to someone else. These fake Activities have no external state to get wrong.

async def _undo(what: str, resource: str) -> None:
    activity.logger.info("%s %s: attempt %d, starting", what, resource, activity.info().attempt)
    await _work(UNDO_SECONDS)
    activity.logger.info("%s %s: done", what, resource)


@activity.defn
async def unregister_endpoint(endpoint: str) -> None:
    await _undo("unregister_endpoint", endpoint)


@activity.defn
async def stop_model(model: str) -> None:
    await _undo("stop_model", model)


@activity.defn
async def release_sandbox(box: str) -> None:
    await _undo("release_sandbox", box)


@activity.defn
async def release_gpu(gpu: str) -> None:
    await _undo("release_gpu", gpu)


# ---- the CPU lane: one long tool ----

FINISHED_TOOLS: list[str] = []                # in-process marker for the tests: which tool calls ran to the end


@activity.defn
async def execute_tool(call: ToolCall) -> ToolResult:
    """A long tool in 1-second chunks. With `heartbeat` off it never talks to the Service while it
    runs, so nothing can tell it the run was cancelled. With it on, the cancellation rides back on a
    heartbeat response and the SDK raises CancelledError at the next await."""
    chunks = int(call.args.get("chunks", TOOL_CHUNKS))
    heartbeat = bool(call.args.get("heartbeat", False))
    i = 0
    try:
        for i in range(1, chunks + 1):
            if heartbeat:
                activity.heartbeat(i)             # progress out, cancellation (if any) back
            await _work(1)
            activity.logger.info("execute_tool %s: chunk %d/%d", call.tool, i, chunks)
    except asyncio.CancelledError:
        activity.logger.info("execute_tool %s: cancelled at chunk %d/%d", call.tool, i, chunks)
        raise                                     # re-raise, or the Activity does not appear cancelled
    FINISHED_TOOLS.append(call.key)
    activity.logger.info("execute_tool %s: finished all %d chunks", call.tool, chunks)
    return ToolResult(key=call.key, output=f"{call.tool}: {chunks} chunks done")


@workflow.defn
class AgentRun:
    def __init__(self) -> None:
        self.step = 0
        self.status = "running"                    # running | done | failed | cancelled
        self.goal = ""
        self.context_summary = ""
        self.attempts = 0
        self.compensations: list = []              # [(undo_activity, arg)] — Workflow state, so replay rebuilds it
        self.options = ToolOptions()
        self.in_flight = ""
        self.rolled_back: list[str] = []

    def plan(self) -> Step:
        if self.step == 0:
            return Step("llm", prompt=f"Plan one tool call for: {self.goal}")
        if self.step == 1:
            return Step("tool", tool="deploy_model" if self.goal.startswith("deploy") else "crunch")
        return Step("finish")

    @workflow.run
    async def run(self, goal: str, options: ToolOptions | None = None) -> str:
        self.goal = goal
        self.options = options or ToolOptions()
        try:
            while self.status == "running":
                step = self.plan()
                if step.kind == "finish":
                    self.status = "done"
                    break
                if step.kind == "llm":
                    key = f"{workflow.info().workflow_id}:{self.step}:{hashlib.sha256(step.prompt.encode()).hexdigest()[:12]}"
                    self.context_summary = await workflow.execute_activity(
                        call_llm, args=[step.prompt, key], start_to_close_timeout=timedelta(seconds=10)
                    )
                else:
                    call = ToolCall(key=f"{workflow.info().workflow_id}:{self.step}:{step.tool}", tool=step.tool,
                                    args={"heartbeat": self.options.heartbeat, "chunks": self.options.chunks})
                    result = await (self.gpu_lane(call) if step.tool == "deploy_model" else self.cpu_lane(call))
                    self.context_summary += f"\n{result.output}"
                self.step += 1
        except asyncio.CancelledError:
            self.status = "cancelled"              # Temporal's cancel, delivered once at the await above
            raise                                  # ... after `finally` has run the rollback
        except ActivityError as e:
            if isinstance(e.cause, CancelledError):
                self.status = "cancelled"          # the same cancel, but it landed while an Activity was in
            else:                                  # flight: it arrives wrapped as that Activity's cancellation
                self.status = "failed"             # a step failed for good (its retries are spent)
            raise
        finally:
            if self.status in ("cancelled", "failed"):
                await self.rollback()              # runs normally: the one cancellation was already delivered
        return self.context_summary

    async def gpu_lane(self, call: ToolCall) -> ToolResult:
        """Four steps, each recorded; the undo list grows with every success."""
        self.compensations = []
        gpu = await workflow.execute_activity(reserve_gpu, call.key, start_to_close_timeout=STEP_TIMEOUT)
        self.compensations.append((release_gpu, gpu))
        box = await workflow.execute_activity(allocate_sandbox, gpu, start_to_close_timeout=STEP_TIMEOUT)
        self.compensations.append((release_sandbox, box))
        model = await workflow.execute_activity(start_model, box, start_to_close_timeout=STEP_TIMEOUT)
        self.compensations.append((stop_model, model))
        endpoint = await workflow.execute_activity(                       # fails after FORWARD_RETRY is spent
            register_endpoint, model, start_to_close_timeout=STEP_TIMEOUT, retry_policy=FORWARD_RETRY
        )
        self.compensations.append((unregister_endpoint, endpoint))
        return ToolResult(key=call.key, output=f"deployed at {endpoint}")

    async def cpu_lane(self, call: ToolCall) -> ToolResult:
        """A sandbox, then one long tool inside it. The sandbox is the resource a cancel must give back."""
        self.compensations = []
        box = await workflow.execute_activity(allocate_sandbox, call.key, start_to_close_timeout=STEP_TIMEOUT)
        self.compensations.append((release_sandbox, box))
        self.in_flight = call.tool
        tool = workflow.start_activity(              # a handle: the Activity is scheduled now, awaited below
            execute_tool, call,
            start_to_close_timeout=timedelta(seconds=self.options.chunks + 30),
            heartbeat_timeout=HEARTBEAT_TIMEOUT if self.options.heartbeat else None,
            retry_policy=RetryPolicy(maximum_attempts=1),
        )
        if self.options.shield:
            try:
                result = await asyncio.shield(tool)  # the cancel reaches this await, not the Activity
            except asyncio.CancelledError:
                result = await tool                  # let the in-flight work finish, then close as cancelled
                self.context_summary += f"\n{result.output}"
                raise
        else:
            result = await tool                      # cancelling this await cancels the Activity too
        self.in_flight = ""
        return result

    async def rollback(self) -> None:
        """The saga's reverse path. Each undo is an Activity with its own retry policy."""
        for undo, arg in reversed(self.compensations):
            await workflow.execute_activity(undo, arg, start_to_close_timeout=STEP_TIMEOUT, retry_policy=UNDO_RETRY)
            self.rolled_back.append(undo.__name__)
        self.compensations = []

    @workflow.query
    def status(self) -> dict:
        return {
            "step": self.step,
            "status": self.status,
            "in_flight": self.in_flight,
            "compensations": [undo.__name__ for undo, _ in self.compensations],
            "rolled_back": self.rolled_back,
        }

Lab source: labs/07-cancellation-and-sagas in the course repository. Verified against temporal server start-dev with the Python SDK 1.32 on 2026-09-11.