Describe the bug
In @livekit/agents 1.5.3, when a pipeline reply is interrupted before its playout has started, AgentActivity.mainTask can hang forever on the interrupted speech handle's generation future. _currentSpeech is never cleared, so the agent goes permanently silent — no further replies are ever forwarded to the user until an external mechanism (e.g. an idle/duration guard) tears down the session.
This looks like a deeper regression in the same area as #836 / #1124 / #1085. The waitIfNotInterrupted([generation]) guard proposed in #1124 (and now present in current mainTask) does correctly return early on interrupt — but the very next branch performs a second, unguarded wait on the same generation future, and that second wait is the one that actually deadlocks.
Root cause (traced against 1.5.3 dist/voice/agent_activity.js)
mainTask (~line 1424):
this._currentSpeech = speechHandle;
speechHandle._authorizeGeneration();
const generation = speechHandle._waitForGeneration();
await speechHandle.waitIfNotInterrupted([generation]); // returns immediately on interrupt — OK
if (speechHandle.interrupted && speechHandle._tasks.length > 0) {
await ThrowsPromise.race([generation, abortFuture.await]); // ← hangs here
}
this._currentSpeech = void 0;
generation only resolves via _markGenerationDone() / _markDone() on the speech handle, which only happens once the reply task's own task-done callback fires. abortFuture only resolves when the whole activity is torn down (not per-speech).
The reply task itself gets stuck one level deeper, inside forwardSegment (~line 2156 in the same file):
const interruptedPlaybackEv = await raceWithAbort(
audioOutput.waitForPlayout(),
replyAbortController.signal
);
audioOutput.waitForPlayout() waits for a playback-finished event that will never fire, because playout never started. The only other way out of this await is the replyAbortController.signal aborting — but that controller's own .abort() call happens later in the same function (the "Aborting all pipeline reply tasks due to interruption" path), in code that is only reached after the segment loop this await sits in returns. The segment loop can't return, because it's blocked on this same await. It's a self-referential wait: A blocks on B, and B is only triggered by code that runs after A returns.
Net effect: the reply task never calls _markGenerationDone(), so its done-callback never calls _markDone(), so generation in mainTask never resolves, so _currentSpeech stays stuck on the wedged handle forever.
To reproduce
- Build an agent flow where a reply is generated from an activity/agent-handoff
onEnter (i.e. generateReply() fired programmatically, not directly as a response to the turn that just finished) — e.g. an agent handoff in a multi-agent voice workflow.
- Use VAD-based turn detection.
- Have the user's speech continue into the handoff window — a mid-utterance pause exceeding
minSilence is enough to split one utterance into two turns, with the second turn landing right as the new reply is created but before its TTS audio has started playing.
- The new-turn interrupt hits the reply before playout starts. Observe: the agent never speaks again for the rest of the session.
In a synthetic voice test harness driving this exact flow (scripted utterance with a natural mid-sentence pause landing in the handoff window), the trigger reproduced deterministically on repeated runs.
Observed log signature
"speech interrupted, new user turn detected" fires repeatedly, always pointing at the same speech id, across many subsequent user turns.
- Each subsequent turn creates a new speech handle and LLM inference completes for it, but it never plays —
mainTask never advances past the wedged handle to authorize the next one.
- No
"Aborting all pipeline reply tasks due to interruption" line ever appears for the wedged speech id.
- The session only ends via an external mechanism (idle/duration guard, or user disconnect) — it never recovers on its own.
Environment
@livekit/agents 1.5.3 (Node.js / TypeScript)
- Voice pipeline: STT + LLM + TTS with Silero VAD turn detection (not STT-endpointing based)
- Appears provider-independent — the bug is in the pipeline state machine, not tied to a specific STT/LLM/TTS backend
Workaround
We're running a downstream patch on mainTask's interrupted-branch wait: bound it with a timeout in addition to generation/abortFuture, and on timeout, force-abort the reply task's own tasks (via speechHandle._tasks + the SDK's own task-cancellation helper, which drives the same abort controller raceWithAbort in forwardSegment is waiting on), plus a _markDone() safety net if the tasks don't close in time. This reliably restores the agent's voice within seconds of the interrupt and has shown zero recurrence of the hang signature across repeated runs of the reproduction above.
Related issues
Happy to share more log excerpts or a minimal repro harness if useful.
Describe the bug
In
@livekit/agents1.5.3, when a pipeline reply is interrupted before its playout has started,AgentActivity.mainTaskcan hang forever on the interrupted speech handle'sgenerationfuture._currentSpeechis never cleared, so the agent goes permanently silent — no further replies are ever forwarded to the user until an external mechanism (e.g. an idle/duration guard) tears down the session.This looks like a deeper regression in the same area as #836 / #1124 / #1085. The
waitIfNotInterrupted([generation])guard proposed in #1124 (and now present in currentmainTask) does correctly return early on interrupt — but the very next branch performs a second, unguarded wait on the samegenerationfuture, and that second wait is the one that actually deadlocks.Root cause (traced against 1.5.3
dist/voice/agent_activity.js)mainTask(~line 1424):generationonly resolves via_markGenerationDone()/_markDone()on the speech handle, which only happens once the reply task's own task-done callback fires.abortFutureonly resolves when the whole activity is torn down (not per-speech).The reply task itself gets stuck one level deeper, inside
forwardSegment(~line 2156 in the same file):audioOutput.waitForPlayout()waits for a playback-finished event that will never fire, because playout never started. The only other way out of thisawaitis thereplyAbortController.signalaborting — but that controller's own.abort()call happens later in the same function (the"Aborting all pipeline reply tasks due to interruption"path), in code that is only reached after the segment loop thisawaitsits in returns. The segment loop can't return, because it's blocked on this sameawait. It's a self-referential wait: A blocks on B, and B is only triggered by code that runs after A returns.Net effect: the reply task never calls
_markGenerationDone(), so its done-callback never calls_markDone(), sogenerationinmainTasknever resolves, so_currentSpeechstays stuck on the wedged handle forever.To reproduce
onEnter(i.e.generateReply()fired programmatically, not directly as a response to the turn that just finished) — e.g. an agent handoff in a multi-agent voice workflow.minSilenceis enough to split one utterance into two turns, with the second turn landing right as the new reply is created but before its TTS audio has started playing.In a synthetic voice test harness driving this exact flow (scripted utterance with a natural mid-sentence pause landing in the handoff window), the trigger reproduced deterministically on repeated runs.
Observed log signature
"speech interrupted, new user turn detected"fires repeatedly, always pointing at the same speech id, across many subsequent user turns.mainTasknever advances past the wedged handle to authorize the next one."Aborting all pipeline reply tasks due to interruption"line ever appears for the wedged speech id.Environment
@livekit/agents1.5.3 (Node.js / TypeScript)Workaround
We're running a downstream patch on
mainTask's interrupted-branch wait: bound it with a timeout in addition togeneration/abortFuture, and on timeout, force-abort the reply task's own tasks (viaspeechHandle._tasks+ the SDK's own task-cancellation helper, which drives the same abort controllerraceWithAbortinforwardSegmentis waiting on), plus a_markDone()safety net if the tasks don't close in time. This reliably restores the agent's voice within seconds of the interrupt and has shown zero recurrence of the hang signature across repeated runs of the reproduction above.Related issues
waitIfNotInterrupted([_waitForGeneration()])guard that is now present inmainTask, but that only covers the first wait; this issue is about the second, unguarded wait immediately after itHappy to share more log excerpts or a minimal repro harness if useful.