docs: add POC specs, handoffs, and research notes
This commit is contained in:
507
docs/recherche/AXE_B1_REPLAY_TRANSPORT.md
Normal file
507
docs/recherche/AXE_B1_REPLAY_TRANSPORT.md
Normal 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** | 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.*
|
||||
Reference in New Issue
Block a user