Files
hermes-agent/tests/hermes_cli/test_web_server_stt_lease.py
Brooklyn Nicholson 7ee2840c06 fix(desktop): pin each voice operation to one owner and fence stale STT continuations
- Resolve the (connection, profile) once per dictation / conversation and use
  it for the warm-up, the transcription (client-direct config + relay) and the
  release; untagged halves stay untagged instead of re-reading ambient scope.
- Recorder and conversation drop any continuation (warm-up, transcript,
  submit) that settles after unmount, end(), or a newer start().
- syncSttLease: every settled-apart acquire is sent (no client freshness
  window); an acquire coalesces only onto a queued acquire that is still the
  latest intent, so on/off/on ends acquired and waits for its own acquire.
- Idle-unload watcher applies the stt.local policy of the caller that last
  loaded/used the shared model, re-read in that caller's context each cycle.
2026-09-30 15:16:40 -05:00

391 lines
15 KiB
Python

"""``POST /api/audio/stt-lease`` — desktop voice input as STT warm-up/release.
The desktop acquires a lease when the mic opens so the backend pre-loads the
configured local faster-whisper model while the user is still speaking; the
transcription request then starts hot instead of paying the cold load inside
its timeout (issue #105955). Releasing the last lease drops the refcount but
— unlike TTS — never unloads the model: the engine is shared with the
gateway/CLI surfaces in the same backend process.
"""
from __future__ import annotations
import time
import pytest
@pytest.fixture
def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
from hermes_constants import get_hermes_home
from hermes_cli import profiles
default_home = get_hermes_home()
profiles_root = default_home / "profiles"
worker_home = profiles_root / "worker_beta"
for home in (default_home, worker_home):
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
(worker_home / ".env").write_text("", encoding="utf-8")
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
return {"default": default_home, "worker_beta": worker_home}
@pytest.fixture
def client(monkeypatch, isolated_profiles):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c
@pytest.fixture(autouse=True)
def _clean_stt_state():
from tools import stt_lease, transcription_tools
stt_lease._reset_stt_leases_for_tests()
saved_model, saved_name = transcription_tools._local_model, transcription_tools._local_model_name
transcription_tools._local_model, transcription_tools._local_model_name = None, None
yield
stt_lease._reset_stt_leases_for_tests()
transcription_tools._local_model, transcription_tools._local_model_name = saved_model, saved_name
def _local_cfg(monkeypatch, model="tiny"):
from tools import transcription_tools
monkeypatch.setattr(transcription_tools, "_HAS_FASTER_WHISPER", True)
monkeypatch.setattr(
transcription_tools, "_load_stt_config", lambda: {"local": {"model": model}}
)
# Explicit local provider without going through the selection-file probe.
monkeypatch.setattr(transcription_tools, "_get_provider", lambda cfg: "local")
def test_active_acquires_and_warms(client, monkeypatch):
from tools import stt_lease
warmed = []
monkeypatch.setattr(
stt_lease,
"warm_stt_provider",
lambda cfg=None, provider=None: warmed.append(1)
or {"provider": "local", "warmed": True, "action": "loaded"},
)
resp = client.post("/api/audio/stt-lease", json={"lease": "desktop:voice-input:abc", "active": True})
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
assert body["lease"] == "desktop:voice-input:abc"
assert body["active"] is True
assert body["leases"] == 1
assert body["action"] == "loaded"
assert warmed == [1]
assert stt_lease.stt_lease_holders() == ["desktop:voice-input:abc"]
def test_inactive_releases_without_unloading(client, monkeypatch):
from tools import stt_lease, transcription_tools
monkeypatch.setattr(
stt_lease, "warm_stt_provider", lambda cfg=None, provider=None: {"action": "noop", "warmed": False, "provider": "openai"}
)
client.post("/api/audio/stt-lease", json={"lease": "desktop:voice-input:a", "active": True})
client.post("/api/audio/stt-lease", json={"lease": "desktop:voice-input:b", "active": True})
# A resident model shared with other surfaces must survive the releases.
sentinel = object()
transcription_tools._local_model, transcription_tools._local_model_name = sentinel, "tiny"
first = client.post("/api/audio/stt-lease", json={"lease": "desktop:voice-input:a", "active": False}).json()
assert first["leases"] == 1
assert transcription_tools._local_model is sentinel
last = client.post("/api/audio/stt-lease", json={"lease": "desktop:voice-input:b", "active": False}).json()
assert last["leases"] == 0
assert transcription_tools._local_model is sentinel
def test_warm_failure_is_reported_not_an_http_error(client, monkeypatch):
from tools import stt_lease
def _boom(cfg=None, provider=None):
raise RuntimeError("engine exploded")
monkeypatch.setattr(stt_lease, "warm_stt_provider", _boom)
resp = client.post("/api/audio/stt-lease", json={"lease": "desktop:voice-input:abc", "active": True})
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
assert body["action"] == "error"
assert "engine exploded" in body["error"]
def test_blank_lease_rejected(client):
resp = client.post("/api/audio/stt-lease", json={"lease": " ", "active": True})
assert resp.status_code == 400
def test_warm_loads_configured_local_model_into_transcription_slot(monkeypatch):
from tools import stt_lease, transcription_tools
_local_cfg(monkeypatch, model="small")
loaded = []
monkeypatch.setattr(
transcription_tools,
"_get_or_load_local_model",
lambda name, cfg: loaded.append((name, cfg)) or object(),
)
result = stt_lease.warm_stt_provider()
assert result["warmed"] is True
assert result["provider"] == "local"
assert result["action"] == "loaded"
# Same name transcription resolves (config model, normalized) and the
# same loader transcription reads — not a shadow cache.
assert loaded and loaded[0][0] == "small"
assert "elapsed_ms" in result
def test_warm_reports_cached_when_model_already_resident(monkeypatch):
from tools import stt_lease, transcription_tools
_local_cfg(monkeypatch, model="tiny")
sentinel = object()
transcription_tools._local_model, transcription_tools._local_model_name = sentinel, "tiny"
calls = []
monkeypatch.setattr(
transcription_tools,
"_get_or_load_local_model",
lambda name, cfg: calls.append(name) or sentinel,
)
result = stt_lease.warm_stt_provider()
assert result == {
"provider": "local",
"warmed": True,
"action": "cached",
"elapsed_ms": result["elapsed_ms"],
}
def test_warm_is_noop_for_cloud_providers(monkeypatch):
from tools import stt_lease, transcription_tools
monkeypatch.setattr(transcription_tools, "_load_stt_config", lambda: {"provider": "openai"})
monkeypatch.setattr(transcription_tools, "_get_provider", lambda cfg: "openai")
calls = []
monkeypatch.setattr(
transcription_tools, "_get_or_load_local_model", lambda name, cfg: calls.append(name)
)
result = stt_lease.warm_stt_provider()
assert result["warmed"] is False
assert result["action"] == "noop"
assert calls == []
def test_warm_never_raises_on_engine_failure(monkeypatch):
from tools import stt_lease, transcription_tools
_local_cfg(monkeypatch)
def _boom(name, cfg):
raise RuntimeError("CUDA exploded")
monkeypatch.setattr(transcription_tools, "_get_or_load_local_model", _boom)
result = stt_lease.warm_stt_provider()
assert result["warmed"] is False
assert result["action"] == "error"
assert "CUDA exploded" in result["error"]
def test_warm_only_load_starts_idle_unload_watcher(monkeypatch):
"""A model loaded ONLY by warm-up (mic open → cancel, no transcription)
must still be governed by ``stt.local.unload_after_idle_seconds``.
Pre-fix, ``_start_idle_unload_watcher`` only ran after a successful
``_transcribe_local``, so the warm-only path left the model resident with
no watcher until some later transcription happened. The regression
executes the real acquire/load path with a fresh process-like state and
asserts the watcher started and eventually evicts the model.
"""
from tools import stt_lease, transcription_tools
_local_cfg(monkeypatch)
monkeypatch.setattr(
transcription_tools,
"_load_stt_config",
lambda: {"local": {"model": "tiny", "unload_after_idle_seconds": 1}},
)
loaded = []
monkeypatch.setattr(transcription_tools, "_IDLE_UNLOAD_CHECK_INTERVAL", 0.01)
monkeypatch.setattr(transcription_tools, "_local_model", None)
monkeypatch.setattr(transcription_tools, "_local_model_name", None)
monkeypatch.setattr(transcription_tools, "_idle_unload_thread", None)
# Backdate the activity so the first watcher cycle (0.01s) already sees
# the configured idle window as elapsed.
monkeypatch.setattr(transcription_tools, "_last_transcription_time", 0.0)
real_model = object()
monkeypatch.setattr(
transcription_tools,
"_get_or_load_local_model",
lambda name, cfg: loaded.append(name) or real_model,
)
# The idle watcher reads the module global, not the warm-up's local ref,
# so mirror the singleton the real loader would have written.
transcription_tools._local_model = real_model
transcription_tools._local_model_name = "tiny"
result = stt_lease.warm_stt_provider()
assert result["warmed"] is True
assert loaded == ["tiny"]
# The watcher is running (fresh process: no prior watcher thread).
assert transcription_tools._idle_unload_thread is not None
assert transcription_tools._idle_unload_thread.is_alive()
# ...and eventually enforces the configured idle lifetime.
for _ in range(200):
if transcription_tools._local_model is None:
break
time.sleep(0.02)
assert transcription_tools._local_model is None
def test_warm_reacquire_after_eviction_restarts_watcher(monkeypatch):
"""After a prior watcher evicted its model and exited, a warm-up that
reloads it must start a NEW watcher — the old thread is gone."""
from tools import stt_lease, transcription_tools
_local_cfg(monkeypatch)
monkeypatch.setattr(
transcription_tools,
"_load_stt_config",
lambda: {"local": {"model": "tiny", "unload_after_idle_seconds": 60}},
)
monkeypatch.setattr(transcription_tools, "_IDLE_UNLOAD_CHECK_INTERVAL", 0.5)
# Simulate the post-eviction world: model gone, previous watcher exited.
monkeypatch.setattr(transcription_tools, "_local_model", None)
monkeypatch.setattr(transcription_tools, "_local_model_name", None)
monkeypatch.setattr(
transcription_tools, "_idle_unload_thread", type("Dead", (), {"is_alive": lambda self: False})()
)
real_model = object()
monkeypatch.setattr(
transcription_tools, "_get_or_load_local_model", lambda name, cfg: real_model
)
transcription_tools._local_model = real_model
transcription_tools._local_model_name = "tiny"
result = stt_lease.warm_stt_provider()
assert result["warmed"] is True
# A NEW watcher is running for the reloaded model.
assert transcription_tools._idle_unload_thread is not None
assert transcription_tools._idle_unload_thread.is_alive()
# Clean up the daemon so it does not leak into other tests.
transcription_tools._idle_unload_stop.set()
transcription_tools._idle_unload_thread.join(timeout=2)
transcription_tools._idle_unload_stop.clear()
transcription_tools._local_model = None
transcription_tools._local_model_name = None
transcription_tools._idle_unload_thread = None
def _watch_under_profiles(monkeypatch, *owners):
"""Start/refresh the idle watcher from each (profile, idle_seconds) caller in order, each
inside its own context — the same ContextVar mechanism a request's profile scope uses."""
import contextvars
import threading
from tools import transcription_tools
class _GatedStop:
"""The watcher's first tick waits until every owner has been registered."""
def __init__(self):
self.event = threading.Event()
self.gate = threading.Event()
def wait(self, timeout):
self.gate.wait(2)
return self.event.wait(timeout)
def set(self):
self.event.set()
self.gate.set()
def clear(self):
self.event.clear()
stop = _GatedStop()
monkeypatch.setattr(transcription_tools, "_idle_unload_stop", stop)
profile = contextvars.ContextVar("test_stt_profile", default="launch")
idle_by_profile = {"launch": 3600, **dict(owners)}
monkeypatch.setattr(
transcription_tools,
"_load_stt_config",
lambda: {"local": {"model": "tiny", "unload_after_idle_seconds": idle_by_profile[profile.get()]}},
)
monkeypatch.setattr(transcription_tools, "_IDLE_UNLOAD_CHECK_INTERVAL", 0.01)
monkeypatch.setattr(transcription_tools, "_idle_unload_thread", None)
monkeypatch.setattr(transcription_tools, "_last_transcription_time", time.monotonic() - 2)
transcription_tools._local_model = object()
transcription_tools._local_model_name = "tiny"
for name, idle in owners:
def _call(name=name, idle=idle):
profile.set(name)
transcription_tools._start_idle_unload_watcher(idle)
contextvars.copy_context().run(_call)
stop.gate.set()
for _ in range(50):
if transcription_tools._local_model is None:
break
time.sleep(0.01)
evicted = transcription_tools._local_model is None
stop.set()
if transcription_tools._idle_unload_thread is not None:
transcription_tools._idle_unload_thread.join(timeout=2)
transcription_tools._local_model = None
transcription_tools._local_model_name = None
transcription_tools._idle_unload_thread = None
return evicted
def test_idle_watcher_applies_the_profile_that_warmed_the_model(monkeypatch):
"""#128668 review: the watcher thread has no request scope of its own. It must read
``unload_after_idle_seconds`` in the scope of the caller that loaded the model, not the
launch profile's (3600s here), or a 1s policy never fires."""
assert _watch_under_profiles(monkeypatch, ("worker_beta", 1)) is True
def test_idle_watcher_follows_the_latest_owner_of_the_shared_model(monkeypatch):
"""A → B → A: the live watcher adopts each new owner's policy instead of keeping the one
it was started under."""
assert _watch_under_profiles(monkeypatch, ("alpha", 3600), ("worker_beta", 1)) is True
assert _watch_under_profiles(monkeypatch, ("worker_beta", 1), ("alpha", 3600)) is False