54 KiB
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
- Créer
agent_v0/server_v1/replay_watchdog.py(§3 ci-dessous, ~270 lignes copy-paste-ready). - Patcher 4 emplacements dans
api_stream.py: enrichissement schéma_retry_pending(ligne 3354), boot/teardown du watchdog (ligne 791/900), idempotence dansreport_action_result(ligne 3491 — déjà OK, juste pop additionnel des nouveaux champs). - 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. - 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). - 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)
_retry_pending[action_id_sent] = {
"action": dict(action),
"retry_count": 0,
"replay_id": "",
}
Après
_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
"""
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)
@@ 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 :
@@ 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 :
@@ 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é)
# À 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 :
- t0=10:00:00 — DISPATCH X
- t0+30s — watchdog snapshot voit X orphan
- t0+30.1s — REPORT X arrive, pop(X) ✅
- 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'unCtrl+Aou 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_countarrivera à 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, basculerreplay_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 |
| Celery | broker | task_acks_late=True (pas default) + worker_prefetch_multiplier=1 requis |
Configurable, autoretry_for |
celery configurations |
| 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 |
| browser-use | CDP WebSocket direct (intra-machine) | Connexion synchrone — pas applicable | Loop detector (boucle d'actions) | 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 |
| 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 |
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_pendingreste 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 (lesreplay_statescomplets 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
"""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 :
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=<X> resent=N/MAX age=Ts session=<S> machine=<M> replay=<R>
[BUS] lea:dispatch_orphan_giveup action_id=<X> resent=N age_total=Ts session=<S> machine=<M> replay=<R>
[METRIC] watchdog scan=N orphans=N resent=N gaveup=N in_flight=N skipped=N elapsed_ms=F
Recherche typique en démo :
journalctl --user -u rpa-streaming -f | grep -E "lea:dispatch_orphan|METRIC] watchdog"
Endpoint metrics
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 :
{"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 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) :
-
Le watchdog reste actif — ceinture+bretelles. Même avec SSE + ack, le crash entre
yieldserveur etPOST /resultclient laisse une action en_retry_pending. Le watchdog la rattrape. -
Branchement
sse_notifierdansget_or_create_watchdog: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 → leevent_generatorSSE détecte un nouvel élément dispo et le pousse au client. -
WATCHDOG_ORPHAN_TIMEOUT_Speut 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. -
RPA_REPLAY_TRANSPORT=poll|ssecô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_dialoghandling pause le replay sans toucher_retry_pending(cf. api_stream.py:3785-3870). Le watchdog ne crée pas d'interférence. Quandreplay_state["status"]="paused_need_help",get_next_actionretourne{action: None, replay_paused: True}mais_retry_pendingpeut encore contenir l'action ayant déclenché la pause (avecdispatched_atancien). À ajouter en v1.1 : purger_retry_pendingdes entrées dont lereplay_idest enpaused_need_help/failed/cancelled(lecancel_replayligne 4489 le fait déjà partiellement pour cancel).
14. Sources
Patterns watchdog / lifespan FastAPI
- FastAPI Lifespan Events (doc officielle)
- How to Build Background Task Processing in FastAPI (oneuptime, 2026-01-25)
- Python Background Tasks — Asyncio Traps, FastAPI & Celery (2026)
- Stop Burning CPU on Dead FastAPI Streams — Jason Cameron
- Understanding Pitfalls of Async Task Management in FastAPI Requests — Leapcell
- FastAPI Lifespan Explained — AlgoMart Medium
Visibility timeout / ack-based delivery
- Amazon SQS visibility timeout (doc AWS)
- How to Handle SQS Message Visibility Timeout (oneuptime, 2026-01-27)
- How to Implement Retry Logic with SQS (oneuptime, 2026-02-02)
- Dramatiq motivation — acks_late by default
- Celery vs RQ vs Dramatiq: Which Task Queue to Use 2026
- Reliable Python Queues: 7 Celery/Dramatiq/RQ Choices (Medium)
Disconnect detection / SSE
- sse-starlette Client Disconnection Detection
- FastAPI Discussion #8805 — cancel handler on client disconnect
- Stop streaming response when client disconnects (FastAPI #7572)
Patterns externes frameworks RPA
- Skyvern task_v2_service.py (services/)
- Skyvern forge/ — agent execution loop
- Skyvern Queue system for tasks #488
- browser-use
- Microsoft playwright-mcp
- Anthropic computer-use-demo loop.py
Observabilité
- prometheus-fastapi-instrumentator (pour mise en place future)
- How to Add Custom Metrics to Python Applications with Prometheus (oneuptime)
Internes projet (lecture seule)
agent_v0/server_v1/api_stream.pylignes 526-551 (_async_replay_lock), 2906-3444 (get_next_action), 3354-3359 (création_retry_pending), 3491 (pop dansreport_action_result), 4489-4491 (cleanup parcancel_replay)agent_v0/server_v1/replay_engine.pylignes 2583-2641 (_schedule_retrymé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.