fix(voice): stop the interrupt walk at a queued speech that disallows interruptions - #6644
Open
biztex wants to merge 5 commits into
Open
fix(voice): stop the interrupt walk at a queued speech that disallows interruptions#6644biztex wants to merge 5 commits into
biztex wants to merge 5 commits into
Conversation
SpeechHandle.interrupt() raises when the handle disallows interruptions, and that raise escaped from the middle of AgentActivity.interrupt(): the preemptive generation was already cancelled and the background speeches already interrupted, while the queue kept playing, the realtime session was never told and the returned future never resolved. Background speeches were filtered but queued ones were not, so a queued say(allow_interruptions=False) aborted the sequence as well. The playing speech is now checked before anything is touched (force=True still interrupts it) and queued handles are filtered like the background ones. The raise is documented on both interrupt() entry points.
This was referenced Jul 31, 2026
longcw
reviewed
Jul 31, 2026
…ed speech filtering the queue per handle punched a hole in it: with [protected, interruptible, protected] the middle speech was dropped while the other two still played. The walk now follows the playout order - the playing speech, then the queue sorted as it will be popped, since _speech_q is a heap whose list order is not that order - and stops at the first speech that disallows interruptions, so everything behind it plays untouched. The preemptive generation and the background speeches do not depend on the playing speech, so they are cancelled unconditionally as before. The realtime cancel is sent when nothing is playing or the playing speech was actually interrupted; otherwise it would truncate the speech that was kept.
the guard keyed off _current_speech, which is wrong in two windows. A realtime generation sits in _speech_q until the scheduling task promotes it, so with nothing playing yet the walk could keep a protected queue head and still cancel the response belonging to it. And SpeechHandle._cancel() is a no-op once the handle is done, leaving `interrupted` False for a done but not yet cleared current speech - the provider was then left generating even though nothing was protected. The cancel now depends on whether the walk kept the head of the playout order.
a done or force-interrupted handle lingers in _current_speech and in the queue until the scheduling task drops it. When such a stale handle disallowed interruptions the walk stopped on it, leaving the live speeches behind it playing and suppressing the realtime cancel, even though it can no longer leave a gap in the conversation. It is skipped now, and the cancel depends on whether the first speech that will actually play was kept - walking past a stale handle must not make a live protected one behind it look interrupted.
longcw
reviewed
Aug 1, 2026
…realtime per review: nothing maps the streaming realtime response to a SpeechHandle, so queue position cannot stand in for ownership - the cancel is unconditional again, as before this PR. The queue is walked in pop order and stops at the first speech that disallows interruptions, with a warning naming the handle, so the speeches behind it keep playing instead of the raise escaping and leaving them scheduled with the future unresolved.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A bug in
AgentActivity.interrupt(), independent of how #6635 gets fixed — found while working on #6638, which was closed in favour of #6642. It isn't realtime-specific.Problem
SpeechHandle.interrupt()raisesRuntimeErrorwhen the handle disallows interruptions, and the queue loop calls it on every queued speech without handling that. With an interruptible speech playing and a protected one queued behind it, the raise escapes from the middle ofinterrupt(): the remaining queued speeches are never interrupted and the returned future never resolves.Reachable today without a realtime model:
session.say("...", allow_interruptions=False)queued behind another speech, then anysession.interrupt()from user code.Fix
The queue walk stops at the first speech that disallows interruptions, logging a warning that names the handle and points at
force=True. Everything behind it is going to play, so interrupting past it would leave a gap in the conversation — skipping per handle would drop the middle speech of[protected, interruptible, protected]and play the other two.The queue is walked sorted by
(priority, sequence), since_speech_qis a heap and its list order is not the order it pops in.Behaviour that is deliberately unchanged: the preemptive generation and the background speeches are cancelled as before,
rt_session.interrupt()is still called unconditionally (nothing maps the streaming realtime response to aSpeechHandle, so there is no sound way to decide it belongs to a speech that was kept), a protected speech that is currently playing still raises, andforce=Trueinterrupts the whole chain.Verification
tests/test_interrupt_protected_speech.pycovers stopping mid-queue with the speeches behind untouched and the warning emitted, the[protected, interruptible, protected]no-hole case, a priority-ordered case where heap order differs from pop order, the playing-protected speech still raising,force=True, and the ordinary all-interruptible path.ruff format --check,ruff check,check_types.py(mypy strict) and the fullpytest --unitsuite pass locally.