Files
pipecat/tests/test_job_group.py
Aleix Conchillo Flaqué b03247f360 Rename BaseTask → BaseWorker and reserve "task" for asyncio
Replaces every "task" identifier that referred to the BaseTask
abstraction with "worker". Asyncio task plumbing (asyncio.Task,
BaseTaskManager, TaskManager, create_task, cancel_task, etc.) stays
untouched. Highlights:

- Classes: BaseTask → BaseWorker, PipelineTask → PipelineWorker,
  LLMTask → LLMWorker, LLMContextTask → LLMContextWorker, TaskBus →
  WorkerBus, TaskRegistry → WorkerRegistry, TaskActivationArgs →
  WorkerActivationArgs, TaskReadyData → WorkerReadyData,
  TaskRegistryEntry → WorkerRegistryEntry, TaskObserver →
  WorkerObserver, all Bus*TaskMessage → Bus*WorkerMessage,
  BusAddTaskMessage.task field → worker, BusWorkerRegistryMessage.tasks
  field → workers.
- Methods/decorators: activate_task → activate_worker, deactivate_task
  → deactivate_worker, add_task → add_worker, watch_task →
  watch_worker, @task_ready → @worker_ready, setup_pipeline_task hook
  → setup_pipeline_worker.
- Params/fields: FrameProcessorSetup.pipeline_task and
  FunctionCallParams.pipeline_task → pipeline_worker. Parameter names
  like task_name → worker_name; spawn/run accept worker:.
- Files: pipeline/base_task.py → base_worker.py, pipeline/task.py →
  worker.py (plus a re-export shim at pipeline/task.py),
  task_observer.py → worker_observer.py, task_ready_decorator.py →
  worker_ready_decorator.py, pipecat.tasks → pipecat.workers,
  llm_task.py → llm_worker.py, llm_context_task.py →
  llm_context_worker.py, examples/multi-task → examples/multi-worker.

Back-compat:
- PipelineTask kept as a deprecated subclass of PipelineWorker that
  warns on construction.
- pipecat.pipeline.task re-exports PipelineWorker/PipelineTask/etc. so
  existing user imports keep working.
- FrameProcessor.pipeline_task kept as a deprecated property that
  forwards to pipeline_worker.

Local variables in examples that hold a worker (task = PipelineTask(...))
are renamed to worker = PipelineWorker(...). Asyncio-task locals
(runner_task, etc.) are preserved.
2026-05-21 19:07:13 -07:00

740 lines
26 KiB
Python

#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import unittest
from pipecat.bus import (
AsyncQueueBus,
BusJobCancelMessage,
BusJobRequestMessage,
BusJobResponseMessage,
)
from pipecat.pipeline.base_worker import BaseWorker
from pipecat.pipeline.job_context import (
JobError,
JobEvent,
JobGroupError,
JobGroupEvent,
JobStatus,
)
from pipecat.registry import WorkerRegistry
from pipecat.registry.types import WorkerReadyData
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
class StubTask(BaseWorker):
pass
class JobWorkerTask(BaseWorker):
"""Worker that automatically responds to job requests via the bus."""
def __init__(self, name, *, response=None, status=JobStatus.COMPLETED):
super().__init__(name)
self._auto_response = response
self._auto_status = status
async def on_job_request(self, message):
await super().on_job_request(message)
await self.send_job_response(message.job_id, self._auto_response, status=self._auto_status)
class UrgentJobWorkerTask(BaseWorker):
"""Worker that responds urgently to job requests."""
def __init__(self, name, *, response=None, status=JobStatus.COMPLETED):
super().__init__(name)
self._auto_response = response
self._auto_status = status
async def on_job_request(self, message):
await super().on_job_request(message)
await self.send_job_response(
message.job_id, self._auto_response, status=self._auto_status, urgent=True
)
class UpdatingWorkerTask(BaseWorker):
"""Worker that sends updates before responding."""
def __init__(self, name, *, updates, response=None):
super().__init__(name)
self._updates = updates
self._auto_response = response
async def on_job_request(self, message):
await super().on_job_request(message)
for update in self._updates:
await self.send_job_update(message.job_id, update)
await self.send_job_response(message.job_id, self._auto_response)
class StreamingWorkerTask(BaseWorker):
"""Worker that streams data before responding."""
def __init__(self, name, *, chunks, response=None):
super().__init__(name)
self._chunks = chunks
self._auto_response = response
async def on_job_request(self, message):
await super().on_job_request(message)
await self.send_job_stream_start(message.job_id, {"content_type": "text"})
for chunk in self._chunks:
await self.send_job_stream_data(message.job_id, chunk)
await self.send_job_stream_end(message.job_id, self._auto_response)
class SlowWorkerTask(BaseWorker):
"""Worker that blocks during job execution until cancelled."""
def __init__(self, name):
super().__init__(name)
self.started = asyncio.Event()
self.was_cancelled = False
async def on_job_request(self, message):
await super().on_job_request(message)
self.started.set()
try:
# Block until cancelled
await asyncio.Event().wait()
except asyncio.CancelledError:
self.was_cancelled = True
async def create_test_env():
bus = AsyncQueueBus()
tm = TaskManager()
tm.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
await bus.setup(tm)
await bus.start()
registry = WorkerRegistry(runner_name="test-runner")
return bus, tm, registry
async def setup_task(bus, registry, task):
"""Subscribe a task to the bus and register it as ready."""
task.attach(registry=registry, bus=bus)
await task.setup(bus.task_manager)
await bus.subscribe(task)
await registry.register(WorkerReadyData(worker_name=task.name, runner="test-runner"))
def capture_bus(bus):
sent = []
original_send = bus.send
async def capture_send(message):
sent.append(message)
await original_send(message)
bus.send = capture_send
return sent
class TestJobGroupContext(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.bus, self.tm, self.registry = await create_test_env()
async def asyncTearDown(self):
await self.bus.stop()
async def test_job_group_collects_responses(self):
"""job_group() context manager collects all responses."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
w1 = JobWorkerTask("w1", response={"a": 1})
w2 = JobWorkerTask("w2", response={"b": 2})
await setup_task(self.bus, self.registry, w1)
await setup_task(self.bus, self.registry, w2)
async with parent.job_group("w1", "w2", payload={"work": True}) as tg:
pass
self.assertEqual(tg.responses, {"w1": {"a": 1}, "w2": {"b": 2}})
async def test_job_group_sends_request(self):
"""job_group() sends BusJobRequestMessage to each task."""
sent = capture_bus(self.bus)
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
w1 = JobWorkerTask("w1", response={"ok": True})
await setup_task(self.bus, self.registry, w1)
async with parent.job_group("w1", payload={"data": 1}) as tg:
pass
request_msgs = [m for m in sent if isinstance(m, BusJobRequestMessage)]
self.assertEqual(len(request_msgs), 1)
self.assertEqual(request_msgs[0].payload, {"data": 1})
async def test_job_group_raises_on_cancel(self):
"""job_group() raises JobGroupError when group is cancelled."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
# StubTask doesn't auto-respond, so we can cancel manually
worker = StubTask("worker")
await setup_task(self.bus, self.registry, worker)
with self.assertRaises(JobGroupError) as ctx:
async with parent.job_group("worker") as tg:
asyncio.ensure_future(parent.cancel_job_group(tg.job_id, reason="manual cancel"))
# Let the event loop schedule handler tasks spawned by the bus
await asyncio.sleep(0)
self.assertIn("manual cancel", str(ctx.exception))
async def test_job_group_raises_on_timeout(self):
"""job_group() raises JobGroupError on timeout."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
# StubTask doesn't auto-respond, so timeout fires
worker = StubTask("worker")
await setup_task(self.bus, self.registry, worker)
with self.assertRaises(JobGroupError) as ctx:
async with parent.job_group("worker", timeout=0.05) as tg:
pass
self.assertIn("timeout", str(ctx.exception))
async def test_job_group_raises_on_ready_timeout(self):
"""job_group() raises JobGroupError when tasks aren't ready in time."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
# "ghost" is never registered, so the ready-wait times out
with self.assertRaises(JobGroupError) as ctx:
async with parent.job_group("ghost", timeout=0.05) as tg:
pass
self.assertIn("not ready", str(ctx.exception))
async def test_job_group_cancels_on_block_exception(self):
"""job_group() cancels remaining jobs when the block raises."""
sent = capture_bus(self.bus)
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = StubTask("worker")
await setup_task(self.bus, self.registry, worker)
with self.assertRaises(ValueError):
async with parent.job_group("worker") as tg:
raise ValueError("something went wrong")
cancel_msgs = [m for m in sent if isinstance(m, BusJobCancelMessage)]
self.assertEqual(len(cancel_msgs), 1)
self.assertEqual(cancel_msgs[0].reason, "context exited with error")
async def test_job_group_raises_on_worker_error(self):
"""job_group() raises JobGroupError when a worker errors with cancel_on_error."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = JobWorkerTask("worker", response={"error": "failed"}, status=JobStatus.ERROR)
await setup_task(self.bus, self.registry, worker)
with self.assertRaises(JobGroupError):
async with parent.job_group("worker") as tg:
pass
# Error response is tracked in partial responses
self.assertEqual(tg.responses, {"worker": {"error": "failed"}})
async def test_job_group_on_job_error_fires(self):
"""on_job_error fires when a worker errors with cancel_on_error."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = JobWorkerTask("worker", response={"error": "boom"}, status=JobStatus.ERROR)
await setup_task(self.bus, self.registry, worker)
errors = []
@parent.event_handler("on_job_error")
async def on_error(task, message):
errors.append(message)
with self.assertRaises(JobGroupError):
async with parent.job_group("worker") as tg:
pass
self.assertEqual(len(errors), 1)
self.assertEqual(errors[0].source, "worker")
self.assertEqual(errors[0].response, {"error": "boom"})
self.assertEqual(errors[0].status, JobStatus.ERROR)
async def test_job_group_partial_responses_on_error(self):
"""Partial responses from successful workers are available after error."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
# w1 responds successfully, w2 responds with error.
# Order depends on bus dispatch, but both are registered.
w1 = JobWorkerTask("w1", response={"ok": True})
w2 = JobWorkerTask("w2", response={"error": "fail"}, status=JobStatus.ERROR)
await setup_task(self.bus, self.registry, w1)
await setup_task(self.bus, self.registry, w2)
with self.assertRaises(JobGroupError):
async with parent.job_group("w1", "w2") as tg:
pass
# w2's error response should be in partial responses
self.assertIn("w2", tg.responses)
self.assertEqual(tg.responses["w2"], {"error": "fail"})
async def test_job_group_job_id_available(self):
"""job_id is available inside the async with block."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = JobWorkerTask("worker", response={})
await setup_task(self.bus, self.registry, worker)
captured_job_id = None
async with parent.job_group("worker") as tg:
captured_job_id = tg.job_id
self.assertIsNotNone(captured_job_id)
self.assertEqual(captured_job_id, tg.job_id)
async def test_job_group_on_job_completed_still_fires(self):
"""on_job_completed callback still fires when using job_group()."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = JobWorkerTask("w1", response={"ok": True})
await setup_task(self.bus, self.registry, worker)
completed = []
@parent.event_handler("on_job_completed")
async def on_completed(task, result):
completed.append(result)
async with parent.job_group("w1") as tg:
pass
self.assertEqual(len(completed), 1)
self.assertEqual(completed[0].responses, {"w1": {"ok": True}})
async def test_job_group_iterates_updates(self):
"""async for yields update events from workers."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = UpdatingWorkerTask(
"worker",
updates=[{"progress": 25}, {"progress": 75}],
response={"result": "done"},
)
await setup_task(self.bus, self.registry, worker)
events = []
async with parent.job_group("worker", payload={"work": True}) as tg:
async for event in tg:
events.append(event)
self.assertEqual(len(events), 2)
self.assertEqual(events[0].type, JobGroupEvent.UPDATE)
self.assertEqual(events[0].worker_name, "worker")
self.assertEqual(events[0].data, {"progress": 25})
self.assertEqual(events[1].data, {"progress": 75})
self.assertEqual(tg.responses, {"worker": {"result": "done"}})
async def test_job_group_iterates_stream_events(self):
"""async for yields stream_start, stream_data, and stream_end events."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = StreamingWorkerTask(
"worker",
chunks=[{"text": "hello "}, {"text": "world"}],
response={"final": True},
)
await setup_task(self.bus, self.registry, worker)
events = []
async with parent.job_group("worker") as tg:
async for event in tg:
events.append(event)
types = [e.type for e in events]
self.assertEqual(
types,
[
JobGroupEvent.STREAM_START,
JobGroupEvent.STREAM_DATA,
JobGroupEvent.STREAM_DATA,
JobGroupEvent.STREAM_END,
],
)
self.assertEqual(events[0].data, {"content_type": "text"})
self.assertEqual(events[1].data, {"text": "hello "})
self.assertEqual(events[2].data, {"text": "world"})
self.assertEqual(events[3].data, {"final": True})
async def test_job_group_iterates_mixed_events(self):
"""async for yields events from multiple workers interleaved."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
w1 = UpdatingWorkerTask("w1", updates=[{"status": "working"}], response={"a": 1})
w2 = JobWorkerTask("w2", response={"b": 2})
await setup_task(self.bus, self.registry, w1)
await setup_task(self.bus, self.registry, w2)
events = []
async with parent.job_group("w1", "w2") as tg:
async for event in tg:
events.append(event)
self.assertEqual(len(events), 1)
self.assertEqual(events[0].type, JobGroupEvent.UPDATE)
self.assertEqual(events[0].worker_name, "w1")
self.assertEqual(tg.responses, {"w1": {"a": 1}, "w2": {"b": 2}})
async def test_job_group_no_iteration_still_works(self):
"""job_group() works without iterating (pass body)."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = UpdatingWorkerTask("worker", updates=[{"progress": 50}], response={"ok": True})
await setup_task(self.bus, self.registry, worker)
async with parent.job_group("worker") as tg:
pass
self.assertEqual(tg.responses, {"worker": {"ok": True}})
async def test_urgent_job_response_collected(self):
"""Urgent job responses are collected like normal responses."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = UrgentJobWorkerTask("worker", response={"urgent": True})
await setup_task(self.bus, self.registry, worker)
async with parent.job_group("worker") as tg:
pass
self.assertEqual(tg.responses, {"worker": {"urgent": True}})
async def test_urgent_job_response_triggers_on_job_error(self):
"""Urgent error response triggers on_job_error and cancels group."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = UrgentJobWorkerTask(
"worker", response={"error": "critical"}, status=JobStatus.ERROR
)
await setup_task(self.bus, self.registry, worker)
errors = []
@parent.event_handler("on_job_error")
async def on_error(task, message):
errors.append(message)
with self.assertRaises(JobGroupError):
async with parent.job_group("worker") as tg:
pass
self.assertEqual(len(errors), 1)
self.assertEqual(errors[0].response, {"error": "critical"})
async def test_urgent_response_has_system_priority(self):
"""Urgent job response is delivered before normal data messages."""
from pipecat.bus import BusDataMessage, BusJobResponseUrgentMessage
bus = self.bus
# Queue data messages first, then an urgent response
parent = StubTask("parent")
await setup_task(bus, self.registry, parent)
received = []
@parent.event_handler("on_bus_message")
async def on_msg(task, message):
received.append(message)
# Send data messages before starting dispatch
for i in range(3):
await bus.send(BusDataMessage(source=f"data_{i}"))
await bus.send(
BusJobResponseUrgentMessage(
source="worker", target="parent", job_id="t1", status=JobStatus.COMPLETED
)
)
# Start dispatch — urgent should arrive first
# bus is already started in setUp; restart by stopping and starting
await bus.stop()
await bus.start()
await asyncio.sleep(0.1)
await bus.stop()
# First message should be the urgent response
self.assertIsInstance(received[0], BusJobResponseUrgentMessage)
class TestJobContext(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.bus, self.tm, self.registry = await create_test_env()
async def asyncTearDown(self):
await self.bus.stop()
async def test_job_collects_response(self):
"""job() context manager collects the worker's response."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = JobWorkerTask("worker", response={"result": 42})
await setup_task(self.bus, self.registry, worker)
async with parent.job("worker", payload={"x": 1}) as t:
pass
self.assertEqual(t.response, {"result": 42})
async def test_job_sends_request(self):
"""job() sends a BusJobRequestMessage to the task."""
sent = capture_bus(self.bus)
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = JobWorkerTask("worker", response={"ok": True})
await setup_task(self.bus, self.registry, worker)
async with parent.job("worker") as t:
pass
request_msgs = [m for m in sent if isinstance(m, BusJobRequestMessage)]
self.assertEqual(len(request_msgs), 1)
self.assertEqual(request_msgs[0].target, "worker")
self.assertEqual(request_msgs[0].source, "parent")
async def test_job_iterates_events(self):
"""job() yields intermediate events via async for."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = UpdatingWorkerTask("worker", updates=[{"progress": 50}], response={"done": True})
await setup_task(self.bus, self.registry, worker)
events = []
async with parent.job("worker") as t:
async for event in t:
events.append(event)
self.assertEqual(len(events), 1)
self.assertEqual(events[0].type, JobEvent.UPDATE)
self.assertIsInstance(events[0], JobEvent)
self.assertEqual(t.response, {"done": True})
async def test_job_streams_data(self):
"""job() yields stream events from a streaming worker."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = StreamingWorkerTask(
"worker",
chunks=[{"text": "hello"}, {"text": "world"}],
response={"ok": True},
)
await setup_task(self.bus, self.registry, worker)
events = []
async with parent.job("worker") as t:
async for event in t:
events.append(event)
types = [e.type for e in events]
self.assertEqual(
types,
[
JobEvent.STREAM_START,
JobEvent.STREAM_DATA,
JobEvent.STREAM_DATA,
JobEvent.STREAM_END,
],
)
self.assertEqual(t.response, {"ok": True})
async def test_job_cancels_on_exception(self):
"""job() cancels the job if the block raises."""
sent = capture_bus(self.bus)
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = StubTask("worker")
await setup_task(self.bus, self.registry, worker)
with self.assertRaises(ValueError):
async with parent.job("worker") as t:
raise ValueError("something went wrong")
cancel_msgs = [m for m in sent if isinstance(m, BusJobCancelMessage)]
self.assertEqual(len(cancel_msgs), 1)
self.assertEqual(cancel_msgs[0].reason, "context exited with error")
async def test_job_raises_on_worker_error(self):
"""job() raises JobError when the worker errors."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = JobWorkerTask("worker", response={"error": "boom"}, status=JobStatus.ERROR)
await setup_task(self.bus, self.registry, worker)
with self.assertRaises(JobError):
async with parent.job("worker") as t:
pass
async def test_job_exposes_job_id(self):
"""job() exposes the job_id inside the context."""
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = JobWorkerTask("worker", response={"ok": True})
await setup_task(self.bus, self.registry, worker)
async with parent.job("worker") as t:
self.assertIsInstance(t.job_id, str)
self.assertTrue(len(t.job_id) > 0)
async def test_job_group_cancels_on_cancelled_error(self):
"""job_group() cancels workers when CancelledError is raised (e.g. tool interruption)."""
sent = capture_bus(self.bus)
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = StubTask("worker")
await setup_task(self.bus, self.registry, worker)
async def run_job_group():
async with parent.job_group("worker") as tg:
# Simulate tool cancellation while waiting
raise asyncio.CancelledError()
task = asyncio.create_task(run_job_group())
with self.assertRaises(asyncio.CancelledError):
await task
cancel_msgs = [m for m in sent if isinstance(m, BusJobCancelMessage)]
self.assertEqual(len(cancel_msgs), 1)
self.assertEqual(cancel_msgs[0].reason, "context exited with error")
# Job group should be cleaned up
self.assertEqual(len(parent.job_groups), 0)
async def test_job_cancels_on_cancelled_error(self):
"""job() cancels the worker when CancelledError is raised (e.g. tool interruption)."""
sent = capture_bus(self.bus)
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = StubTask("worker")
await setup_task(self.bus, self.registry, worker)
async def run_job():
async with parent.job("worker") as t:
raise asyncio.CancelledError()
task = asyncio.create_task(run_job())
with self.assertRaises(asyncio.CancelledError):
await task
cancel_msgs = [m for m in sent if isinstance(m, BusJobCancelMessage)]
self.assertEqual(len(cancel_msgs), 1)
self.assertEqual(cancel_msgs[0].reason, "context exited with error")
self.assertEqual(len(parent.job_groups), 0)
async def test_fire_and_forget_jobs_cancelled_manually(self):
"""User cancels fire-and-forget jobs in a CancelledError handler."""
sent = capture_bus(self.bus)
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
w1 = StubTask("w1")
w2 = StubTask("w2")
await setup_task(self.bus, self.registry, w1)
await setup_task(self.bus, self.registry, w2)
# Simulate what a user would do in on_function_calls_cancelled:
# track job IDs and cancel only the ones started here
try:
job_ids = []
job_ids.append(await parent.request_job("w1", payload={"job": 1}))
job_ids.append(await parent.request_job("w2", payload={"job": 2}))
self.assertEqual(len(parent.job_groups), 2)
raise asyncio.CancelledError()
except asyncio.CancelledError:
for jid in job_ids:
await parent.cancel_job_group(jid, reason="tool cancelled")
cancel_msgs = [m for m in sent if isinstance(m, BusJobCancelMessage)]
self.assertEqual(len(cancel_msgs), 2)
cancelled_targets = {m.target for m in cancel_msgs}
self.assertEqual(cancelled_targets, {"w1", "w2"})
for m in cancel_msgs:
self.assertEqual(m.reason, "tool cancelled")
self.assertEqual(len(parent.job_groups), 0)
async def test_cancel_interrupts_running_handler(self):
"""Cancelling a job interrupts a handler that is currently executing."""
sent = capture_bus(self.bus)
parent = StubTask("parent")
await setup_task(self.bus, self.registry, parent)
worker = SlowWorkerTask("worker")
await setup_task(self.bus, self.registry, worker)
job_id = await parent.request_job("worker", payload={"job": 1})
# Wait for the worker to start executing
await asyncio.wait_for(worker.started.wait(), timeout=2.0)
# Cancel while the handler is blocked
await parent.cancel_job_group(job_id, reason="no longer needed")
# Give the event loop time to process the cancellation
await asyncio.sleep(0.1)
# The handler should have been cancelled
self.assertTrue(worker.was_cancelled)
# A cancel message should have been sent
cancel_msgs = [m for m in sent if isinstance(m, BusJobCancelMessage)]
self.assertEqual(len(cancel_msgs), 1)
self.assertEqual(cancel_msgs[0].reason, "no longer needed")
# A CANCELLED response should have been sent back
response_msgs = [m for m in sent if isinstance(m, BusJobResponseMessage)]
self.assertEqual(len(response_msgs), 1)
self.assertEqual(response_msgs[0].status, JobStatus.CANCELLED)
if __name__ == "__main__":
unittest.main()