docs: add POC specs, handoffs, and research notes

This commit is contained in:
Dom
2026-06-02 16:28:34 +02:00
parent 18ed6cb751
commit f2e9aac6b7
86 changed files with 27615 additions and 25 deletions

View File

@@ -0,0 +1,507 @@
# AXE B1 — Refonte du transport replay + watchdog d'orphelins
**Date :** 2026-05-23
**Auteur :** Claude (recherche dispatchée, lecture seule sur code)
**Périmètre :** B1 (transport) + B3 (watchdog `_retry_pending`)
**Statut :** Étude — aucun changement de code. Pseudo-code prêt à coller.
> **Lecture pré-requise :** `docs/REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md` (diagnostic 9 actions perdues en 33 s) et `docs/SYNTHESE_TECHNOS_REPLAY_2026-05-23.md` §4.
---
## 1. TL;DR — recommandation
**Migration cible : SSE (`sse-starlette`).** En complément immédiat, **watchdog `_retry_pending` indépendant** activable sous flag, gardé même après bascule SSE (ceinture+bretelles).
WebSocket est techniquement supérieur mais coûte 2× plus cher à mettre en place pour un gain marginal vu notre besoin (push serveur → client, l'upload events/screenshots reste sur les endpoints HTTP POST existants, déjà robustes). **L'asymétrie « push descendant lourd / upload léger »** colle exactement à SSE.
HTTP/2 server push **éliminé** : déprécié au niveau protocole, non supporté par uvicorn, non implémenté côté `requests` Python. Hors course en 2026.
**Effort estimé :**
- Watchdog seul : 2-4 h dev + 1 h test E2E. Déployable indépendamment.
- Endpoint SSE serveur + client + bascule progressive : 2 j dev + 1 j test sur Léa Windows + démo de non-régression.
- Total reco : **3-4 jours** pour aboutir à un transport robuste.
**Risque principal :** NoMachine 9.5.7 sur lien LAN peut intercaler un proxy implicite ou couper l'idle TCP. SSE expose `X-Accel-Buffering: no` et un `ping=15` qui couvrent ce cas — WebSocket exigerait un ping/pong applicatif explicite. Avantage SSE.
---
## 2. Table comparative
| Critère | Pull/Poll actuel | **SSE** | WebSocket | HTTP/2 push |
|---|---|---|---|---|
| **Reconnexion auto** | manuelle | **native (`EventSource`/`sseclient`)** + `Last-Event-ID` | code applicatif | non std |
| **Latence push** | 01 s (polling) + timeout 5 s | **<50 ms** | <50 ms | <50 ms |
| **Trafic** | 1 GET/s minimum, headers à chaque appel | **0 quand idle (juste ping 15 s)** | 0 quand idle (ping app) | minimal |
| **Détection déco client** | indirecte (échec POST report) | **`await request.is_disconnected()` immédiat** | `WebSocketDisconnect` exception | n/a |
| **Détection déco serveur** | timeout client | reconnect auto via EventSource/sseclient | ping/pong manuel | n/a |
| **Complexité serveur** | basse (mais bug doc'd) | **basse** (`EventSourceResponse` + asyncio.Queue) | moyenne (gestion connexions, locks, broadcast) | élevée (hypercorn req, peu testé) |
| **Complexité client Léa** | basse | **basse** (`sseclient-py` + boucle for) | moyenne (`websockets` lib + reconnect manuel) | non supporté `requests` |
| **Compat. proxy / NoMachine / NPM** | OK (HTTP standard) | **OK avec `X-Accel-Buffering: no`** + ping 15 s | OK si proxy autorise Upgrade headers | mauvais |
| **Compat. firewall entreprise** | excellent | **excellent (HTTP)** | bon (Upgrade) mais parfois bloqué | mauvais |
| **Auth Bearer token (existant)** | OK | **OK (header `Authorization`)** | OK (header initial) | OK |
| **Idempotence actions perdues** | non géré → bug 8 mai | gérée via ack POST + watchdog | gérée via ack ws + watchdog | n/a |
| **Ressources Python** | basses (1 req à la fois) | basses (1 connexion persistante par client) | basses (idem) | élevées (h2 stack) |
| **Maturité 2026** | éprouvé mais inadapté | **mature, recommandé par Anthropic/Skyvern docs** | mature | en déclin (deprecated HTTP/3) |
**Verdict :** SSE remporte sur tous les axes pertinents pour notre cas. WebSocket reste optionnel si on a un besoin futur de bidirectionnel synchrone (ex. validation interactive temps réel pendant un step). Pour l'instant : dispatch d'actions = unidirectionnel descendant, parfait pour SSE.
---
## 3. Patterns adoptés par les frameworks de référence (2025-2026)
### 3.1. Anthropic Computer Use SDK
**Architecture observée :** boucle **in-process** (pas de transport réseau entre orchestrateur et exécuteur — c'est le SDK qui exécute localement les `tool_use` retournés par le LLM). Pattern « Gather Context → Take Action → Verify Work → Repeat ». Référence : [`claude-quickstarts/computer-use-demo/computer_use_demo/loop.py`](https://github.com/anthropics/claude-quickstarts/blob/main/computer-use-demo/computer_use_demo/loop.py).
**Notre cas est différent** : agent distant Windows ≠ machine du LLM. Mais la philosophie « boucle d'ack avec screenshot après chaque action » est exactement ce qu'on a déjà côté `report_action_result`. À conserver.
### 3.2. OpenAI Operator / CUA
**Architecture observée :** cloud-based virtual browser ; le modèle retourne un `computer_call` que **le client SDK** exécute dans son environnement puis renvoie le résultat + screenshot via le prochain `messages.create`. Pas de queue côté serveur OpenAI — c'est l'orchestrateur client qui maintient l'état. Source : [Computer use docs](https://developers.openai.com/api/docs/guides/tools-computer-use).
→ Notre architecture serveur autoritaire (queue + replay_states) est légitime ; la « source de vérité » côté OpenAI est portée par le client, chez nous par le serveur. Différence de philosophie mais validée.
### 3.3. Skyvern (Planner-Actor-Validator)
**Architecture observée d'après docs publiques et structure repo** : `/skyvern/{cli, client, core, errors, forge, services, webeye}`. Le `core` orchestre, `webeye` exécute (Playwright). Communication interne via objets Python — **monolithe** côté serveur Skyvern. L'agent Skyvern parle au browser via CDP (websocket), pas une queue HTTP. Source : [github.com/Skyvern-AI/skyvern](https://github.com/Skyvern-AI/skyvern), [Skyvern 2.0 blog](https://www.skyvern.com/blog/skyvern-2-0-state-of-the-art-web-navigation-with-85-8-on-webvoyager-eval/).
**Pas un précédent direct** : Skyvern contrôle Chrome via CDP intra-machine. Notre Léa = poste Windows distant. Mais validation du loop Planner-Actor-Validator (déjà signalée comme convergence dans `INSPIRATION_FRAMEWORKS_2026-05-10.md`).
### 3.4. browser-use
**Architecture observée :** **WebSocket CDP direct** vers le navigateur (event-driven), pas de queue HTTP intermédiaire. Migration explicite de Playwright vers CDP brut pour réduire la latence. Source : [browser-use.com/posts/playwright-to-cdp](https://browser-use.com/posts/playwright-to-cdp), [cdp-use](https://github.com/browser-use/cdp-use).
**Précédent intéressant** : choix WebSocket pour un transport descendant. Mais leur cas est intra-machine (LLM ↔ Chrome local) ; le nôtre est inter-machine Linux ↔ Windows. Notre raison de préférer SSE (asymétrie push/upload) ne s'applique pas chez eux.
### 3.5. Playwright MCP
**Architecture observée :** modèle client/serveur, **transport stdio (local) OU HTTP/SSE (remote)**. Le MCP client (LLM) envoie des tool calls, le MCP server exécute Playwright et renvoie un snapshot de l'accessibility tree. Source : [Playwright MCP 2026 architecture](https://testquality.com/playwright-test-agents-mcp-architecture-2026/), [doc officielle](https://github.com/microsoft/playwright-mcp).
**Précédent direct et fort** : Microsoft a explicitement choisi **SSE pour le remote transport**. C'est le standard MCP. Notre cas (LLM/serveur Linux ↔ exécuteur Léa Windows) est isomorphe. Confirmation que SSE est la bonne route.
### 3.6. Cradle (Microsoft, agent jeu vidéo)
Agent monolithe local, pas de transport distant. Hors périmètre.
### 3.7. Synthèse patterns externes
**Pattern dominant 2025-2026 pour dispatcher des actions à un agent distant** = SSE quand asymétrique, WebSocket quand bidirectionnel. **Playwright MCP** = précédent le plus proche de notre cas → SSE.
---
## 4. Pseudo-code endpoint serveur (FastAPI + sse-starlette)
À placer en complément de `get_next_action` (sans la supprimer pendant la phase 3 de migration — coexistence sous flag). Bibliothèque : `pip install sse-starlette>=2.1`.
```python
# api_stream.py — nouveau bloc, à insérer près de get_next_action
import asyncio
from sse_starlette.sse import EventSourceResponse, ServerSentEvent
# Une file asyncio par (session_id, machine_id) — décorrélée de _replay_queues
# qui reste source de vérité de l'ordre des steps.
_sse_subscribers: dict[tuple[str, str], asyncio.Queue] = {}
_sse_lock = asyncio.Lock()
async def _sse_subscribe(session_id: str, machine_id: str) -> asyncio.Queue:
"""Crée ou récupère la queue de notifications SSE d'un client connecté."""
key = (session_id, machine_id)
async with _sse_lock:
if key not in _sse_subscribers:
_sse_subscribers[key] = asyncio.Queue(maxsize=64)
return _sse_subscribers[key]
async def _sse_unsubscribe(session_id: str, machine_id: str) -> None:
key = (session_id, machine_id)
async with _sse_lock:
_sse_subscribers.pop(key, None)
def sse_notify_new_action(session_id: str, machine_id: str) -> None:
"""À appeler chaque fois qu'une action visuelle est mise en queue.
Pousse un signal léger ; le serveur prépare ensuite l'action complète
(avec resolve serveur côté get_next_action) et la pousse au client.
Aucun ack ici — c'est le client qui POSTera /replay/result."""
key = (session_id, machine_id)
q = _sse_subscribers.get(key)
if q is None:
return # client pas (encore) connecté → restera dans _replay_queues
try:
q.put_nowait("dispatch")
except asyncio.QueueFull:
logger.warning("SSE queue pleine pour %s — drop signal", key)
@app.get("/api/v1/traces/stream/replay/events")
async def replay_events_sse(
request: Request,
session_id: str,
machine_id: str = "default",
):
"""Endpoint SSE — push d'actions au client Léa Windows.
Le client se connecte une fois, reste connecté tant que la session vit.
Chaque action prête à être exécutée arrive comme event JSON.
Heartbeat 15s : maintient la connexion à travers NPM/NoMachine/proxies.
Reconnexion automatique côté sseclient-py.
"""
queue = await _sse_subscribe(session_id, machine_id)
async def event_generator():
# Au connect : si des actions sont déjà en queue (cas reconnect),
# les pousser immédiatement avant d'attendre.
try:
# Drain initial : récupérer ce qui est déjà dans _replay_queues
# pour cette machine et l'envoyer immédiatement.
initial = await _drain_pending_actions(session_id, machine_id)
for action in initial:
yield ServerSentEvent(
data=json.dumps(action),
event="action",
id=action.get("action_id"),
)
_mark_retry_pending(action)
while True:
if await request.is_disconnected():
logger.info("SSE client %s/%s disconnect détecté",
session_id, machine_id)
break
try:
# Attendre une notification (timeout = laisser is_disconnected
# check passer). 5 s = compromis trafic / réactivité.
signal = await asyncio.wait_for(queue.get(), timeout=5.0)
except asyncio.TimeoutError:
continue # repart sur is_disconnected check
if signal == "shutdown":
break
# Récupérer la(les) action(s) à dispatcher.
# Réutilise toute la logique server-side existante (pause_for_human,
# extract_text, t2a_decision, condition…) — refactor `get_next_action`
# pour en extraire une coroutine `_resolve_next_visual_action()`.
action = await _resolve_next_visual_action(session_id, machine_id)
if action is None:
continue
yield ServerSentEvent(
data=json.dumps(action),
event="action",
id=action.get("action_id"),
)
_mark_retry_pending(action)
logger.info("[REPLAY] DISPATCH(SSE) action_id=%s",
action.get("action_id"))
except asyncio.CancelledError:
logger.info("SSE %s/%s cancelled (server shutdown)",
session_id, machine_id)
raise
finally:
await _sse_unsubscribe(session_id, machine_id)
return EventSourceResponse(
event_generator(),
ping=15, # ping toutes les 15 s (NPM/NoMachine OK)
ping_message_factory=lambda: ServerSentEvent(comment="hb"),
headers={"X-Accel-Buffering": "no"}, # bypass nginx/NPM buffer
)
```
**Points clés :**
1. `EventSourceResponse(ping=15)` → heartbeat « `: hb\n\n` » toutes les 15 s. Tient les proxies NPM et NoMachine ouverts (idle timeout typique 60 s).
2. `X-Accel-Buffering: no` → désactive le buffering nginx/NPM (sinon l'event reste bloqué côté reverse-proxy jusqu'à ~16 KB).
3. `request.is_disconnected()` → détection instantanée de déconnexion Léa (NoMachine freeze, redémarrage Windows). Le serveur libère immédiatement les ressources.
4. `id=action_id` sur chaque event → permet au client de demander `Last-Event-ID` au reconnect (`sseclient-py` le gère automatiquement). À combiner avec le watchdog § 6 pour garantir zéro perte.
5. Coexistence avec pull-poll : ajouter env `RPA_REPLAY_TRANSPORT=sse|poll` côté serveur ET côté client (rollback 1-ligne en cas de pépin démo).
**Hook côté queue producer** (à insérer là où `_replay_queues[session_id].append(action)` est appelé, p. ex. dans `start_replay`) :
```python
# Après chaque append d'action visuelle dans _replay_queues :
sse_notify_new_action(session_id, machine_id)
```
---
## 5. Pseudo-code client Léa Windows (remplace polling actuel)
À placer dans `agent_v0/agent_v1/network/streamer.py` ou un nouveau module `replay_subscriber.py`. Bibliothèque : `pip install sseclient-py>=1.8`.
```python
# replay_subscriber.py — boucle de réception SSE côté Léa
import json
import time
import requests
import sseclient
from ..config import API_TOKEN, STREAMING_ENDPOINT
RECONNECT_BACKOFF = [1.0, 2.0, 5.0, 10.0, 30.0]
class ReplaySubscriber:
def __init__(self, session_id, machine_id, on_action):
self.session_id = session_id
self.machine_id = machine_id
self.on_action = on_action # callback exécuteur
self.running = False
self._last_event_id = None
self._thread = None
def start(self):
self.running = True
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self):
self.running = False
def _run(self):
attempt = 0
while self.running:
try:
url = (
f"{STREAMING_ENDPOINT}/api/v1/traces/stream/replay/events"
f"?session_id={self.session_id}&machine_id={self.machine_id}"
)
headers = {
"Authorization": f"Bearer {API_TOKEN}",
"Accept": "text/event-stream",
"Cache-Control": "no-cache",
}
if self._last_event_id:
headers["Last-Event-ID"] = self._last_event_id
# IMPORTANT : pas de read_timeout — c'est tout l'intérêt SSE.
# Le ping serveur 15 s + le TCP keepalive OS suffisent.
# On garde un connect_timeout pour ne pas bloquer si serveur down.
resp = requests.get(
url, headers=headers, stream=True,
timeout=(10, None), # (connect, read) ; read=None = infini
)
if resp.status_code != 200:
raise RuntimeError(f"SSE refusé HTTP {resp.status_code}")
attempt = 0 # reset backoff dès qu'on a une connexion stable
client = sseclient.SSEClient(resp)
for event in client.events():
if not self.running:
break
if event.event == "action" and event.data:
action = json.loads(event.data)
if event.id:
self._last_event_id = event.id
# Exécuter via le callback (cascade replay existante)
try:
result = self.on_action(action)
except Exception as e:
logger.exception("on_action levé : %s", e)
result = {"success": False, "error": str(e)}
# Reporter résultat — endpoint inchangé
self._post_result(action, result)
# event.event == "ping" → ignorer, c'est le heartbeat
except (requests.ConnectionError, requests.Timeout, RuntimeError) as e:
if not self.running:
break
delay = RECONNECT_BACKOFF[min(attempt, len(RECONNECT_BACKOFF)-1)]
logger.warning("SSE déconnecté (%s) → retry dans %ss", e, delay)
time.sleep(delay)
attempt += 1
def _post_result(self, action, result):
"""Réutilise l'endpoint POST /replay/result existant (inchangé)."""
# … identique à _report_action_result actuel d'executor.py
```
**Compatibilité avec `agent_frozen` :** côté Léa cliente, l'ajout est un nouveau module (pas un patch de code chaud). Modifier `main.py` pour instancier `ReplaySubscriber` au lieu du polling, sous flag env `RPA_REPLAY_TRANSPORT`. Redéploiement SCP vers `dom@192.168.1.11` requis (cf. `feedback_scp_auto_modif_client_windows.md`).
---
## 6. Watchdog `_retry_pending` (à brancher MAINTENANT, indépendamment de SSE)
**Justification :** même avec SSE, un crash entre le `yield` serveur et le `POST /replay/result` client peut laisser une action dans `_retry_pending` sans report. Le watchdog est l'ultime filet.
**À insérer dans `api_stream.py`** au démarrage de l'app (event `startup`) :
```python
# api_stream.py — watchdog d'orphelins
_RETRY_WATCHDOG_INTERVAL_S = 10.0
_RETRY_ORPHAN_THRESHOLD_S = 30.0 # action sans report depuis 30 s → re-dispatch
_RETRY_MAX_RESENDS = 2 # éviter boucle infinie
async def _retry_pending_watchdog():
"""Re-dispatche les actions dispatched depuis > 30s sans report.
Idempotence garantie par report_action_result qui pop _retry_pending."""
while True:
try:
await asyncio.sleep(_RETRY_WATCHDOG_INTERVAL_S)
now = time.time()
orphans = []
# Snapshot pour éviter mutation concurrente
async with _async_replay_lock():
for aid, info in list(_retry_pending.items()):
dispatched_at = info.get("dispatched_at", 0)
resent = info.get("resent_count", 0)
if dispatched_at == 0:
continue
if now - dispatched_at < _RETRY_ORPHAN_THRESHOLD_S:
continue
if resent >= _RETRY_MAX_RESENDS:
logger.error(
"[BUS] lea:dispatch_orphan_giveup action_id=%s "
"resent=%d age=%.1fs",
aid, resent, now - dispatched_at,
)
_retry_pending.pop(aid, None)
continue
orphans.append((aid, info))
for aid, info in orphans:
action = info["action"]
session_id = action.get("session_id") or info.get("session_id")
if not session_id:
continue
async with _async_replay_lock():
q = _replay_queues.setdefault(session_id, [])
# Repush en TÊTE (sinon ordre des steps cassé)
q.insert(0, action)
info["resent_count"] = info.get("resent_count", 0) + 1
info["dispatched_at"] = 0 # sera reset au prochain DISPATCH
logger.warning(
"[BUS] lea:dispatch_orphan_resent action_id=%s "
"resent=%d session=%s",
aid, info["resent_count"], session_id,
)
# Si SSE actif : notifier le subscriber
machine_id = info.get("machine_id", "default")
sse_notify_new_action(session_id, machine_id)
except asyncio.CancelledError:
break
except Exception:
logger.exception("Watchdog _retry_pending levé — continue")
@app.on_event("startup")
async def _start_retry_watchdog():
if os.environ.get("RPA_RETRY_WATCHDOG_ENABLED", "1") == "1":
app.state._retry_watchdog_task = asyncio.create_task(
_retry_pending_watchdog()
)
logger.info("Watchdog _retry_pending démarré (orphan>%.0fs, every %.0fs)",
_RETRY_ORPHAN_THRESHOLD_S, _RETRY_WATCHDOG_INTERVAL_S)
```
**Modifications minimales requises** pour que le watchdog ait les bonnes infos :
1. Au point de dispatch (ligne ~3354 actuelle), ajouter `dispatched_at: time.time()` et `session_id` / `machine_id` dans `_retry_pending[action_id]`.
2. Dans `report_action_result` (ligne 3491), `pop` reste la clé d'idempotence — **aucun changement**, le code actuel fonctionne déjà parfaitement avec resends.
**Concurrence avec un report tardif :** si le client renvoie un report APRÈS le re-dispatch (race), le second `pop` retourne `None``report_action_result` répond `{"status": "no_active_replay"}` (ligne 3488) ou un retry de retry — tous les chemins sont déjà idempotents grâce au `pop`.
**Kill-switch :** `RPA_RETRY_WATCHDOG_ENABLED=0` (mode legacy). Pattern aligné sur QW1/QW2/QW4 (cf. `LESSONS_LEARNED_GHT_2026-05.md` §kill-switches).
---
## 7. Plan de migration en 3 étapes
### Étape 1 — Watchdog SEUL (2-4 h, déployable demain)
- Ajouter `_retry_pending_watchdog` + champs `dispatched_at/session_id/machine_id` dans `_retry_pending`.
- Flag `RPA_RETRY_WATCHDOG_ENABLED=1` par défaut, désactivable.
- Garder le client à `read_timeout=30` (déjà recommandé quick fix démo).
- **Effet immédiat :** plus aucune action perdue silencieusement, même sur le transport pull-poll actuel.
- Test : injecter un sleep 35 s avant `report_action_result` côté client → vérifier `lea:dispatch_orphan_resent` dans logs et que l'action ré-arrive.
### Étape 2 — Endpoint SSE serveur en parallèle (1 j)
- Ajouter `/api/v1/traces/stream/replay/events` (code §4).
- Refactoriser `get_next_action` pour extraire `_resolve_next_visual_action(session_id, machine_id)` réutilisable depuis le SSE (DRY — c'est la même cascade pause_for_human / extract_text / t2a_decision / clic conditionnel).
- Tests serveur : `pytest tests/integration/test_stream_processor.py` + nouveau `test_sse_dispatch.py` (connect, push, disconnect, ping, Last-Event-ID).
- Le pull-poll continue de tourner en parallèle (zéro impact démo).
### Étape 3 — Bascule client Léa (1 j + redéploiement SCP)
- Ajouter `replay_subscriber.py` côté agent_v1.
- Flag `RPA_REPLAY_TRANSPORT=sse|poll` côté client, valeur par défaut = `poll` pendant 1 semaine, puis bascule à `sse` après runs validés.
- SCP vers `dom@192.168.1.11` : `network/streamer.py`, `network/replay_subscriber.py`, `core/executor.py`, `main.py`.
- Test E2E sur Demo_urgence_3_db (46 steps) avec NoMachine freeze simulé (cf. `feedback_agent_frozen.md`) → vérifier reconnect SSE + Last-Event-ID résume sans perte.
- Si OK : flag par défaut `sse` ; le watchdog reste actif comme filet.
---
## 8. Risques et tests E2E
### Risques techniques
| Risque | Mitigation |
|---|---|
| NoMachine 9.5.7 coupe la connexion idle même avec ping 15 s | `ping=10` au lieu de 15, et `tcp_keepalive` côté socket Python (`setsockopt SO_KEEPALIVE`) |
| NPM reverse-proxy bufferise SSE | `X-Accel-Buffering: no` + vérifier `proxy_buffering off` dans la conf NPM `lea.labs.laurinebazin.design` |
| Léa Windows freeze longue (>2 min) → SSE socket morte mais OS pense vivante | watchdog côté serveur tue la connexion si pas d'ack `report_action_result` reçu depuis 60 s (à ajouter) |
| Double-dispatch (race watchdog + reconnect Last-Event-ID) | idempotence côté client : `if action_id in self._processed: skip` (set bounded LRU 256) |
| Gemma cloud 503 (vécu 12 mai) bloque t2a_decision >> 30 s | watchdog re-pushe → mais le 2e essai re-bloque. Plafond `_RETRY_MAX_RESENDS=2` puis abandon → pause supervisée |
| Drift exemption template ≥0.95 / hybrid ≥0.80 (contournement actif) | aucun impact — c'est une logique de resolve, pas de transport |
| Fallback heartbeat capture <1200×800 (contournement) | aucun impact — c'est sur l'upload, pas le dispatch |
### Tests E2E à passer avant bascule
1. **Smoke** : démarrer replay 5 steps, vérifier dispatch SSE et arrivée chez client.
2. **Long action serveur** : step avec `extract_text` 8 s puis `click` — l'action `click` doit arriver SANS perte (le test 8 mai en a perdu 9).
3. **Déconnexion brutale** : `taskkill /F /IM python.exe` côté Léa puis relancer → SSE reconnect + Last-Event-ID résume sans re-dispatcher les actions déjà acquittées.
4. **NoMachine freeze simulé** : couper VPN 90 s → reconnect, vérifier que les actions empilées arrivent en rafale propre.
5. **Watchdog isolé** : passer `RPA_REPLAY_TRANSPORT=poll` + `RPA_RETRY_WATCHDOG_ENABLED=1`, faire dropper le report manuellement (sleep 35 s avant POST `/replay/result`) → vérifier resend + idempotence.
6. **Démo complète Demo_urgence_3_db** (46 steps, MOREL Catherine UHCD) : 0 action perdue, comparaison logs avant/après.
### Liens avec autres axes
- **AXE B2 (Validator)** : un Validator strict (vérif sémantique post-clic) n'a de sens que si on est sûr que toutes les actions arrivent. **B1 est prérequis de B2.**
- **AXE B4 (ORA — Observe Reason Act)** : ORA pousse des actions dans la queue exactement comme le replay classique. Le SSE bénéficie à ORA gratuitement (pas de refacto supplémentaire). **B1 dé-risque B4.**
---
## 9. Sources
### SSE / FastAPI
- [sse-starlette GitHub](https://github.com/sysid/sse-starlette) — référence implémentation
- [sse-starlette ping interval issue #16](https://github.com/sysid/sse-starlette/issues/16)
- [FastAPI tutorial SSE](https://fastapi.tiangolo.com/tutorial/server-sent-events/)
- [Real-Time Notifications Python FastAPI SSE](https://medium.com/@inandelibas/real-time-notifications-in-python-using-sse-with-fastapi-1c8c54746eb7)
- [Stop streaming response when client disconnects](https://github.com/fastapi/fastapi/discussions/7572)
- [Server-Sent Events Beat WebSockets for 95% of Real-Time Apps](https://dev.to/polliog/server-sent-events-beat-websockets-for-95-of-real-time-apps-heres-why-a4l)
### WebSocket / FastAPI
- [FastAPI WebSockets doc](https://fastapi.tiangolo.com/advanced/websockets/)
- [Weaponizing Real Time FastAPI](https://blog.greeden.me/en/2025/10/28/weaponizing-real-time-websocket-sse-notifications-with-fastapi-connection-management-rooms-reconnection-scale-out-and-observability/)
- [WebSocket Heartbeat Ping/Pong](https://websocket.org/guides/heartbeat/)
- [Handling WebSocket Disconnections FastAPI](https://hexshift.medium.com/handling-websocket-disconnections-gracefully-in-fastapi-9f0a1de365da)
### HTTP/2 status Python
- [Uvicorn HTTP/2 Issue #47](https://github.com/Kludex/uvicorn/issues/47) — non supporté
- [Gunicorn HTTP/2 guide](https://gunicorn.org/guides/http2/) — server push deprecated
- [The Three Python ASGI Servers](https://dev.to/bowmanjd/the-three-python-asgi-servers-5447)
### Frameworks externes
- [Anthropic computer-use-demo loop.py](https://github.com/anthropics/claude-quickstarts/blob/main/computer-use-demo/computer_use_demo/loop.py)
- [OpenAI Computer Use docs](https://developers.openai.com/api/docs/guides/tools-computer-use)
- [OpenAI Operator Explained](https://anchorbrowser.io/blog/how-openai-operator-works-with-ai-agents)
- [Skyvern GitHub](https://github.com/Skyvern-AI/skyvern)
- [Skyvern 2.0 architecture blog](https://www.skyvern.com/blog/skyvern-2-0-state-of-the-art-web-navigation-with-85-8-on-webvoyager-eval/)
- [browser-use Playwright to CDP](https://browser-use.com/posts/playwright-to-cdp)
- [browser-use cdp-use repo](https://github.com/browser-use/cdp-use)
- [Playwright MCP 2026 architecture](https://testquality.com/playwright-test-agents-mcp-architecture-2026/)
### Client SSE Python
- [sseclient-py PyPI](https://pypi.org/project/sseclient/)
- [requests-sse PyPI](https://pypi.org/project/requests-sse/)
- [LaunchDarkly Python SSE client](https://launchdarkly-sse-client-library.readthedocs.io/en/latest/)
### Patterns retry / idempotence / orphans
- [ARQ retry doc](https://arq-docs.helpmanual.io/)
- [Building Resilient Task Queues FastAPI ARQ](https://davidmuraya.com/blog/fastapi-arq-retries/)
- [Queue-Based Exponential Backoff](https://dev.to/andreparis/queue-based-exponential-backoff-a-resilient-retry-pattern-for-distributed-systems-37f3)
- [Interrupted Asynchronous Task Problem](https://medium.com/picus-security-engineering/the-interrupted-asynchronous-task-problem-and-solution-with-python-rq-435f1a597631)
### Proxy / NoMachine
- [SSE vs WebSocket Agent Readiness](https://agenthermes.ai/blog/sse-websocket-agent-readiness)
- [Troubleshooting SSE Multi-Service](https://medium.com/@wang645788/troubleshooting-server-sent-events-sse-in-a-multi-service-architecture-5084ce155ea0)
---
*Document destiné à servir de base de décision avant chiffrage final et implémentation. Lecture seule sur le code, aucune modification. À discuter avec Dom avant toute bascule.*