# 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** | 0–1 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.*