Skip to content
This repository was archived by the owner on Jul 5, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/fix-action-manager-init-event.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed `ActionManager._ongoing_actions_finished_event` starting UNSET at construction time, which violated the invariant "event is set iff `_ongoing_actions_count == 0`" since the count starts at 0. The event now starts SET. Without this, a node's first `pre_action` (e.g. `tts_say`) that early-returns without incrementing the counter — empty/whitespace text after templating, a handler exception caught before `_increment_ongoing_actions_count`, etc. — left the event UNSET forever and the next `set_node` would hang on `_maybe_wait_for_ongoing_actions_to_finish().wait()`.
11 changes: 11 additions & 0 deletions src/pipecat_flows/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,17 @@ def __init__(self, worker: PipelineWorker, flow_manager: "FlowManager"):
self._flow_manager = flow_manager
self._ongoing_actions_count = 0
self._ongoing_actions_finished_event = asyncio.Event()
# Invariant: the event is set iff ``_ongoing_actions_count == 0``
# ("no ongoing actions in flight"). At construction time the
# count is 0, so the event must start SET — otherwise an
# immediate ``await _maybe_wait_for_ongoing_actions_to_finish()``
# blocks forever. This bites when a node's first ``pre_action``
# (e.g. ``tts_say``) early-returns without incrementing the
# counter — empty/whitespace text after templating, a handler
# exception caught before ``_increment_ongoing_actions_count``,
# etc. — leaving the event UNSET and the next ``set_node`` to
# hang on the wait.
self._ongoing_actions_finished_event.set()
self._deferred_post_actions: list[ActionConfig] = []
self._showed_deprecation_warning_for_legacy_action_handler = False

Expand Down
33 changes: 33 additions & 0 deletions tests/test_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,39 @@ async def test_initialization(self):
self.assertIn("tts_say", self.action_manager._action_handlers)
self.assertIn("end_conversation", self.action_manager._action_handlers)

async def test_finished_event_is_set_at_construction(self):
"""The ``_ongoing_actions_finished_event`` must start SET at
construction time so that the invariant ``event is set iff
count == 0`` holds (count starts at 0). Otherwise an immediate
``_maybe_wait_for_ongoing_actions_to_finish`` blocks forever on
a freshly-built ActionManager — concrete trigger: a node's first
``pre_action`` (e.g. ``tts_say``) early-returns without
incrementing the counter (empty text after templating, handler
raises, etc.) and there's no later decrement to set the event."""
self.assertEqual(self.action_manager._ongoing_actions_count, 0)
self.assertTrue(self.action_manager._ongoing_actions_finished_event.is_set())

async def test_tts_action_with_empty_text_does_not_hang_set_node(self):
"""An ``tts_say`` action whose ``text`` is empty must NOT leave
the ongoing-actions event UNSET. ``_handle_tts_action``
early-returns on empty text without incrementing the counter,
so the invariant from the previous test is the only thing that
keeps the event set."""
# Empty text triggers the early-return path in _handle_tts_action.
action = {"type": "tts_say", "text": ""}
await self.action_manager.execute_actions([action])
self.assertEqual(self.action_manager._ongoing_actions_count, 0)
self.assertTrue(self.action_manager._ongoing_actions_finished_event.is_set())
# ``_maybe_wait_for_ongoing_actions_to_finish`` must return
# immediately rather than hanging.
await asyncio.wait_for(
self.action_manager._maybe_wait_for_ongoing_actions_to_finish(
previous_action_type="tts_say",
upcoming_action_type=None,
),
timeout=1.0,
)

async def test_tts_action(self):
"""Test basic TTS action execution."""
action = {"type": "tts_say", "text": "Hello"}
Expand Down