30 KiB
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) etdocs/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.
→ 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.
→ 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, Skyvern 2.0 blog.
→ 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, 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, doc officielle.
→ 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.
# 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 :
EventSourceResponse(ping=15)→ heartbeat «: hb\n\n» toutes les 15 s. Tient les proxies NPM et NoMachine ouverts (idle timeout typique 60 s).X-Accel-Buffering: no→ désactive le buffering nginx/NPM (sinon l'event reste bloqué côté reverse-proxy jusqu'à ~16 KB).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.id=action_idsur chaque event → permet au client de demanderLast-Event-IDau reconnect (sseclient-pyle gère automatiquement). À combiner avec le watchdog § 6 pour garantir zéro perte.- Coexistence avec pull-poll : ajouter env
RPA_REPLAY_TRANSPORT=sse|pollcô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) :
# 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.
# 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) :
# 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 :
- Au point de dispatch (ligne ~3354 actuelle), ajouter
dispatched_at: time.time()etsession_id/machine_iddans_retry_pending[action_id]. - Dans
report_action_result(ligne 3491),popreste 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+ champsdispatched_at/session_id/machine_iddans_retry_pending. - Flag
RPA_RETRY_WATCHDOG_ENABLED=1par 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_resultcôté client → vérifierlea:dispatch_orphan_resentdans 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_actionpour 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+ nouveautest_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.pycôté agent_v1. - Flag
RPA_REPLAY_TRANSPORT=sse|pollcôté client, valeur par défaut =pollpendant 1 semaine, puis bascule àsseaprè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
- Smoke : démarrer replay 5 steps, vérifier dispatch SSE et arrivée chez client.
- Long action serveur : step avec
extract_text8 s puisclick— l'actionclickdoit arriver SANS perte (le test 8 mai en a perdu 9). - Déconnexion brutale :
taskkill /F /IM python.execôté Léa puis relancer → SSE reconnect + Last-Event-ID résume sans re-dispatcher les actions déjà acquittées. - NoMachine freeze simulé : couper VPN 90 s → reconnect, vérifier que les actions empilées arrivent en rafale propre.
- 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. - 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 — référence implémentation
- sse-starlette ping interval issue #16
- FastAPI tutorial SSE
- Real-Time Notifications Python FastAPI SSE
- Stop streaming response when client disconnects
- Server-Sent Events Beat WebSockets for 95% of Real-Time Apps
WebSocket / FastAPI
- FastAPI WebSockets doc
- Weaponizing Real Time FastAPI
- WebSocket Heartbeat Ping/Pong
- Handling WebSocket Disconnections FastAPI
HTTP/2 status Python
- Uvicorn HTTP/2 Issue #47 — non supporté
- Gunicorn HTTP/2 guide — server push deprecated
- The Three Python ASGI Servers
Frameworks externes
- Anthropic computer-use-demo loop.py
- OpenAI Computer Use docs
- OpenAI Operator Explained
- Skyvern GitHub
- Skyvern 2.0 architecture blog
- browser-use Playwright to CDP
- browser-use cdp-use repo
- Playwright MCP 2026 architecture
Client SSE Python
Patterns retry / idempotence / orphans
- ARQ retry doc
- Building Resilient Task Queues FastAPI ARQ
- Queue-Based Exponential Backoff
- Interrupted Asynchronous Task Problem
Proxy / NoMachine
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.