Skip to content
This repository was archived by the owner on Jul 5, 2026. It is now read-only.

actions: ActionManager finished-event must start SET to match count=0 invariant - #279

Open
Maurotb wants to merge 1 commit into
pipecat-ai:mainfrom
Maurotb:fix-action-manager-init-event-state
Open

actions: ActionManager finished-event must start SET to match count=0 invariant#279
Maurotb wants to merge 1 commit into
pipecat-ai:mainfrom
Maurotb:fix-action-manager-init-event-state

Conversation

@Maurotb

@Maurotb Maurotb commented Jun 2, 2026

Copy link
Copy Markdown

Ran into a hard-to-debug hang where set_node would block forever after a node whose first pre_action was a tts_say with an empty text — the empty text was the result of upstream string interpolation collapsing to "".

Traced it to ActionManager.__init__:

self._ongoing_actions_count = 0
self._ongoing_actions_finished_event = asyncio.Event()  # default = UNSET

The invariant elsewhere in the class is "event is set iff _ongoing_actions_count == 0" — see _increment_ongoing_actions_count (clears the event) and _decrement_ongoing_actions_count (sets it when count hits 0). At construction the count is 0, so the event should start SET to keep the invariant. As-is, an immediate _maybe_wait_for_ongoing_actions_to_finish blocks on asyncio.Event.wait() forever.

Normally this isn't observable because every action increments-then-decrements, so the event gets set by the first decrement. But if the very first action's handler early-returns without incrementing_handle_tts_action does exactly that on empty text:

text = action.get("text")
if not text:
    logger.error("TTS action missing 'text' field")
    return  # ← no increment, so no later decrement to set the event

— then the event stays UNSET forever and the wait hangs. The user-visible symptom is the next set_node never completing: tools never re-register, the LLM keeps seeing the old node's tool names, and you get a hallucination loop on tool calls that "exist" but aren't actually available.

Two-line fix: call .set() on the event right after construction so the invariant holds at t=0. Behaviour for the well-formed case (every action increments then decrements) is unchanged — the first _increment clears the event before any wait could happen.

Test plan

  • New test_finished_event_is_set_at_construction — pins the invariant itself (count == 0 ↔ event set).
  • New test_tts_action_with_empty_text_does_not_hang_set_node — reproduces the concrete trigger end-to-end: an empty-text tts_say followed by _maybe_wait_for_ongoing_actions_to_finish returns immediately instead of hanging (asserted with asyncio.wait_for(..., timeout=1.0)).
  • pytest tests/test_actions.py → 14 passed (12 existing + 2 new).
  • ruff check + ruff format --check clean.

Compat

Behaviour-narrowing for the broken case (event correctly reflects "no ongoing actions" at boot), no change for the well-formed case (counter increments before any wait, event gets cleared as usual). No new state, no new dependency. The only callers that could notice are tests/code that explicitly assert event.is_set() is False immediately after ActionManager(...) — I couldn't find any.

… invariant

`ActionManager.__init__` creates `_ongoing_actions_finished_event`
with `asyncio.Event()` (default UNSET) while `_ongoing_actions_count`
starts at 0. The intended invariant is "the event is set iff
`_ongoing_actions_count == 0`" — so at construction the event must
start SET. Otherwise an immediate
`_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 conditional that collapses to an empty
string is the common case), a handler exception caught before
`_increment_ongoing_actions_count`, etc. With the counter at 0
and no later decrement to set the event, the next `set_node` hangs
on the wait — the LLM never registers the new node's tools, and any
context aggregator pinned to the previous node's tool names ends up
in a hallucination loop.

Two-line fix: call `.set()` on the event right after construction.
Adds two pin tests:

* `test_finished_event_is_set_at_construction` — pins the invariant
  itself (count == 0 ↔ event set).
* `test_tts_action_with_empty_text_does_not_hang_set_node` —
  reproduces the concrete trigger end-to-end: empty-text `tts_say`
  followed by `_maybe_wait_for_ongoing_actions_to_finish` returns
  immediately instead of hanging on `asyncio.wait_for(..., timeout=1.0)`.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant