# agent_v1/ui/session_watchdog.py """Watchdog de session interactive Windows — résilience RDP/Citrix. Problème résolu (preuve poste clinique Émilie, 01/07) : 09:46:28 [MAIN] agent.run() est sorti mais agent.running=True — probablement pystray sans session interactive (SSH) 09:46:28 [MAIN] Keepalive headless actif — main thread bloque... Sur les postes cliniques (tous RDP/Citrix), la session interactive disparaît quand l'utilisateur se déconnecte / la session bascule en verrouillage. `pystray.Icon.run()` sort alors immédiatement (plus de bureau interactif `WinSta0\\Default` pour recevoir les entrées et afficher l'icône). L'ancien `_headless_keepalive` bloquait le main thread *pour toujours* : l'icône tray + la fenêtre chat DISPARAISSAIENT et ne revenaient JAMAIS, même après reconnexion RDP. Les soignants croyaient que Léa avait planté (la capture continuait pourtant en fond). Solution : un watchdog qui surveille la disponibilité du bureau interactif via `OpenInputDesktop()` (signal Win32 canonique — échoue quand la session est déconnectée/verrouillée, réussit à la reconnexion) et (re)lance l'UI tray dès qu'une session redevient disponible. Les threads de fond (heartbeat, replay poll, capture_server) NE SONT JAMAIS touchés : ils tournent contre `agent.running` et restent uniques. On ne relance JAMAIS un second `AgentV1` — seulement la couche UI (tray + chat). État de l'art (recherche 01/07) : - `OpenInputDesktop()` échoue (ERROR_ACCESS_DENIED / ERROR_INVALID_...) quand le processus n'est pas rattaché au windowstation interactif `WinSta0` — c'est exactement le cas quand la session RDP est déconnectée. C'est la méthode fiable recommandée (comparer les *noms* de bureau via GetUserObjectInformation n'apporte rien de plus ici : on a juste besoin d'un booléen « input desktop dispo ? »). - `WTSGetActiveConsoleSessionId` renvoie une pseudo-session même sans login → PAS fiable pour ce besoin. - `pystray.Icon.run()` ne sort jamais en session interactive normale ; il sort immédiatement sinon → c'est notre signal de « session perdue ». """ from __future__ import annotations import logging import platform import threading from typing import Callable, Optional logger = logging.getLogger(__name__) # Intervalle de sondage du bureau interactif (secondes). # 3s = compromis : réactif à la reconnexion sans marteler l'API Win32. POLL_INTERVAL_S = 3.0 def is_interactive_desktop_available() -> bool: """Retourne True si un bureau interactif Windows est disponible. Utilise `OpenInputDesktop()` : succès => le windowstation interactif (`WinSta0\\Default`) est accessible et peut afficher un tray. Échec => session RDP/Citrix déconnectée ou verrouillée sans bureau d'entrée. Hors Windows (Linux/dev/tests) : renvoie toujours True (pas de notion de bureau interactif verrouillable ici — on laisse l'UI tourner). Toute erreur d'appel Win32 est traitée comme « indisponible » (prudent) SAUF l'indisponibilité de l'API elle-même (pywin32 absent) → True pour ne pas priver un poste de son tray à cause d'une dépendance manquante. """ if platform.system() != "Windows": return True try: import win32con # type: ignore import win32service # type: ignore except Exception: # pywin32 indisponible : on ne peut pas sonder → on suppose dispo # (comportement historique : tenter l'UI plutôt que la bloquer). logger.debug("pywin32 indisponible — sondage bureau interactif ignoré") return True hdesk = None try: # DESKTOP_SWITCHDESKTOP (0x0100) = droit minimal, aligné sur l'usage # documenté pour tester la présence du bureau d'entrée. hdesk = win32service.OpenInputDesktop(0, False, win32con.DESKTOP_SWITCHDESKTOP) return hdesk is not None except Exception: # OpenInputDesktop lève quand aucun bureau d'entrée n'est accessible # (session déconnectée / verrouillée). C'est le cas « indisponible ». return False finally: if hdesk is not None: try: # PyHANDLE se ferme via .Close() (pywin32) ; fallback silencieux. hdesk.Close() except Exception: pass class InteractiveSessionWatchdog: """Surveille la session interactive et (re)lance l'UI tray à la reconnexion. Ne détient AUCUN état de capture. Sa seule responsabilité : garantir qu'il existe au plus UN tray vivant à la fois, et le ressusciter quand une session interactive redevient disponible. Les daemon threads de l'agent (heartbeat/replay/capture) sont indépendants et intacts. Paramètres : run_ui : callable bloquant qui lance le tray (typiquement ``agent.ui.run`` / ``agent.run``). Retourne quand le tray sort (normal en fin de session interactive). is_running : callable -> bool ; True tant que l'agent doit vivre (typiquement ``lambda: agent.running``). is_available : callable -> bool de détection de session (injectable pour les tests). Défaut = is_interactive_desktop_available. poll_interval_s : période de sondage quand la session est absente. """ def __init__( self, run_ui: Callable[[], None], is_running: Callable[[], bool], is_available: Optional[Callable[[], bool]] = None, poll_interval_s: float = POLL_INTERVAL_S, ) -> None: self._run_ui = run_ui self._is_running = is_running self._is_available = is_available or is_interactive_desktop_available self._poll_interval_s = poll_interval_s self._wake = threading.Event() # Sérialise le lancement de l'UI : jamais deux trays en parallèle. self._ui_lock = threading.Lock() def stop(self) -> None: """Réveille le watchdog pour qu'il réévalue ``is_running`` et sorte.""" self._wake.set() def _run_ui_once(self) -> None: """Lance l'UI tray une fois (bloquant) sous verrou, avec garde d'erreur. Le verrou empêche formellement qu'un second appel démarre un tray alors qu'un premier tourne encore (invariant « un seul tray »). """ with self._ui_lock: try: self._run_ui() except Exception: # Un crash du tray ne doit jamais tuer le watchdog : on log et # on laisse la boucle décider (retry ou sortie selon is_running). logger.exception("[WATCHDOG] Le tray UI a levé une exception") def run(self) -> None: """Boucle principale (bloque le main thread à la place du keepalive). Cycle : 1. Attendre qu'un bureau interactif soit disponible. 2. (Re)lancer le tray — bloque jusqu'à sa sortie (déconnexion RDP). 3. Recommencer tant que ``is_running`` est vrai. Ne consomme pas de CPU en boucle serrée : sonde toutes les ``poll_interval_s`` via un Event interruptible (réveil immédiat au stop). """ logger.info( "[WATCHDOG] Surveillance session interactive active " "(re-affichage auto du tray + chat à la reconnexion RDP/Citrix)." ) first_cycle = True while self._is_running(): if not self._is_available(): # Session absente : sonder périodiquement sans brûler le CPU. if first_cycle: logger.warning( "[WATCHDOG] Aucune session interactive — Léa reste active " "en fond (capture/heartbeat), tray masqué. En attente de " "reconnexion RDP/Citrix pour ré-afficher l'interface." ) # Event.wait renvoie True si stop() a été appelé → on sort. if self._wake.wait(timeout=self._poll_interval_s): break first_cycle = False continue # Session disponible : (re)lancer le tray. if not first_cycle: logger.info( "[WATCHDOG] Session interactive détectée — ré-affichage du " "tray et de la fenêtre chat de Léa." ) first_cycle = False # Bloque jusqu'à la sortie du tray (fin de session interactive). self._run_ui_once() # Le tray est sorti. Si l'agent doit vivre, on reboucle (le # prochain tour re-sondera la session et re-affichera le tray). if not self._is_running(): break logger.info("[WATCHDOG] Arrêt de la surveillance de session interactive.")