# Diagnostic — Replay `replay_free_68ca51ab` bloqué sur l'onglet Imagerie Date : 8 mai 2026 (matin avant démo GHT Sud 95) Workflow : `Urgence_aiva_demo` (`wf_a38aeebea5e6_1778162737`) Replay : `replay_free_68ca51ab` (cancellé manuellement à 10:34, après pause supervisée step 18) Auteur : ingénieur senior debug RPA / vision > **TL;DR — il y a deux causes simultanées, l'une explique l'autre.** > > 1. **Cause primaire (réseau) :** le client Léa V1 Windows utilise un `read_timeout=5 s` sur `GET /replay/next`. Sur cette même connexion, le serveur exécute parfois un `extract_text` (5–7 s) PUIS dispatche un `click` dans le même appel. Le client coupe avant la réponse. L'action `click` était déjà *poppée* de la queue serveur (`_retry_pending`), donc **elle est perdue silencieusement** — pas de retry automatique, pas de re-dispatch. > C'est ce qui s'est passé pour les steps 10, 12, 14 et 17 (clic Imagerie, Notes médicales, Synthèse Urgences, Codage). Le client a poursuivi son polling, mais quand il est revenu à l'écoute, le serveur en était déjà au step 18 (`Coller ou saisir le dossier patient`) — qui n'existe que sur la maquette aiva-vision (`codage.html`), donc échec `target_not_found` → pause supervisée. > > 2. **Cause aggravante (vision) :** la cascade `OCR-DIRECT` (`_resolve_by_ocr_text`) renvoie le **centre de la ligne entière** quand le `target_text` n'est qu'un sous-fragment (`score=0.8`). Pour la barre de tabs Easily, docTR détecte les 5 tabs comme une seule ligne. Conséquence : `Imagerie`, `Notes médicales` et `Synthèse Urgences` retournent quasiment les mêmes coordonnées (~0.23, 0.28) — c'est-à-dire le centre de la rangée de tabs (qui tombe sur Imagerie). Même si le client avait reçu chaque action, le clic aurait probablement raté la cible. --- ## 1. Reconstruction temporelle Workflow `Urgence_aiva_demo` (steps issues de la DB SQLite, ordre 1→22) : | Order | id (court) | type | label | |-------|----------------|------------------|-----------------------------------------------| | 1 | 43ab3c1417d3 | extract_table | Lire liste patients (IPP) | | 2 | 1dada40f6a44 | pause_for_human | Confirmer démarrage | | 3 | 288d0bceea90 | click_anchor | Ouvrir dossier MOREL (25003284) | | 4 | 5388268582d6 | extract_text | Lire Motif d'admission (`t_motif`) | | 5 | b18e530526bb | keyboard_shortcut| Scroll fin de page (End) | | 6 | b425b17b37f6 | extract_text | Lire bas de Motif (`t_motif_bas`) | | 7 | fc4cf0a78b65 | keyboard_shortcut| Retour haut (Home) | | 8 | 45f5d7fb7456 | click_anchor | Onglet Examens cliniques | | 9 | 4148c9e8caa4 | extract_text | Lire Examens cliniques (`t_examens`) | | 10 | 4c0663941f22 | click_anchor | Onglet Imagerie | | 11 | 93cf4c6651f3 | extract_text | Lire Imagerie (`t_imagerie`) | | 12 | **3b13c973d737** | click_anchor | **Onglet Notes médicales** | | 13 | a5840d6bf8ed | extract_text | Lire Notes médicales (`t_notes`) | | 14 | 8767d8e2e221 | click_anchor | Onglet Synthèse Urgences | | 15 | 835e5dd54bb7 | extract_text | Lire Synthèse (`t_synthese`) | | 16 | fc5a9676af55 | t2a_decision | Décision T2A (LLM) | | 17 | 156d7cd29ebb | click_anchor | Onglet « Codage > » (vers maquette aiva) | | 18 | 36346c1c40b9 | click_anchor | Cliquer textarea DPI (sur codage.html) | | ... | | | | ### Logs serveur — `journalctl --user -u rpa-streaming` Filtrage `replay_free_68ca51ab` + `RESOLVE_*` + `REPORT` + `extract_text`. Extraits pertinents : ``` 10:25:46 RESOLVE_ENTRY by_text='Examens cliniques' strict_mode=True screen=2560x1490 has_anchor=True 10:25:48 Strict resolve OCR-DIRECT : OK 'Examens cliniques' → (0.2305, 0.2676) score=0.80 10:25:48 RESOLVE_EXIT resolved=True method='hybrid_text_direct' coords=(0.2305, 0.2676) score=0.8 10:25:49 REPORT step_45f5d7fb7456 success=True actual_position=(0.2305, 0.2798) 10:25:55 extract_text → variable 't_examens' (1689 chars) 10:25:55 DISPATCH action_id=step_4c0663941f22 (click) by_text='Imagerie' ← client a déjà timeout 10:26:01 extract_text → variable 't_imagerie' (1084 chars) 10:26:01 DISPATCH action_id=step_3b13c973d737 (click) by_text='Notes médicales' ← perdu 10:26:08 extract_text → variable 't_notes' (1084 chars) 10:26:08 DISPATCH action_id=step_8767d8e2e221 (click) by_text='Synthèse Urgences' ← perdu 10:26:17 extract_text → variable 't_synthese' (1084 chars) 10:26:27 t2a_decision → variable 'dec' decision=FORFAIT_URGENCE (10.0s) 10:26:27 DISPATCH action_id=step_156d7cd29ebb (click) by_text='Codage' ← perdu (concurrence de polls) 10:26:27 DISPATCH action_id=step_36346c1c40b9 (click) by_text='Coller ou saisir le dossier patient' 10:26:28 RESOLVE_ENTRY by_text='Coller ou saisir le dossier patient' strict_mode=True 10:26:30 Strict resolve OCR-DIRECT : 'Coller ou saisir le dossier patient' non trouvé, passage VLM 10:26:36 RESOLVE_EXIT resolved=False method='strict_vlm_template_failed' … 10:28:47 REPORT step_36346c1c40b9 success=False error='target_not_found' warning='visual_resolve_failed' 10:34:00 Replay annulé manuellement ``` **Ce qui ne figure PAS dans les logs serveur** : aucun `RESOLVE_ENTRY` pour `by_text='Imagerie'`, `'Notes médicales'`, `'Synthèse Urgences'` ou `'Codage'` côté replay live. La cascade de résolution n'a JAMAIS été appelée pour ces tabs. → Le client n'a jamais frappé `/resolve_target` ni reçu l'action. ### Logs client — `C:\rpa_vision\agent_debug.log` ``` 10:25:44.710 Action de replay recue : click (id=step_45f5d7fb7456 — Examens cliniques) 10:25:47.448 Server resolve OK [hybrid_text_direct] score=0.80 10:25:48.008 Replay click [VISUAL] : (0.230, 0.280) -> (590, 447) sur (2560x1600) 10:25:48.324 Ecran change apres ~200ms 10:25:48.537 Resultat rapporte : replay_status=running, restant=14 10:25:53.771 WARNING : HTTPConnectionPool(host=192.168.1.40, port=5005): Read timed out (read timeout=5) 10:25:53.771 Replay termine - retour en mode capture 10:25:53.780 shared_state Replay termine ← 33 s de silence 10:26:26.608 Action de replay recue : click (id=step_36346c1c40b9 — Coller ou saisir...) 10:26:35.409 Server resolve échoué : vlm_and_template_all_failed 10:26:39.096 Server resolve échoué : no_target_criteria 10:26:44.178 Server resolve échoué : vlm_and_template_all_failed 10:26:45.585 ERROR [LEA] Léa a besoin d'aide: Je n'y arrive pas (« Coller ou saisir... ») … 10:28:45.762 [APPRENTISSAGE] Timeout global → 0 actions capturées 10:28:46.231 Replay termine ``` → Confirmation directe : le client a sauté **9 actions serveur+visuelles** entre l'OK Examens cliniques (10:25:48) et la réception de step 18 (10:26:26). --- ## 2. Diagnostic causal ### Chaîne de responsabilité ``` +---------------------------------------------------------------------------+ | Hyp #1 (cascade serveur foire) — INFIRMÉE | | La cascade serveur n'est même jamais invoquée pour ces 4 tabs. | | | | Hyp #2 (cascade locale Léa V1 prend le relais) — INFIRMÉE | | Le client n'a pas reçu d'action → rien à résoudre localement. | | | | Hyp #3 (coords brutes du record obsolètes) — INFIRMÉE | | L'ancre `anchor_0438bd2d9bdd_1778161174` (« Notes médicales ») a | | bbox (444, 424, 146, 48) qui dans l'image de référence pointe sur | | « Imagerie » et NON Notes médicales (le crop le confirme : | | /tmp/anchor_0438bd2d9bdd_1778161174_bbox.png montre « Imagerie »). | | Pareil pour anchor_6a2591e7c51c (« Synthèse Urgences ») dont la | | bbox (580, 423, 192, 47) crop « Notes médicales ». | | → Les bboxes des tabs sont décalées d'un cran à gauche dans la DB, | | mais ce n'est PAS la cause du blocage actuel : le mode strict + OCR- | | DIRECT ignore la bbox et part de by_text. Anomalie cosmétique à | | nettoyer hors-démo. | | | | Hyp #4 (offset écran live vs record) — PARTIELLEMENT VRAIE | | Voir §3. | | | | Hyp #5 (event onclick JS) — INFIRMÉE | | Voir §3. | | | | Hyp #6 (cache client/serveur) — INFIRMÉE | | Aucun `from_memory=True` dans les logs ; TargetMemoryStore pas hit. | | | | Cause primaire = HTTP TIMEOUT 5 s côté client | | + actions serveur lentes (extract_text 5-7 s, t2a_decision 10 s) | | + pas de watchdog d'orphelins dans `_retry_pending` | | | | Cause aggravante = OCR-DIRECT center-of-line bug | | score=0.8 → coords = centre de la ligne docTR entière, pas du span. | +---------------------------------------------------------------------------+ ``` ### Mécanique exacte du timeout `agent_v0/server_v1/api_stream.py` (`get_next_action`, lignes 2816-3083) : 1. Acquiert `_replay_lock` avec `acquire_timeout=4.5 s`. Sinon retourne `{server_busy: True}` — **OK**. 2. Une fois le lock pris, boucle `while queue:` qui exécute toutes les actions « serveur » (`extract_text`, `extract_table`, `t2a_decision`, `pause_for_human` non bloquant) **dans le même appel HTTP**, jusqu'à tomber sur une action visuelle (`click`/`type`/`key_combo`) qu'il dispatch et retourne. 3. `extract_text` est wrappé dans `loop.run_in_executor(...)` (timeout 180 s) pour ne pas bloquer l'event loop FastAPI — bon design. 4. **Mais le client appelle ce endpoint avec `timeout=5` (executor.py:1786).** Si la chaîne `extract_text + dispatch_click` prend plus de 5 s, la réponse arrive après que le client ait fermé sa socket. La réponse contient le `click` action et est perdue. 5. Côté serveur (ligne 3209-3224), l'action est déjà *poppée* de la queue et stockée dans `_retry_pending[action_id]` au moment du dispatch. Pas de retry automatique tant que le client ne renvoie pas un report (qui ne viendra pas). 6. Le client repasse en `_replay_active=False` (`main.py:331`) — *cosmétique* — puis continue de poller. Au poll suivant, la queue est passée à l'action suivante (`extract_text`), idem boucle. **Aucun watchdog ne ré-énonce `_retry_pending` au client.** L'unique chemin pour récupérer une action perdue serait que le client envoie un report avec `success=False` (jamais le cas ici puisqu'il n'a pas reçu l'action). ### Mécanique exacte de l'OCR-DIRECT center-of-line `agent_v0/server_v1/resolve_engine.py:1447-1527` : ```python # Match exact > contient > mot par mot score = 0.0 if target_lower == line_lower: score = 1.0 elif target_lower in line_lower: score = 0.8 elif any(target_lower == w.value.lower() for w in line_obj.words): score = 0.9 if score > best_score: box = line_obj.geometry # bbox de la LIGNE ENTIÈRE cx = (box[0][0] + box[1][0]) / 2 cy = (box[0][1] + box[1][1]) / 2 ``` Quand docTR voit la barre de tabs Easily comme **une seule ligne** : `"Motif d'admission Examens cliniques Imagerie Notes médicales Synthèse Urgences Codage >"`, il retourne pour CHAQUE `target` qui est un sous-fragment de cette ligne **le centre de la rangée entière** (~50 % en x, ~28 % en y). Les coords ne dépendent pas du tab demandé. Preuve archivée dans les logs e2e_singleshot du 8 mai à 09:58 (single screenshot, donc résultats reproductibles) : - `'Imagerie'` → `(0.2305, 0.2805)` score 0.8 - `'Notes médicales'` → `(0.2285, 0.2805)` score 0.8 - `'Synthèse Urgences'` → `(0.2285, 0.2805)` score 0.8 - (delta ~5 px, trois tabs visuellement à 4-5 cm d'écart les uns des autres) → Si le client avait reçu les 4 actions, il aurait cliqué 4 fois quasiment au même endroit (vers Imagerie). Bug latent indépendant du timeout. --- ## 3. Vérification des hypothèses 4 et 5 ### Hyp #4 — offset écran live vs record Géométrie réelle du rendu Easily Assure dans une fenêtre Edge fullscreen 2560×1600 : - Edge title bar : ~40 px (offset_y de la fenêtre = 49 d'après le log « Grounding contraint à la fenêtre : 2560x1490 (0, 49) ») - Edge tabs/URL/bookmarks : ~250 px - `.app-header` Easily (bleu) : 36 px (padding 8 + font 18) - `.menu-bar` (Patients/Planning/...) : 32 px - `.patient-banner` (IPP MOREL...) : ~50 px - `.tabs` (Motif/Examens/...) : 36 px (height CSS) → **y range ≈ 410-450 dans l'image 2560x1600** Le crop de référence (ancre `anchor_0438bd2d9bdd_1778161174`) à y=420-480 montre exactement la rangée de tabs (cf. `/tmp/tabs_row_full.png`). Pas d'offset majeur entre record et live. Une éventuelle dérive ±10-30 px est gérable par un click au pixel central. → **Hypothèse 4 partiellement vraie** : il y a effectivement un offset, mais il n'est pas la cause du bug. Et il est dégradé par le bug OCR-DIRECT center-of-line (cause #2) puisque le centre de la ligne tombe au milieu de la barre, pas sur le tab demandé. ### Hyp #5 — event onclick JS de la maquette `docs/clients/ght_sud_95/mockup_easily_assure/dossier.html:36-43` : ```html
Motif d'admission Examens cliniques Imagerie Notes médicales Synthèse Urgences Codage >
``` `app.js:377-401` : ```js function installTabs() { const tabs = document.querySelectorAll('.tabs .tab[data-tab]'); tabs.forEach(tab => { tab.addEventListener('click', () => { const target = tab.getAttribute('data-tab'); history.replaceState(null, '', '#' + target + location.search); activate(target); window.scrollTo(0, 0); }); }); } ``` → Mécanisme propre. `addEventListener('click')` directement sur les ``. Aucun overlay, aucun event swallow. Un MouseEvent (Win32 SendInput → Windows Edge → DOM) sur le pixel d'un tab DÉCLENCHE le listener. Le tab `Codage` est un lien `href="codage.html"` → navigation native. Aucun problème côté maquette. → **Hypothèse 5 infirmée**. --- ## 4. Reproduction en isolation Données déjà disponibles via le test e2e_singleshot du 8 mai 09:58 (`session=e2e_singleshot_1778227119_1fe686`), qui appelle `/resolve_target` sur un screenshot fixe (probablement l'onglet Imagerie ouvert) : | target_spec.by_text | Résolution | x_pct | y_pct | score | |------------------------------|-----------------------|--------|--------|-------| | `25003284` | hybrid_text_direct | 0.0312 | 0.3539 | 1.00 | | `Examens cliniques` | hybrid_text_direct | 0.0610 | 0.3195 | 1.00 | | `Imagerie` | hybrid_text_direct | 0.2305 | 0.2805 | 0.80 | | `Notes médicales` | hybrid_text_direct | 0.2285 | 0.2805 | 0.80 | | `Synthèse Urgences` | hybrid_text_direct | 0.2285 | 0.2805 | 0.80 | | `Codage` | hybrid_text_direct | 0.1279 | 0.1641 | 0.80 | | `Coller ou saisir le dossier patient` | hybrid_text_direct | 0.0630 | 0.4125 | 1.00 | | `Justification de la décision`| template_matching | 0.5000 | 0.5000 | 1.00 | → Reproduction confirmée : trois tabs (Imagerie / Notes / Synthèse) renvoyés à coords pratiquement identiques. Le test du 10:01 (1920x1080) reproduit la même chose : `Notes médicales` → (0.2227, 0.1259), `Imagerie` → (0.2256, 0.1267), même row. Pour reproduire en CLI sans flask : ```bash cd /home/dom/ai/rpa_vision_v3 && source .venv/bin/activate python -c " from agent_v0.server_v1.resolve_engine import _resolve_by_ocr_text img='/home/dom/ai/rpa_vision_v3/visual_workflow_builder/backend/data/anchors/anchor_0438bd2d9bdd_1778161174_full.png' for t in ['Imagerie','Notes médicales','Synthèse Urgences','Codage','Examens cliniques']: r = _resolve_by_ocr_text(img, t, 2560, 1600) print(f'{t:25s} -> {r}') " ``` Ce smoke test (offline, ~30 s la 1re fois pour télécharger le modèle docTR) prouve la cause #2 sans dépendre du PC Windows. --- ## 5. Trois correctifs proposés (sans appliquer) ### Quick fix démo (5–10 min) — **passer le client en `timeout=30` pour `/replay/next`** Fichier : `agent_v1/core/executor.py:1786` côté Windows. Changer : ```python timeout=5, ``` en : ```python timeout=30, ``` Justification : la borne dure côté serveur est déjà 180 s par action serveur ; le serveur retourne aussi `server_busy=True` au plus tard à 4.5 s. Un timeout client à 30 s laisse passer un `extract_text` de 5-10 s + dispatch d'un click sans couper la connexion. Pas d'effet de bord majeur — au pire le client attend 30 s en cas de mort serveur, déjà couvert par la backoff. **Effet** : - Le client ne loupera plus aucun click même si extract_text/t2a_decision est devant. - Le bug OCR-DIRECT center-of-line reste, mais `Examens cliniques` (score 1.0) et `Codage` (autre ligne) seront correctement résolus → la maquette aiva-vision finira par s'afficher. - Imagerie/Notes/Synthèse cliqueront tous les trois sur le centre de la rangée (en pratique au-dessus d'Imagerie). C'est cosmétiquement faux mais **t_imagerie/t_notes/t_synthese seront tous identiques** ; il faut prévenir Amina qu'on n'aura qu'une seule lecture du DPI multi-onglets. **Risque** : très bas. Modifier un seul littéral. Redéploiement SSH du fichier executor.py. ### Quick fix démo bis (10–15 min) — **boost OCR-DIRECT pour ne renvoyer que le centre du span matché** Fichier : `agent_v0/server_v1/resolve_engine.py:1486-1519`. Idée : quand le score est 0.8 (substring match) ou 0.9 (mot exact dans la ligne), recalculer `cx, cy` à partir des **bboxes des words** qui composent le `target_text`, pas de la ligne entière. Pseudo-patch (à appliquer après-démo) : ```python elif target_lower in line_lower: score = 0.8 # Recalculer la bbox du span uniquement matched_words = [w for w in line_obj.words if w.value.lower() in target_lower] if matched_words: xs = [pt[0] for w in matched_words for pt in w.geometry] ys = [pt[1] for w in matched_words for pt in w.geometry] cx = (min(xs) + max(xs)) / 2 cy = (min(ys) + max(ys)) / 2 ``` **Effet** : chaque tab résolu à son propre centre, plus de collision. **Risque** : moyen — il faut tester avec docTR pour vérifier que les `geometry` des words sont normalisées dans le même repère que celui de la ligne. Possible que le nettoyage du substring matching soit tordu par les accents/casse. À NE PAS appliquer à la chaude pour la démo, mais pour le runner 2. > **Combo conseillé pour la démo** : appliquer SEULEMENT le fix #1 (timeout 30 s). Le bug center-of-line tab fait que t_imagerie/t_notes/t_synthese seront tous = même contenu (Imagerie). Si Amina utilise déjà `t_imagerie ∪ t_notes ∪ t_synthese` dans le prompt T2A, ça reste exploitable (juste moins de variété). Le clic Codage > marchera (autre ligne docTR). ### Fix moyen terme (30–60 min) — **watchdog `_retry_pending` côté serveur** Ajout d'une boucle background dans `api_stream.py` qui scanne `_retry_pending` toutes les 10 s et : - Si une action a été dispatchée il y a > 30 s sans `REPORT` → la repush en tête de queue (avec un `_resent=True` flag pour stats). - Émission `[BUS] lea:dispatch_orphan_resent`. Justification : aujourd'hui une action perdue (timeout, kill client, déconnexion réseau) est perdue silencieusement. C'est un trou de fiabilité indépendamment de la démo GHT. Le watchdog garantit la reprise sans intervention manuelle. **Risque** : moyen — il faut bien gérer la concurrence avec le client qui pourrait finalement renvoyer le report tardivement. Idempotence des reports déjà gérée dans `report_action_result` (line 3356 : `_retry_pending.pop(action_id)`), donc resend = réponse éventuelle ignorée. ### Fix structurel (post-démo, refonte) — **Server-Sent Events (SSE) ou WebSocket pour le push d'actions** Le pattern « pull avec long poll 5 s » est intrinsèquement fragile dès que les étapes serveur sont imprévisibles. Solutions architecturales : 1. **SSE** (`text/event-stream`) : connexion persistante, le serveur push chaque action quand prête. Pas de timeout client à régler. Reconnexion automatique gérée nativement par EventSource. Plus simple à implémenter que WebSocket en FastAPI. 2. **WebSocket** : full duplex, idéal pour heartbeat + actions + monitoring. Plus de code mais futur-proof. 3. **HTTP/2 server push + chunked responses** : entre les deux. Pas standard côté requests Python. Bénéfices : - Suppression du bug timeout pour de bon. - `_retry_pending` devient quasi inutile (push ack-based). - Réduction du trafic (pas de poll inutile ~1/s). - Détection immédiate de déconnexion client → déclenche pause supervisée serveur. Coût : 1-2 jours dev + tests E2E. --- ## 6. Notes annexes (à nettoyer hors démo) 1. **Anomalie d'ancrage DB** : les ancres `anchor_0438bd2d9bdd_1778161174` (Notes médicales label) et `anchor_6a2591e7c51c_1778229076` (Synthèse Urgences label) ont des bboxes pointant un cran à gauche du tab nommé. Ce n'est pas la cause du bug (mode strict + OCR-DIRECT bypass la bbox) mais c'est trompeur en debug. À reposer en VWB record session post-démo. 2. **`target_text` mal-OCRisé en DB** : le champ `target_text` de l'ancre Notes médicales contient `"ine Né(e) le 14/03/1947 I 77 ans es Imagerie Notes médical J scan, echograj phie"`. C'est un OCR brut de la zone capturée — utile en debug, à ne pas confondre avec un identifiant fiable. 3. **Pré-check OCR post-cascade désactivé** (`RPA_ENABLE_TEXT_PRECHECK=false`) : pour la démo c'est OK. Mais à activer post-démo car il aurait peut-être attrapé le cas (clic sur centre de rangée = OCR autour ne voit pas exactement le by_text demandé). À recalibrer (radius_px et min_token_ratio) pour ne pas faux-rejeter sur les tabs à 2 tokens. 4. **Pas de `RESOLVE_ENTRY` dans les logs serveur du replay live pour les tabs perdus** : confirme que `/resolve_target` n'est PAS appelé tant que le client n'a pas reçu l'action. Aucun chemin caché côté serveur. 5. **Concurrence de polls vue à 10:26:27** : deux DISPATCHes en 0.6 s pour 2 polls quasi-simultanés. C'est cohérent avec deux requêtes en attente sur l'acquire lock + une qui retourne `server_busy` puis une qui acquiert. Le bug fundamental reste le timeout client trop court, pas la concurrence. --- ## Synthèse (≤ 400 mots) Le replay s'est bloqué non pas à cause d'un échec de résolution visuelle, mais à cause d'une **désynchronisation client-serveur silencieuse**. À 10:25:48, le client Léa Windows a cliqué avec succès « Examens cliniques » et reporté `success=True`. Cinq secondes plus tard (10:25:53.771), il poste un nouveau `GET /replay/next` qui timeout à 5 s — parce que côté serveur l'appel commence par `extract_text` (~5–7 s pour récupérer `t_examens` 1689 chars) puis dispatche le click `Imagerie`. Le serveur a déjà *poppé* l'action de la queue et stocké dans `_retry_pending`, mais la réponse HTTP arrive après que le client ait fermé sa socket. **L'action est perdue.** Aucun watchdog côté serveur ne la republie. Le client repasse en mode capture cosmétique mais continue à poller. Pendant 33 s, à chaque /next il aspire de l'action serveur (extract Imagerie, dispatch Notes — perdu, extract Notes, dispatch Synthèse — perdu, extract Synthèse, t2a_decision 10 s, dispatch Codage — perdu) jusqu'à recevoir directement step 18 (« Coller ou saisir le dossier patient ») qui n'existe que sur la maquette `codage.html`. Échec `target_not_found` → pause supervisée → l'utilisateur cancel. C'est pour ça que `t_examens`, `t_imagerie`, `t_notes`, `t_synthese` ont tous le même contenu (1689 puis 1084 chars répétés) : l'écran n'a jamais changé d'onglet ; le DPI envoyé à T2A est mutilé. Bug aggravant **indépendant** : `_resolve_by_ocr_text` (resolve_engine.py:1447) renvoie le **centre de la ligne docTR entière** quand le `target_text` est un sous-fragment (score 0.8). docTR détecte la barre de tabs comme une ligne unique → Imagerie/Notes/Synthèse renvoient tous (0.23, 0.28). Confirmé par le test e2e_singleshot du même jour à 09:58. Même si le client recevait les actions, le clic raterait la cible. Latent dès que plusieurs tokens cibles partagent la même ligne docTR. **Recommandation démo** : passer le `read_timeout` client de 5 s à 30 s (`agent_v1/core/executor.py:1786`). Quick win, zéro risque, suffit pour que le pipeline aboutisse à `codage.html` et que la maquette aiva-vision se remplisse. Accepter pour la démo que les 3 tabs Imagerie/Notes/Synthèse cliqueront tous au centre de la rangée (le DPI multi-onglets sera dégradé mais le t2a_decision restera exploitable car `t_motif` et `t_motif_bas` portent l'essentiel du diagnostic). **Priorité post-démo** : (1) watchdog `_retry_pending`, (2) fix OCR-DIRECT center-of-span, (3) refonte SSE/WebSocket.