Suite du commit 35b27ae49 (lock async sur /replay/next) qui n'avait
traité que la moitié du problème. Le sprint QW4 (commit f5c33477f)
a recâblé le polling frontend PauseDialog vers /replay/{replay_id} →
get_replay_status, qui gardait un `with _replay_lock:` synchrone.
Conséquence : dès qu'une action serveur (extract_text/extract_table/
t2a_decision) tient le lock, l'event loop FastAPI gèle entièrement
(heartbeats Windows, polls replay/next, get_replay_status, tout).
Reproduit aujourd'hui en pré-démo : un replay urgences a fait
extract_text → la queue suivante a tenu le lock → polling VWB sur
get_replay_status a bloqué le MainThread asyncio → 23 minutes de
gel total (py-spy a confirmé MainThread sur api_stream.py:4117).
Modifications :
1. get_replay_status : acquire timeboxé 0.5s via run_in_executor
(même pattern que /replay/next ligne 2815). Si le lock est tenu,
retour immédiat {status: "busy"} → le frontend retentera dans 1s.
Aucun cas où ce poll bloque l'event loop.
2. Actions serveur lignes 2994/3000/3006 : enveloppées dans
asyncio.wait_for(timeout=180). Borne dure pour qu'un hang
d'EasyOCR / Ollama / I/O ne tienne plus jamais le lock
indéfiniment. TimeoutError est rattrapée par l'except Exception
existant → queue.pop(0) → on continue.
Tests : 27/27 baseline sprint QW verts.