fix(tui_gateway): end turn audio before settlement, defer only the trim

Gate finding (2c + quality, convergent): the settle-before-trim split moved
the thinking-sound stop and the TTS end-of-text sentinel after
session["running"] = False. Both are non-blocking (a lock + Event.set; an
unbounded queue put), so deferring them gained nothing, and it opened a
window where a prompt admitted on the same session during the trim had its
thinking sound stopped by the old thread and the old stream's final chunk
cut as a barge-in. They run in _release_turn_scopes again, before
settlement; _post_turn_housekeeping keeps only the trim and the
HERMES_HOME override reset. (The original commit subject says "trim/TTS";
only the trim is deferred now.)

The settle-before-trim test now also asserts both audio teardown steps ran
while the session was still running.
This commit is contained in:
kshitijk4poor
2026-10-03 15:36:06 +05:30
committed by kshitij
parent 5babdc36f6
commit 9b59ce6426
2 changed files with 16 additions and 9 deletions

View File

@@ -107,8 +107,10 @@ def test_accepted_and_finished_records_on_success(turn_env, caplog):
def test_turn_settles_before_post_turn_trim(monkeypatch, caplog):
"""A blocked post-turn trim must not hold the session running or its bookend (#131740)."""
"""A blocked post-turn trim must not hold the session running or its bookend (#131740);
turn audio still ends BEFORE settlement, so a next turn admitted during the trim keeps its own."""
import hermes_cli.mem_trim as mem_trim
import tools.voice_mode as voice_mode
entered, release = threading.Event(), threading.Event()
@@ -122,6 +124,10 @@ def test_turn_settles_before_post_turn_trim(monkeypatch, caplog):
monkeypatch.setattr(server, "_sync_agent_model_with_config", lambda sid, session: None)
monkeypatch.setattr(server, "_tts_stream_begin", lambda: None)
monkeypatch.setattr(server, "_get_usage", lambda agent: {})
audio_end = [] # (event, session running at that moment)
tts = types.SimpleNamespace(put=lambda x: x is None and audio_end.append(("tts", session["running"])))
monkeypatch.setattr(server, "_start_turn_voice", lambda: (tts, True))
monkeypatch.setattr(voice_mode, "stop_thinking_sound", lambda: audio_end.append(("thinking", session["running"])))
agent = types.SimpleNamespace(
session_id="agent-sid-1", run_conversation=lambda *a, **k: {"final_response": "done"},
clear_interrupt=lambda: None)
@@ -133,6 +139,7 @@ def test_turn_settles_before_post_turn_trim(monkeypatch, caplog):
assert entered.wait(timeout=5)
assert session["running"] is False
assert len(_records(caplog, "tui turn finished")) == 1
assert audio_end == [("thinking", True), ("tts", True)]
finally:
release.set()
session["_run_thread"].join(timeout=5)

View File

@@ -1028,13 +1028,19 @@ def _recover_turn_exception(sid: str, session: dict, st: _TurnRun, e: BaseExcept
def _release_turn_scopes(sid: str, session: dict, st: _TurnRun) -> None:
"""Finally-path, before settlement: drop snapshots, undo a one-turn model, reset scopes (home stays bound)."""
"""Finally-path, before settlement: drop snapshots, end turn audio, undo a one-turn model, reset scopes (not home)."""
# Drop both pre-turn history snapshots before asking glibc to return pages (a test
# inspects these two locals by name).
history, run_kwargs = st.history, st.run_kwargs
history.clear()
if isinstance(run_kwargs, dict):
run_kwargs.clear()
if st.thinking_started:
with contextlib.suppress(Exception):
from tools.voice_mode import stop_thinking_sound
stop_thinking_sound()
if st.tts_queue is not None:
st.tts_queue.put(None) # end-of-text sentinel — flush + finish speaking
if st.one_turn_restore:
try:
_restore_agent_model_runtime(st.agent, st.one_turn_restore)
@@ -1057,7 +1063,7 @@ def _release_turn_scopes(sid: str, session: dict, st: _TurnRun) -> None:
def _post_turn_housekeeping(sid: str, session: dict, st: _TurnRun) -> None:
"""Best-effort tail AFTER the turn settled: a slow trim/TTS flush must not hold the bookend (#131740)."""
"""Best-effort tail AFTER the turn settled: a slow trim must not hold the bookend (#131740)."""
try: # while the profile HERMES_HOME override is still active (session's own config)
from hermes_cli.mem_trim import trim_memory
# Every OTHER session must be idle (#58576).
@@ -1065,12 +1071,6 @@ def _post_turn_housekeeping(sid: str, session: dict, st: _TurnRun) -> None:
trim_memory(reason="tui turn completion")
except Exception:
logger.debug("post-turn memory trim failed", exc_info=True)
if st.thinking_started:
with contextlib.suppress(Exception):
from tools.voice_mode import stop_thinking_sound
stop_thinking_sound()
if st.tts_queue is not None:
st.tts_queue.put(None) # end-of-text sentinel — flush + finish speaking
if st.scopes.home is not None:
reset_hermes_home_override(st.scopes.home)