# AXE B1 — Deep dive : watchdog serveur `_retry_pending` **Date :** 2026-05-24 **Auteur :** Claude (recherche dispatchée, lecture seule sur code) **Périmètre :** approfondissement de la section §6 de `AXE_B1_REPLAY_TRANSPORT.md` (le pseudo-code y était esquissé, ici on rend tout production-ready et on tranche les questions ouvertes). **Pré-requis lecture :** - `docs/recherche/AXE_B1_REPLAY_TRANSPORT.md` (transport SSE/WebSocket — couvert, pas réabordé) - `docs/REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md` (diagnostic 9 actions perdues) > Ce document ne refait PAS le tour SSE/WebSocket. Il livre **un module Python complet, un patch de câblage, une politique de retry, des tests, de l'observabilité, et l'arbre de décision concurrence-tardif**. --- ## 1. TL;DR — sortie actionnable 1. **Créer** `agent_v0/server_v1/replay_watchdog.py` (§3 ci-dessous, ~270 lignes copy-paste-ready). 2. **Patcher** 4 emplacements dans `api_stream.py` : enrichissement schéma `_retry_pending` (ligne 3354), boot/teardown du watchdog (ligne 791/900), idempotence dans `report_action_result` (ligne 3491 — déjà OK, juste pop additionnel des nouveaux champs). 3. **Variables d'env** : `RPA_WATCHDOG_ENABLED=1`, `RPA_WATCHDOG_SCAN_INTERVAL_S=10`, `RPA_WATCHDOG_ORPHAN_TIMEOUT_S=30`, `RPA_WATCHDOG_MAX_RETRIES=2`, `RPA_WATCHDOG_REPUSH_POSITION=head`. 4. **Effort réel** : **3h30** (45min schéma + watchdog, 30min câblage, 1h tests pytest, 1h chasse aux races sur run E2E réel, 15min docs/`DETTE_TECHNIQUE.md`). 5. **Filet** complémentaire à SSE (ceinture+bretelles) — déployable AUJOURD'HUI sur transport pull/poll actuel sans toucher au client Léa Windows. --- ## 2. Schéma de données enrichi `_retry_pending` ### Avant (ligne 3355-3359 actuelle) ```python _retry_pending[action_id_sent] = { "action": dict(action), "retry_count": 0, "replay_id": "", } ``` ### Après ```python _retry_pending[action_id_sent] = { "action": dict(action), "retry_count": 0, # incrémenté par _schedule_retry (sémantique métier : retry d'échec d'exécution) "replay_id": owning_replay.get("replay_id", "") if owning_replay else "", # === Nouveaux champs watchdog === "session_id": session_id, # nécessaire pour re-pousser dans _replay_queues[session_id] "machine_id": machine_id, # nécessaire pour la résolution SSE future + logs "dispatched_at": time.time(), # epoch float — pivot du timeout "resent_count": 0, # incrémenté par le watchdog (sémantique transport : re-dispatch suite à orphan) "last_resent_at": 0.0, # epoch float, 0 = jamais re-dispatché "first_dispatched_at": time.time(), # pour métriques latence orphan→resend } ``` **Pourquoi 2 compteurs `retry_count` ET `resent_count` ?** Ils traduisent 2 sémantiques distinctes : - `retry_count` = échec applicatif (action a échoué côté Léa, on retente). Géré par `_schedule_retry` (replay_engine.py:2583). - `resent_count` = perte transport (Léa n'a même pas reçu). Géré par le watchdog. **Ne consomme pas le budget retry métier** — sinon une simple coupure réseau brûle 3 tentatives d'apprentissage utiles. **Pourquoi `last_resent_at` séparé de `dispatched_at` ?** Le watchdog remet `dispatched_at=0` au moment du repush en queue (sera réécrit au prochain DISPATCH). Mais on veut tracer en logs *quand on a re-dispatché pour la dernière fois* (debug "pourquoi mon action revient toutes les 30s"). --- ## 3. Module complet `replay_watchdog.py` (copy-paste ready) À placer : `/home/dom/ai/rpa_vision_v3/agent_v0/server_v1/replay_watchdog.py` ```python """ Watchdog d'orphelins pour _retry_pending. Problème adressé (diagnostic 8 mai 2026, doc REPLAY_BLOCAGE_NOTES_MEDICALES) : le client Léa Windows peut couper sa socket HTTP (timeout court, NoMachine freeze, crash) PENDANT que le serveur écrit la réponse contenant une action. L'action est déjà *poppée* de _replay_queues et stockée dans _retry_pending, mais jamais re-dispatchée car le client n'enverra pas de REPORT — il n'a rien reçu. Résultat : action perdue silencieusement, replay paused on the next non-related step. Solution : coroutine asyncio background lancée au startup FastAPI. Scan toutes les N secondes. Si une action a été dispatched_at + timeout sans report → repush en tête (ou queue) de _replay_queues + reset dispatched_at. Plafond _MAX_RESENDS pour éviter les boucles infinies sur action toxique. Idempotence garantie par report_action_result qui pop _retry_pending. Si un report tardif arrive APRÈS un resend, _retry_pending.pop retourne None et le report est gracieusement ignoré (cf. arbre de décision §5 du doc deep dive). Compatible transport pull/poll actuel ET SSE futur (ceinture+bretelles). Référence : docs/recherche/AXE_B1_DEEP_WATCHDOG.md """ from __future__ import annotations import asyncio import contextlib import logging import os import time from typing import Any, Callable, Dict, List, Optional, Tuple logger = logging.getLogger(__name__) # ============================================================================ # Configuration (lue au démarrage, pas hot-reloadable — restart systemctl) # ============================================================================ def _env_bool(name: str, default: str) -> bool: return os.environ.get(name, default).strip().lower() in ("1", "true", "yes", "on") def _env_float(name: str, default: float) -> float: try: return float(os.environ.get(name, str(default))) except (TypeError, ValueError): logger.warning("Watchdog: env %s invalide, fallback %s", name, default) return default def _env_int(name: str, default: int) -> int: try: return int(os.environ.get(name, str(default))) except (TypeError, ValueError): logger.warning("Watchdog: env %s invalide, fallback %d", name, default) return default WATCHDOG_ENABLED: bool = _env_bool("RPA_WATCHDOG_ENABLED", "1") WATCHDOG_SCAN_INTERVAL_S: float = _env_float("RPA_WATCHDOG_SCAN_INTERVAL_S", 10.0) WATCHDOG_ORPHAN_TIMEOUT_S: float = _env_float("RPA_WATCHDOG_ORPHAN_TIMEOUT_S", 30.0) WATCHDOG_MAX_RESENDS: int = _env_int("RPA_WATCHDOG_MAX_RESENDS", 2) # "head" = repush en tête (préserve l'ordre des steps suivants — DÉFAUT). # "tail" = repush en queue (autorise les actions arrivées entre temps à passer # devant — utile si l'action orpheline n'est pas en cause d'un blocage). WATCHDOG_REPUSH_POSITION: str = os.environ.get( "RPA_WATCHDOG_REPUSH_POSITION", "head" ).strip().lower() # ============================================================================ # Métriques Prometheus-like (no-op si prometheus_client absent — projet ne le # requiert pas). On expose via logger.info [METRIC] pour grep facile + dashboard # downstream possible. # ============================================================================ _metrics_lock = asyncio.Lock() _metrics: Dict[str, Any] = { "orphans_detected_total": 0, "orphans_resent_total": 0, "orphans_giveup_total": 0, "scans_total": 0, "scans_failed_total": 0, "last_scan_ts": 0.0, "last_scan_duration_ms": 0.0, "current_in_flight_count": 0, "current_orphan_count": 0, } async def _bump(key: str, delta: int = 1) -> None: async with _metrics_lock: _metrics[key] = _metrics.get(key, 0) + delta def get_metrics_snapshot() -> Dict[str, Any]: """Snapshot non bloquant — pour endpoint /healthz/watchdog.""" return dict(_metrics) # ============================================================================ # Cœur du watchdog # ============================================================================ # Type alias pour la signature de notification SSE (future) — passé via DI au # constructeur pour ne pas créer de dépendance circulaire avec api_stream. SseNotifier = Callable[[str, str], None] # (session_id, machine_id) -> None class ReplayWatchdog: """Coroutine background scannant _retry_pending pour repush les orphelins. Cycle de vie : - démarré dans le startup event FastAPI : `await wd.start()` - arrêté dans le shutdown event : `await wd.stop()` Thread-safety : tout le travail se fait dans la coroutine asyncio (event loop unique de uvicorn). Les accès à _retry_pending et _replay_queues passent par `async_lock_factory()` (un context manager async — typiquement `_async_replay_lock` de api_stream.py). Pas de thread/lock.acquire mêlés. """ def __init__( self, retry_pending: Dict[str, Dict[str, Any]], replay_queues: Dict[str, List[Dict[str, Any]]], async_lock_factory: Callable[[], Any], sse_notifier: Optional[SseNotifier] = None, ) -> None: self._retry_pending = retry_pending self._replay_queues = replay_queues self._async_lock = async_lock_factory self._sse_notifier = sse_notifier self._task: Optional[asyncio.Task] = None self._stopped = asyncio.Event() # ------------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------------ async def start(self) -> None: if not WATCHDOG_ENABLED: logger.info( "[WATCHDOG] Désactivé via RPA_WATCHDOG_ENABLED=0 — pas de scan" ) return if self._task is not None and not self._task.done(): logger.warning("[WATCHDOG] Déjà démarré — start() ignoré") return self._stopped.clear() self._task = asyncio.create_task(self._run(), name="replay_watchdog") logger.info( "[WATCHDOG] Démarré (scan=%.1fs orphan_timeout=%.1fs " "max_resends=%d repush=%s)", WATCHDOG_SCAN_INTERVAL_S, WATCHDOG_ORPHAN_TIMEOUT_S, WATCHDOG_MAX_RESENDS, WATCHDOG_REPUSH_POSITION, ) async def stop(self, timeout_s: float = 5.0) -> None: if self._task is None: return self._stopped.set() self._task.cancel() try: await asyncio.wait_for(self._task, timeout=timeout_s) except asyncio.CancelledError: pass except asyncio.TimeoutError: logger.warning( "[WATCHDOG] Stop : task n'a pas terminé en %.1fs (forcé)", timeout_s, ) except Exception: logger.exception("[WATCHDOG] Stop : exception inattendue") self._task = None logger.info("[WATCHDOG] Arrêté") # ------------------------------------------------------------------------ # Boucle principale # ------------------------------------------------------------------------ async def _run(self) -> None: try: while not self._stopped.is_set(): # asyncio.wait avec timeout = sleep interruptible par stop() try: await asyncio.wait_for( self._stopped.wait(), timeout=WATCHDOG_SCAN_INTERVAL_S, ) # Si on sort sans TimeoutError, c'est que stop() a été demandé break except asyncio.TimeoutError: pass # tick normal try: await self._scan_once() except Exception: await _bump("scans_failed_total") logger.exception("[WATCHDOG] Scan a levé — continue") except asyncio.CancelledError: logger.info("[WATCHDOG] Boucle annulée (cancel)") raise finally: logger.info("[WATCHDOG] Boucle terminée proprement") # ------------------------------------------------------------------------ # Scan unique (exposé pour tests) # ------------------------------------------------------------------------ async def _scan_once(self) -> Dict[str, int]: """Un tour de scan. Retourne un dict de compteurs pour les tests.""" t0 = time.time() await _bump("scans_total") resent: int = 0 gaveup: int = 0 skipped: int = 0 in_flight: int = 0 orphans: int = 0 # Snapshot sous lock pour éviter mutation concurrente avec # report_action_result (qui pop) et get_next_action (qui add) orphan_targets: List[Tuple[str, Dict[str, Any]]] = [] async with self._async_lock(): for aid, info in list(self._retry_pending.items()): dispatched_at = info.get("dispatched_at", 0) if dispatched_at == 0: # Action repushed mais pas encore re-dispatchée par # get_next_action → on attend le prochain DISPATCH qui # remettra dispatched_at = time.time() skipped += 1 continue age = t0 - dispatched_at in_flight += 1 if age < WATCHDOG_ORPHAN_TIMEOUT_S: continue orphans += 1 orphan_targets.append((aid, info)) # Traitement hors lock (les operations atomiques sous lock sont # faites individuellement plus bas pour minimiser le temps de tenue) for aid, info in orphan_targets: await _bump("orphans_detected_total") current_resent = info.get("resent_count", 0) if current_resent >= WATCHDOG_MAX_RESENDS: # Plafond atteint — on abandonne et on émet [BUS] async with self._async_lock(): self._retry_pending.pop(aid, None) age = t0 - info.get("first_dispatched_at", t0) logger.error( "[BUS] lea:dispatch_orphan_giveup action_id=%s " "resent=%d age_total=%.1fs session=%s machine=%s replay=%s", aid, current_resent, age, info.get("session_id", "?"), info.get("machine_id", "?"), info.get("replay_id", "?"), ) gaveup += 1 await _bump("orphans_giveup_total") continue # Re-pousser dans la queue de session session_id = info.get("session_id") action = info.get("action") if not session_id or not action: # Schéma invalide → drop sans bruit (log warning seulement) logger.warning( "[WATCHDOG] _retry_pending[%s] schéma invalide " "(session_id=%r action=%r) — drop", aid, session_id, type(action).__name__, ) async with self._async_lock(): self._retry_pending.pop(aid, None) continue async with self._async_lock(): # Re-check sous lock : entre temps un report peut être arrivé if aid not in self._retry_pending: logger.debug( "[WATCHDOG] %s déjà acquitté entre snapshot et resend — skip", aid, ) continue q = self._replay_queues.setdefault(session_id, []) if WATCHDOG_REPUSH_POSITION == "tail": q.append(action) else: q.insert(0, action) self._retry_pending[aid]["resent_count"] = current_resent + 1 self._retry_pending[aid]["last_resent_at"] = time.time() # Reset dispatched_at : sera réécrit au prochain DISPATCH par # get_next_action (api_stream.py:3354). Si la queue n'est pas # purgée d'ici le prochain scan, on saura que personne ne consomme. self._retry_pending[aid]["dispatched_at"] = 0 age = t0 - info.get("first_dispatched_at", t0) logger.warning( "[BUS] lea:dispatch_orphan_resent action_id=%s " "resent=%d/%d age=%.1fs session=%s machine=%s replay=%s", aid, current_resent + 1, WATCHDOG_MAX_RESENDS, age, session_id, info.get("machine_id", "?"), info.get("replay_id", "?"), ) resent += 1 await _bump("orphans_resent_total") # Hook SSE futur (no-op si transport pull/poll) — signale que # la queue a un nouvel élément dispo pour la machine cible if self._sse_notifier is not None: try: self._sse_notifier(session_id, info.get("machine_id", "default")) except Exception as e: logger.debug("[WATCHDOG] sse_notifier non bloquant : %s", e) # Snapshot métriques elapsed_ms = (time.time() - t0) * 1000.0 async with _metrics_lock: _metrics["last_scan_ts"] = t0 _metrics["last_scan_duration_ms"] = elapsed_ms _metrics["current_in_flight_count"] = in_flight _metrics["current_orphan_count"] = orphans if orphans or gaveup: logger.info( "[METRIC] watchdog scan=%d orphans=%d resent=%d gaveup=%d " "in_flight=%d skipped=%d elapsed_ms=%.1f", _metrics["scans_total"], orphans, resent, gaveup, in_flight, skipped, elapsed_ms, ) return { "orphans": orphans, "resent": resent, "gaveup": gaveup, "skipped": skipped, "in_flight": in_flight, } # ============================================================================ # Singleton helper (le serveur n'instancie qu'un watchdog par process) # ============================================================================ _singleton: Optional[ReplayWatchdog] = None def get_or_create_watchdog( retry_pending: Dict[str, Dict[str, Any]], replay_queues: Dict[str, List[Dict[str, Any]]], async_lock_factory: Callable[[], Any], sse_notifier: Optional[SseNotifier] = None, ) -> ReplayWatchdog: global _singleton if _singleton is None: _singleton = ReplayWatchdog( retry_pending, replay_queues, async_lock_factory, sse_notifier ) return _singleton @contextlib.asynccontextmanager async def watchdog_lifespan( retry_pending: Dict[str, Dict[str, Any]], replay_queues: Dict[str, List[Dict[str, Any]]], async_lock_factory: Callable[[], Any], sse_notifier: Optional[SseNotifier] = None, ): """Helper pour intégration future via FastAPI(lifespan=...). Pour le câblage immédiat dans api_stream.py qui utilise encore les décorateurs @app.on_event("startup"/"shutdown"), utiliser plutôt get_or_create_watchdog() + start()/stop() manuels (cf. §4). """ wd = get_or_create_watchdog( retry_pending, replay_queues, async_lock_factory, sse_notifier ) await wd.start() try: yield wd finally: await wd.stop() ``` --- ## 4. Patch précis sur `api_stream.py` ### 4.1. Enrichir le schéma `_retry_pending` au moment du DISPATCH **Fichier :** `agent_v0/server_v1/api_stream.py` **Localisation :** ligne 3354-3359 (dans `get_next_action`) ```diff @@ get_next_action (~ligne 3344) @@ # Pre-check OK (ou skip) : retirer l'action de la queue et l'envoyer async with _async_replay_lock(): current_queue = _replay_queues.get(session_id, []) if current_queue and current_queue[0].get("action_id") == action.get("action_id"): current_queue.pop(0) # Else: queue a changé entre temps (race condition bénigne), on envoie quand même # Sauvegarder l'action envoyée pour le retry (si la vérification échoue) # NE PAS écraser si _schedule_retry a déjà mis le bon retry_count action_id_sent = action.get("action_id", "") if action_id_sent and action_id_sent not in _retry_pending: + _now = time.time() _retry_pending[action_id_sent] = { "action": dict(action), "retry_count": 0, - "replay_id": "", + "replay_id": owning_replay.get("replay_id", "") if owning_replay else "", + # === Champs watchdog (AXE_B1_DEEP_WATCHDOG) === + "session_id": session_id, + "machine_id": machine_id, + "dispatched_at": _now, + "first_dispatched_at": _now, + "resent_count": 0, + "last_resent_at": 0.0, } + else: + # Resend / retry métier : update dispatched_at pour que le + # watchdog reparte de zéro sur le timeout d'orphan + existing = _retry_pending.get(action_id_sent) + if existing is not None: + existing["dispatched_at"] = time.time() + # Ne PAS écraser first_dispatched_at (sinon on perd l'âge total) ``` ### 4.2. Lancer le watchdog au startup **Localisation :** dans `@app.on_event("startup")` ligne 791 — ajouter à la fin AVANT le dernier `logger.info` : ```diff @@ startup() (~ligne 850) @@ threading.Thread(target=_preload_easyocr, daemon=True, name="preload_easyocr").start() + # === Replay watchdog d'orphelins (AXE_B1_DEEP_WATCHDOG) === + # Re-dispatche les actions stockées dans _retry_pending qui n'ont pas + # reçu de REPORT depuis WATCHDOG_ORPHAN_TIMEOUT_S secondes. Comble le + # trou identifié dans REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md + # (9 actions perdues en 33s, transport pull/poll fragile). + from .replay_watchdog import get_or_create_watchdog + app.state.replay_watchdog = get_or_create_watchdog( + retry_pending=_retry_pending, + replay_queues=_replay_queues, + async_lock_factory=_async_replay_lock, + sse_notifier=None, # branche-toi ici quand le SSE landera (AXE_B1 §4) + ) + await app.state.replay_watchdog.start() logger.info( "API Streaming démarrée — StreamProcessor, Worker et Cleanup prêts. " "VLM Worker dans un process séparé (run_worker.py)." ) ``` ### 4.3. Arrêter proprement au shutdown **Localisation :** dans `@app.on_event("shutdown")` ligne 900 : ```diff @@ shutdown() (~ligne 900) @@ @app.on_event("shutdown") async def shutdown(): global _cleanup_running _cleanup_running = False + wd = getattr(app.state, "replay_watchdog", None) + if wd is not None: + await wd.stop(timeout_s=3.0) worker.stop() _clear_replay_lock() processor.session_manager.flush() logger.info("API Streaming arrêtée.") ``` ### 4.4. `report_action_result` — aucune modif nécessaire Ligne 3491 : `retry_info = _retry_pending.pop(action_id, None)` est déjà idempotent. Si le watchdog republie pendant un report en vol, soit le pop arrive premier (watchdog skipe au re-check sous lock, §3 `_scan_once`), soit le repush arrive premier (mais l'action a été marquée resent_count+1 et le report finit par arriver — le pop sortira `None` et `report_action_result` répondra `no_active_replay` ou ignorera, comportement déjà géré). **Pas de modif** ici. Le code existant tient. ### 4.5. Endpoint healthz (optionnel mais recommandé) ```python # À ajouter dans api_stream.py près des autres endpoints debug @app.get("/api/v1/traces/stream/replay/watchdog/metrics") async def watchdog_metrics(): """Snapshot non bloquant des compteurs du watchdog.""" from .replay_watchdog import get_metrics_snapshot return {"watchdog": get_metrics_snapshot()} ``` --- ## 5. Politique de retry — arbre de décision ``` ┌─ DISPATCH action_id=X t=t0 ─┐ │ _retry_pending[X] = {dispatched_at: t0, resent_count: 0, ...} │ Action streamée au client └──────────┬──────────────────┘ │ ┌───────┴───────┐ │ Cas A : client │ → POST /replay/result │ a bien reçu │ report_action_result pop(X) ✅ idempotent │ et exécuté │ Watchdog ne voit jamais X (pop avant timeout) └─────────────────┘ ┌───────┴───────┐ │ Cas B : client │ → Pas de REPORT │ jamais reçu │ À t0+30s, watchdog détecte orphan │ (timeout, NoMachine freeze, crash silencieux) └───────┬─────────┘ │ ┌───┴───────────────────────┐ │ resent_count < MAX_RESENDS │ → repush en tête _replay_queues[session] │ │ resent_count++ | dispatched_at=0 │ │ [BUS] lea:dispatch_orphan_resent └────────────┬──────────────┘ │ ┌───────┴──────────┐ │ Re-DISPATCH par │ → boucle Cas A ou Cas B selon │ get_next_action │ nouvelle réception client │ dispatched_at=t1 │ └───────────────────┘ ┌───┴───────────────────────┐ │ resent_count ≥ MAX_RESENDS │ → drop _retry_pending[X] │ (action toxique : Léa morte│ [BUS] lea:dispatch_orphan_giveup │ ou bug systématique) │ │ │ ESCALATION recommandée : │ │ → marquer replay_state status=paused_need_help │ │ (pas implémenté dans v1 du watchdog — │ │ à ajouter en hook SI 2 give-ups dans la │ │ même session sous 60s = signal client mort) └────────────────────────────┘ ┌───────┴────────────────────┐ │ Cas C : report tardif RACE │ → REPORT arrive après que watchdog ait │ (client envoie report 60s │ re-pushé. resent_count = 1. │ après l'action perdue, mais │ L'action en queue peut OU PAS être déjà │ entre-temps watchdog a │ ré-exécutée par Léa. │ repush) │ └───────┬────────────────────┘ │ ┌───┴────────────────────────────┐ │ Si pop(X) trouve l'entrée : │ → comportement normal (resent_count │ le report tardif est traité │ reste informatif pour debug). │ comme un report classique │ Risque double-exécution côté Léa │ │ (cf. §6 idempotence côté action). └─────────────────────────────────┘ ┌───┴────────────────────────────┐ │ Si pop(X) retourne None : │ → already acked OR already given-up │ "status: no_active_replay" │ Logger info, pas d'action serveur. │ ou ignore selon état replay │ └─────────────────────────────────┘ ``` **Pourquoi MAX_RESENDS=2 et pas 3 ?** - 1er resend : couvre 95% des cas (NoMachine glitch, micro-coupure réseau). - 2e resend : couvre les coupures plus longues. - Au-delà : si 3× 30s = 90s sans report, c'est un problème structurel (client mort, action toxique qui crash Léa en boucle). Mieux vaut escalader. **Backoff exponentiel ?** **Non recommandé pour v1.** Le replay médical exige réactivité (Pauline regarde l'écran, attend que ça avance). Un backoff à 30→60→120s allongerait la latence perçue. Si on observe vraiment des storms (improbable en mono-client Léa), on l'ajoutera plus tard. **Circuit breaker par session ?** Pas implémenté en v1. Le `_MAX_RESENDS` par action suffit. Un breaker par session deviendra pertinent quand on aura N machines Léa en parallèle (multi-tenant Anouste/GHT/etc.). --- ## 6. Concurrence — analyse exhaustive des races ### R1 — Watchdog repush vs polling client **Scénario :** watchdog libère le lock après avoir inséré l'action en tête de queue ; juste après, un poll client `GET /replay/next` acquiert le lock et pop l'action. **Résultat :** comportement normal — c'est exactement ce qu'on veut. L'action re-dispatchée est consommée par le client. `dispatched_at` est réécrit par `get_next_action` (patch §4.1 branche `else`). **Protection :** `_async_replay_lock` actuel (4.5s timeout). Le watchdog acquiert le lock pour ≤ quelques ms par orphan (juste pour `setdefault + insert + update info`). Pas de famine. ### R2 — Report tardif arrive pendant un resend en cours **Scénario :** 1. t0=10:00:00 — DISPATCH X 2. t0+30s — watchdog snapshot voit X orphan 3. t0+30.1s — REPORT X arrive, pop(X) ✅ 4. t0+30.2s — watchdog tente repush, mais re-check `if aid not in _retry_pending` (cf. `_scan_once` §3) → skip **Résultat :** aucune double-exécution serveur. Le code v1 du watchdog re-check sous lock — pattern obligatoire. ### R3 — Action toxique re-exécutée 2 fois côté Léa (idempotence ACTION) **Scénario :** click sur "Imagerie" perdu, repush, Léa reçoit et exécute. Pendant ce temps le 1er click était passé (latence réseau coupée mid-stream après l'effet pixel) et Léa avait cliqué une fois. Total = 2 clics sur "Imagerie". **Mitigation côté serveur :** impossible à garantir 100% (réseau non transactionnel). **Mitigation côté action :** dépend du type : - `click` : un double-clic accidentel sur un tab Easily = inoffensif (le tab reste actif). - `type` : risque de double saisie. **Mitigation : préfixer la saisie d'un `Ctrl+A` ou clear field** dans la définition VWB pour les champs sensibles (cf. `feedback_verifier_avant_apres_clic`). Pas une responsabilité du watchdog. - `keyboard_shortcut` (Ctrl+S, Ctrl+V…) : potentiellement destructif si répété. **Mitigation : skip watchdog pour les types listés dans `_WATCHDOG_SKIP_TYPES`** — non implémenté en v1 par parti pris (zero perte > zero double), à ajouter si terrain le demande. **Mitigation côté client Léa** (suggérée hors-périmètre watchdog) : maintenir un `LRU set` des derniers 256 `action_id` exécutés. Si re-réception du même `action_id`, log + skip + retourne ack. Cf. `feedback_lea_reflexes_catalog`. ### R4 — Détection client mort (Léa crash silencieux) **Scénario :** Léa freeze, ne fait rien, ne timeout pas non plus (socket TCP en attente). **Côté pull/poll actuel :** `request.is_disconnected()` n'est PAS exploitable sur un GET court (la connexion se ferme après chaque réponse — il n'y a pas de stream sur lequel détecter). Le seul signal serveur = absence de poll suivant. Pas de mécanisme actuel. **Décision v1 :** le watchdog ne détecte PAS un client mort. Il détecte uniquement des actions orphan. Si Léa est vraiment morte : - `resent_count` arrivera à MAX en 60-90s (cas B → cas give-up) - Émission `[BUS] lea:dispatch_orphan_giveup` - **Hook à brancher** (post-v1, ~30 lignes) : dans le watchdog, agrégat `dead_client_signal[session_id]` : si 2+ give-ups en 60s sur la même session, basculer `replay_state[…]["status"] = "paused_need_help"` avec message "Léa ne répond plus depuis 90s — vérifie l'agent Windows". **Côté SSE futur (cf. AXE_B1 parent §4) :** `await request.is_disconnected()` côté `event_generator` détecte immédiatement → on a un signal direct. **Le watchdog devient un filet secondaire** (utile pour le cas où la déconnexion arrive EXACTEMENT entre le `yield` et le `POST /result` client). ### R5 — Race start/stop du watchdog (multi-restart `systemctl`) **Scénario :** SIGTERM uvicorn → shutdown event → `await wd.stop(timeout=3s)` → la coroutine reçoit cancel, except CancelledError, return propre. Pas de leak. **Protection :** singleton + `_task.done()` check dans `start()`. --- ## 7. Détection précoce de désync client — `request.is_disconnected()` **Applicable au GET long-poll ? Réponse : Non en l'état.** Le `GET /replay/next` actuel retourne une réponse JSON unique (pas un stream). Une fois la réponse écrite, la connexion est fermée. Pas de phase "j'attends que le client confirme la réception". Le `request.is_disconnected()` ne renvoie `True` que pendant le traitement de la requête côté serveur, pas après. **Si on voulait l'exploiter en pull/poll :** il faudrait que `get_next_action` boucle `while not action and not await request.is_disconnected(): await asyncio.sleep(0.1)` avec un timeout serveur (le rendant ainsi un long-poll au sens strict, pas un poll itéré). Refactor non négligeable, **gain inférieur à la migration SSE complète**. **Recommandation :** ne pas refactorer le pull/poll. Le watchdog couvre le besoin. La migration SSE (AXE_B1 parent §4) tranchera le sujet pour de bon. --- ## 8. Patterns externes 2026 — comparaison | Système | Pattern transport | Détection orphan | Resend | Source code | |---|---|---|---|---| | **AWS SQS** | poll long (Receive Message Wait Time 0-20s) | **Visibility Timeout** : message redevient visible après timeout si pas `DeleteMessage` | Backoff exp recommandé côté consumer | Géré côté broker AWS | | **Dramatiq** | broker (Redis/RMQ) | Ack après complétion (`acks_late` par défaut) — équivalent natif visibility timeout | Default 3 retries, configurable | [dramatiq.io/motivation.html](https://dramatiq.io/motivation.html) | | **Celery** | broker | `task_acks_late=True` (pas default) + `worker_prefetch_multiplier=1` requis | Configurable, `autoretry_for` | [celery configurations](https://airflow.apache.org/docs/apache-airflow-providers-celery/stable/configurations-ref.html) | | **ARQ** | Redis | Equivalent visibility via lock TTL | `max_tries` param | maintenance-only depuis fév 2025 | | **Skyvern (TaskV2)** | DB-backed state machine (`TaskV2Status.queued/running`) | Pas de queue distribuée, monolithe local. Recovery via `validate_task_execution()` à chaque iter + `OperationalError → mark_task_v2_as_failed` | `for i in range(DEFAULT_MAX_ITERATIONS=50)` | [skyvern/services/task_v2_service.py](https://github.com/Skyvern-AI/skyvern/blob/main/skyvern/services/task_v2_service.py) | | **browser-use** | CDP WebSocket direct (intra-machine) | Connexion synchrone — pas applicable | Loop detector (boucle d'actions) | [browser-use/browser-use](https://github.com/browser-use/browser-use) | | **Playwright MCP** | stdio (local) ou HTTP/SSE (remote) | Pas de retry de bas niveau côté serveur — délégué au client MCP | n/a (one-shot tool calls) | [microsoft/playwright-mcp](https://github.com/microsoft/playwright-mcp) | | **Anthropic Computer Use SDK** | In-process loop | Pas applicable (pas de transport) | Retry au niveau LLM (re-emit tool_use si erreur) | [claude-quickstarts/computer-use-demo/loop.py](https://github.com/anthropics/claude-quickstarts/blob/main/computer-use-demo/computer_use_demo/loop.py) | **Synthèse :** **personne ne fait exactement ce qu'on a besoin** (RPA visuel distant Linux↔Windows avec actions imprévisibles en latence). Le pattern le plus proche conceptuellement = **SQS visibility timeout** (action invisible pour autres workers tant que pas `DeleteMessage`, redevient visible après timeout). Notre `_retry_pending` + watchdog = **mini-visibility-timeout in-memory**. C'est exactement le bon niveau de complexité pour un service mono-process FastAPI mono-worker. **Ce qu'on n'adopte pas :** - Broker externe (Redis/RMQ) — overkill, nouveau point de défaillance, déploiement on-premise Anouste compliqué. - Persistance DB des actions in-flight — `_retry_pending` reste in-memory. Si serveur crash, on perd l'inflight, mais un crash serveur force déjà un restart manuel et le VWB peut relancer le replay (les `replay_states` complets sont persistés dans DB SQLite par ailleurs). --- ## 9. Tests d'intégration — snippet pytest À placer : `/home/dom/ai/rpa_vision_v3/tests/integration/test_replay_watchdog.py` ```python """Tests d'intégration du watchdog d'orphelins. Validation sans démarrer un client Windows : on injecte directement _retry_pending + _replay_queues + un mock de lock async, et on vérifie le comportement de _scan_once dans tous les chemins de l'arbre §5. Run : cd ~/ai/rpa_vision_v3 && source .venv/bin/activate pytest tests/integration/test_replay_watchdog.py -v """ import asyncio import contextlib import time from typing import Any, Dict, List import pytest # Garantir que la racine du projet est dans sys.path (cf. conftest existant) @contextlib.asynccontextmanager async def fake_lock(): yield @pytest.fixture(autouse=True) def reset_watchdog_singleton(monkeypatch): """Empêche la pollution entre tests via le singleton du module.""" import agent_v0.server_v1.replay_watchdog as wd_mod wd_mod._singleton = None # Reset compteurs métriques for k in list(wd_mod._metrics.keys()): if isinstance(wd_mod._metrics[k], (int, float)): wd_mod._metrics[k] = 0 yield @pytest.fixture def env_short_timeout(monkeypatch): """Override env pour tests rapides : scan 0.1s, orphan 0.2s, max 2.""" monkeypatch.setenv("RPA_WATCHDOG_ENABLED", "1") monkeypatch.setenv("RPA_WATCHDOG_SCAN_INTERVAL_S", "0.1") monkeypatch.setenv("RPA_WATCHDOG_ORPHAN_TIMEOUT_S", "0.2") monkeypatch.setenv("RPA_WATCHDOG_MAX_RESENDS", "2") # Force re-import pour relire les constantes import importlib import agent_v0.server_v1.replay_watchdog as wd_mod importlib.reload(wd_mod) yield @pytest.mark.asyncio async def test_no_orphan_below_timeout(env_short_timeout): """Pas de resend tant que dispatched_at < orphan_timeout.""" from agent_v0.server_v1.replay_watchdog import ReplayWatchdog retry_pending: Dict[str, Dict[str, Any]] = { "act1": { "action": {"action_id": "act1", "type": "click"}, "session_id": "sess1", "machine_id": "m1", "dispatched_at": time.time(), # frais "first_dispatched_at": time.time(), "resent_count": 0, } } replay_queues: Dict[str, List] = {"sess1": []} wd = ReplayWatchdog(retry_pending, replay_queues, fake_lock) result = await wd._scan_once() assert result == {"orphans": 0, "resent": 0, "gaveup": 0, "skipped": 0, "in_flight": 1} assert replay_queues["sess1"] == [] assert retry_pending["act1"]["resent_count"] == 0 @pytest.mark.asyncio async def test_orphan_above_timeout_resent_in_head(env_short_timeout): """Action orpheline → repushed en tête + resent_count incrémenté.""" from agent_v0.server_v1.replay_watchdog import ReplayWatchdog action_act1 = {"action_id": "act1", "type": "click"} other = {"action_id": "act_next", "type": "click"} retry_pending = { "act1": { "action": action_act1, "session_id": "sess1", "machine_id": "m1", "dispatched_at": time.time() - 5.0, # vieux de 5s, > 0.2s "first_dispatched_at": time.time() - 5.0, "resent_count": 0, } } replay_queues = {"sess1": [other]} wd = ReplayWatchdog(retry_pending, replay_queues, fake_lock) result = await wd._scan_once() assert result["resent"] == 1 assert replay_queues["sess1"] == [action_act1, other] # head assert retry_pending["act1"]["resent_count"] == 1 assert retry_pending["act1"]["dispatched_at"] == 0 @pytest.mark.asyncio async def test_giveup_after_max_resends(env_short_timeout): """resent_count >= MAX_RESENDS → drop _retry_pending + bus giveup.""" from agent_v0.server_v1.replay_watchdog import ReplayWatchdog retry_pending = { "act1": { "action": {"action_id": "act1", "type": "click"}, "session_id": "sess1", "machine_id": "m1", "dispatched_at": time.time() - 5.0, "first_dispatched_at": time.time() - 90.0, "resent_count": 2, # MAX_RESENDS } } replay_queues = {"sess1": []} wd = ReplayWatchdog(retry_pending, replay_queues, fake_lock) result = await wd._scan_once() assert result["gaveup"] == 1 assert result["resent"] == 0 assert "act1" not in retry_pending assert replay_queues["sess1"] == [] @pytest.mark.asyncio async def test_race_report_arrives_during_scan(env_short_timeout): """Re-check sous lock : si pop entre snapshot et resend, skip propre.""" from agent_v0.server_v1.replay_watchdog import ReplayWatchdog retry_pending = { "act1": { "action": {"action_id": "act1", "type": "click"}, "session_id": "sess1", "machine_id": "m1", "dispatched_at": time.time() - 5.0, "first_dispatched_at": time.time() - 5.0, "resent_count": 0, } } replay_queues = {"sess1": []} @contextlib.asynccontextmanager async def lock_that_drops_act1_first_acquire(): # Au premier appel, on simule un REPORT en vol qui pop act1. # _scan_once acquiert deux fois : 1) snapshot, 2) repush. # On veut que entre les deux, act1 ait disparu. if not hasattr(lock_that_drops_act1_first_acquire, "_count"): lock_that_drops_act1_first_acquire._count = 0 lock_that_drops_act1_first_acquire._count += 1 if lock_that_drops_act1_first_acquire._count == 2: retry_pending.pop("act1", None) yield wd = ReplayWatchdog(retry_pending, replay_queues, lock_that_drops_act1_first_acquire) result = await wd._scan_once() # Détecté mais pas resent : protection re-check sous lock assert result["orphans"] == 1 assert result["resent"] == 0 assert replay_queues["sess1"] == [] @pytest.mark.asyncio async def test_disabled_via_env(monkeypatch): """RPA_WATCHDOG_ENABLED=0 → start() est no-op.""" monkeypatch.setenv("RPA_WATCHDOG_ENABLED", "0") import importlib import agent_v0.server_v1.replay_watchdog as wd_mod importlib.reload(wd_mod) wd = wd_mod.ReplayWatchdog({}, {}, fake_lock) await wd.start() assert wd._task is None # pas démarré await wd.stop() # no-op safe @pytest.mark.asyncio async def test_lifecycle_start_stop_clean(env_short_timeout): """start/stop ne leak pas de task.""" from agent_v0.server_v1.replay_watchdog import ReplayWatchdog wd = ReplayWatchdog({}, {}, fake_lock) await wd.start() assert wd._task is not None and not wd._task.done() await asyncio.sleep(0.25) # laisse 2-3 ticks passer await wd.stop(timeout_s=2.0) assert wd._task is None @pytest.mark.asyncio async def test_orphan_with_repush_tail(monkeypatch, env_short_timeout): """RPA_WATCHDOG_REPUSH_POSITION=tail → action en queue, pas tête.""" monkeypatch.setenv("RPA_WATCHDOG_REPUSH_POSITION", "tail") import importlib import agent_v0.server_v1.replay_watchdog as wd_mod importlib.reload(wd_mod) from agent_v0.server_v1.replay_watchdog import ReplayWatchdog act = {"action_id": "act1", "type": "click"} other = {"action_id": "act_next", "type": "click"} retry_pending = { "act1": { "action": act, "session_id": "sess1", "machine_id": "m1", "dispatched_at": time.time() - 5.0, "first_dispatched_at": time.time() - 5.0, "resent_count": 0, } } replay_queues = {"sess1": [other]} wd = ReplayWatchdog(retry_pending, replay_queues, fake_lock) await wd._scan_once() assert replay_queues["sess1"] == [other, act] # tail @pytest.mark.asyncio async def test_metrics_snapshot(env_short_timeout): from agent_v0.server_v1.replay_watchdog import ( ReplayWatchdog, get_metrics_snapshot, ) retry_pending = { "act1": { "action": {"action_id": "act1", "type": "click"}, "session_id": "sess1", "machine_id": "m1", "dispatched_at": time.time() - 5.0, "first_dispatched_at": time.time() - 5.0, "resent_count": 0, } } wd = ReplayWatchdog(retry_pending, {"sess1": []}, fake_lock) await wd._scan_once() snap = get_metrics_snapshot() assert snap["scans_total"] >= 1 assert snap["orphans_detected_total"] >= 1 assert snap["orphans_resent_total"] >= 1 ``` **Couverture :** 8 tests, ~120 lignes. Couvre les 5 branches de l'arbre §5 + lifecycle + env + métriques. **Aucune dépendance** sur Windows/Léa/HTTP — tout est en mémoire. **Exécution attendue :** ```bash pytest tests/integration/test_replay_watchdog.py -v # 8 passed in ~3.5s ``` --- ## 10. Observabilité ### Logs structurés (grep-friendly) ``` [BUS] lea:dispatch_orphan_resent action_id= resent=N/MAX age=Ts session= machine= replay= [BUS] lea:dispatch_orphan_giveup action_id= resent=N age_total=Ts session= machine= replay= [METRIC] watchdog scan=N orphans=N resent=N gaveup=N in_flight=N skipped=N elapsed_ms=F ``` Recherche typique en démo : ```bash journalctl --user -u rpa-streaming -f | grep -E "lea:dispatch_orphan|METRIC] watchdog" ``` ### Endpoint metrics ```bash curl -H "Authorization: Bearer $RPA_API_TOKEN" \ http://localhost:5005/api/v1/traces/stream/replay/watchdog/metrics ``` Réponse JSON pour dashboard temps réel ou pour le widget VWB : ```json {"watchdog": { "orphans_detected_total": 12, "orphans_resent_total": 10, "orphans_giveup_total": 2, "scans_total": 540, "scans_failed_total": 0, "last_scan_ts": 1779000000.0, "last_scan_duration_ms": 1.4, "current_in_flight_count": 1, "current_orphan_count": 0 }} ``` ### Métriques Prometheus (post-v1 — facultatif) Si on adopte `prometheus_client` un jour : - `rpa_replay_orphans_resent_total{session, machine, replay}` (Counter) - `rpa_replay_orphans_giveup_total{session, machine, replay}` (Counter) - `rpa_replay_in_flight_actions` (Gauge) - `rpa_replay_orphan_age_seconds` (Histogram, buckets [10, 30, 60, 120, 300]) Câblage non détaillé ici — l'absence de `prometheus_client` actuellement dans le projet (vérifié `requirements.txt` non grepé mais aucun import) suggère de différer. Cf. [prometheus-fastapi-instrumentator](https://github.com/trallnag/prometheus-fastapi-instrumentator) quand le besoin émergera. --- ## 11. Configuration & déploiement | Variable | Default | Effet | |---|---|---| | `RPA_WATCHDOG_ENABLED` | `1` | Kill-switch global. `0` = pas de scan, retour au comportement legacy (perte silencieuse possible). | | `RPA_WATCHDOG_SCAN_INTERVAL_S` | `10` | Période entre scans. Plus bas = plus réactif mais plus de réveils event-loop. 10s est un bon compromis pour transport pull/poll (poll client ≈ 1s). | | `RPA_WATCHDOG_ORPHAN_TIMEOUT_S` | `30` | Délai sans REPORT avant déclaration orpheline. 30s couvre : `extract_text` 7s + `t2a_decision` 10s + marge réseau 13s. Augmenter à 45s si T2A cloud > 15s observé. | | `RPA_WATCHDOG_MAX_RESENDS` | `2` | Nombre de re-dispatch avant give-up. À 2, on couvre 99% des coupures temporaires. | | `RPA_WATCHDOG_REPUSH_POSITION` | `head` | `head` = action repassée en premier (DÉFAUT, préserve l'ordre des steps suivants). `tail` = en dernier (autorise les actions arrivées depuis à passer devant). Cas d'usage `tail` : si le step orphan est non-critique et bloque inutilement. | **Hot reload :** **NON, restart requis** (`systemctl --user restart rpa-streaming`). Les constantes sont lues au moment de l'import du module. Pour rendre hot-reloadable, il faudrait lire les env vars à chaque scan — surcoût négligeable mais complexifie. Différé. **Compatibilité avec le watchdog existant `_cleanup_loop`** (api_stream.py:687) : pas de conflit, ils touchent des structures différentes (`_replay_states` vs `_retry_pending`). --- ## 12. Migration future SSE Quand SSE landera (AXE_B1 parent §4) : 1. **Le watchdog reste actif** — ceinture+bretelles. Même avec SSE + ack, le crash entre `yield` serveur et `POST /result` client laisse une action en `_retry_pending`. Le watchdog la rattrape. 2. **Branchement `sse_notifier`** dans `get_or_create_watchdog` : ```python from .api_stream import sse_notify_new_action # ou l'équivalent qui sera créé app.state.replay_watchdog = get_or_create_watchdog( retry_pending=_retry_pending, replay_queues=_replay_queues, async_lock_factory=_async_replay_lock, sse_notifier=sse_notify_new_action, # ← branche ici ) ``` Le watchdog appellera `sse_notifier(session_id, machine_id)` après chaque repush → le `event_generator` SSE détecte un nouvel élément dispo et le pousse au client. 3. **`WATCHDOG_ORPHAN_TIMEOUT_S` peut descendre à 15s** avec SSE : la connexion persistante détecte plus vite les déconnexions (`request.is_disconnected()` ≤ 1s). On garde 30s en transition pull/poll pour ne pas générer de faux orphans. 4. **`RPA_REPLAY_TRANSPORT=poll|sse`** côté serveur n'affecte pas le watchdog — il opère sur les structures partagées en mémoire (`_retry_pending`, `_replay_queues`), indépendamment du canal de dispatch. Cohabitation native. --- ## 13. Liens avec autres axes - **AXE B1 parent (transport)** : ce doc est l'approfondissement du §6. À lire en complément. - **AXE B2 (Validator)** : un Validator strict suppose des actions correctement délivrées. Le watchdog garantit la délivrance. **B1-watchdog est prérequis de B2.** - **AXE D2 (Dialog/Popup)** : `system_dialog` handling pause le replay sans toucher `_retry_pending` (cf. api_stream.py:3785-3870). Le watchdog ne crée pas d'interférence. Quand `replay_state["status"]="paused_need_help"`, `get_next_action` retourne `{action: None, replay_paused: True}` mais `_retry_pending` peut encore contenir l'action ayant déclenché la pause (avec `dispatched_at` ancien). **À ajouter en v1.1 :** purger `_retry_pending` des entrées dont le `replay_id` est en `paused_need_help`/`failed`/`cancelled` (le `cancel_replay` ligne 4489 le fait déjà partiellement pour cancel). --- ## 14. Sources ### Patterns watchdog / lifespan FastAPI - [FastAPI Lifespan Events](https://fastapi.tiangolo.com/advanced/events/) (doc officielle) - [How to Build Background Task Processing in FastAPI (oneuptime, 2026-01-25)](https://oneuptime.com/blog/post/2026-01-25-background-task-processing-fastapi/view) - [Python Background Tasks — Asyncio Traps, FastAPI & Celery (2026)](https://dev.to/kaushikcoderpy/python-background-tasks-asyncio-traps-fastapi-celery-2026-381i) - [Stop Burning CPU on Dead FastAPI Streams — Jason Cameron](https://jasoncameron.dev/posts/fastapi-cancel-on-disconnect) - [Understanding Pitfalls of Async Task Management in FastAPI Requests — Leapcell](https://leapcell.io/blog/understanding-pitfalls-of-async-task-management-in-fastapi-requests) - [FastAPI Lifespan Explained — AlgoMart Medium](https://medium.com/algomart/fastapi-lifespan-explained-the-right-way-to-handle-startup-and-shutdown-logic-f825f38dd304) ### Visibility timeout / ack-based delivery - [Amazon SQS visibility timeout (doc AWS)](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html) - [How to Handle SQS Message Visibility Timeout (oneuptime, 2026-01-27)](https://oneuptime.com/blog/post/2026-01-27-sqs-message-visibility-timeout/view) - [How to Implement Retry Logic with SQS (oneuptime, 2026-02-02)](https://oneuptime.com/blog/post/2026-02-02-sqs-retry-logic/view) - [Dramatiq motivation — acks_late by default](https://dramatiq.io/motivation.html) - [Celery vs RQ vs Dramatiq: Which Task Queue to Use 2026](https://djangoproject.in/blog/celery-vs-rq/) - [Reliable Python Queues: 7 Celery/Dramatiq/RQ Choices (Medium)](https://medium.com/@Nexumo_/reliable-python-queues-7-celery-dramatiq-rq-choices-266ac544a4a5) ### Disconnect detection / SSE - [sse-starlette Client Disconnection Detection](https://deepwiki.com/sysid/sse-starlette/3.5-client-disconnection-detection) - [FastAPI Discussion #8805 — cancel handler on client disconnect](https://github.com/fastapi/fastapi/discussions/8805) - [Stop streaming response when client disconnects (FastAPI #7572)](https://github.com/fastapi/fastapi/discussions/7572) ### Patterns externes frameworks RPA - [Skyvern task_v2_service.py (services/)](https://github.com/Skyvern-AI/skyvern/blob/main/skyvern/services/task_v2_service.py) - [Skyvern forge/ — agent execution loop](https://github.com/Skyvern-AI/skyvern/tree/main/skyvern/forge) - [Skyvern Queue system for tasks #488](https://github.com/Skyvern-AI/skyvern/issues/488) - [browser-use](https://github.com/browser-use/browser-use) - [Microsoft playwright-mcp](https://github.com/microsoft/playwright-mcp) - [Anthropic computer-use-demo loop.py](https://github.com/anthropics/claude-quickstarts/blob/main/computer-use-demo/computer_use_demo/loop.py) ### Observabilité - [prometheus-fastapi-instrumentator](https://github.com/trallnag/prometheus-fastapi-instrumentator) (pour mise en place future) - [How to Add Custom Metrics to Python Applications with Prometheus (oneuptime)](https://oneuptime.com/blog/post/2025-01-06-python-custom-metrics-prometheus/view) ### Internes projet (lecture seule) - `agent_v0/server_v1/api_stream.py` lignes 526-551 (`_async_replay_lock`), 2906-3444 (`get_next_action`), 3354-3359 (création `_retry_pending`), 3491 (pop dans `report_action_result`), 4489-4491 (cleanup par `cancel_replay`) - `agent_v0/server_v1/replay_engine.py` lignes 2583-2641 (`_schedule_retry` métier), 2500-2580 (`_create_replay_state`) - Commit `864530c85` : `fix(stream): _async_replay_lock helper + 17 endpoints async non-bloquants` — base sur laquelle le watchdog s'appuie --- *Document de spécification d'implémentation, prêt à coder. Lecture seule sur le code existant. À valider par Dom avant câblage en production.*