# SPEC TRANSPORT — Contrat dispatch / ack / retry / orphan / resume **Date :** 2026-05-24 **Auteur :** Claude (recherche dispatchée, lecture seule sur code) **Statut :** spécification contractuelle. Aucune modif code. Toutes les sections sont en tables ou state machines, prose minimale. **Pré-requis :** - `docs/recherche/AXE_B1_REPLAY_TRANSPORT.md` (transport SSE/WebSocket — choix techno) - `docs/recherche/AXE_B1_DEEP_WATCHDOG.md` (implémentation watchdog) - `docs/REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md` (bug 9 actions perdues) - `docs/SYNTHESE_TECHNOS_REPLAY_2026-05-23.md` §4 Ce doc fige **le contrat** entre `api_stream.py` (FastAPI Linux) et `agent_v1/core/executor.py` + `agent_v1/network/streamer.py` (Léa Windows). Il est **isomorphe poll ↔ SSE** (cf. §9). --- ## 1. TL;DR + diagramme d'ensemble Une action visuelle est un **message borné par 2 IDs** : `action_id` (unique par step de replay, déterministe) + `attempt_id` (UUID, incrémenté à chaque re-dispatch transport). Le serveur tient une **mini-visibility-timeout in-memory** (`_retry_pending`). Le client tient un **LRU `dedup_set`** des `attempt_id` récemment exécutés. La pause supervisée (`paused_need_help`) est l'état absorbant en cas d'épuisement des essais ou de signal explicite (`system_dialog`, `wrong_window`, `target_not_found`). Le contrat est identique en polling (transport actuel) et en SSE (cible AXE_B1). ``` ┌───────────── server (Linux, FastAPI :5005) ─────────────┐ │ │ _replay_queues │ ┌──────────┐ DISPATCH ┌─────────────┐ │ [session_id] │ │ PENDING │ ──────────────►│ DISPATCHED │ │ │ └──────────┘ │ (_retry_ │ │ │ ▲ │ pending) │ │ │ │ repush head └─────┬───────┘ │ │ │ (watchdog) │ REPORT(success) │ │ ┌────┴─────┐ age>30s, resent │ verify OK │ │ │ ORPHAN │ ◄─── timeout ◄────────┤ │ │ │ (resent_ │ │ │ │ │ count++)│ ▼ │ │ └──────────┘ ┌──────────┐ │ │ │ resent ≥ MAX │ ACKED │ │ │ ▼ └──────────┘ │ │ ┌──────────┐ REPORT(fail+system_dialog/wrong_window) │ │ │ ABANDONED│◄──┬────────────────┐ │ │ └──────────┘ │ │ │ │ │ ▼ ▼ │ │ │ ┌──────────────┐ ┌──────────┐ │ │ └──►│ PAUSE_NEED_ │ │ FAILED │ │ │ │ HELP │ │ (retry │ │ │ └─────┬────────┘ │ budget │ │ │ │ /resume │ out) │ │ │ ▼ └──────────┘ │ │ (resume_action en tête queue) │ └──────────────────────────────────────────────────────────┘ ▲ │ │ POST /replay/result │ GET /replay/next (poll) │ │ ou SSE event │ ▼ ┌───────────┴──────────── client Léa (Windows) ────────────┐ │ ┌──────────┐ parse ┌──────────┐ execute_replay_ │ │ │ RECEIVED │──────────►│ DEDUP │ action() │ │ └──────────┘ │ CHECK │ │ │ └────┬─────┘ │ │ hit LRU │ miss │ │ │ ▼ │ │ │ ┌──────────┐ │ │ │ │EXECUTING │ │ │ │ └────┬─────┘ │ │ ▼ ▼ │ │ ┌──────────────┐ │ │ │ REPORTING │ → POST /replay/result │ │ └──────────────┘ │ └──────────────────────────────────────────────────────────┘ ``` --- ## 2. State machine serveur — une action dans `_retry_pending` ``` start │ ▼ ┌───────────────────────────────────────────────────────────────┐ │ PENDING │ │ présent dans _replay_queues[session_id], pas encore │ │ extrait par get_next_action │ └───────────────────────┬───────────────────────────────────────┘ │ get_next_action → pop queue + write │ _retry_pending[action_id]={dispatched_at=now} │ + log [REPLAY] DISPATCH ▼ ┌───────────────────────────────────────────────────────────────┐ │ DISPATCHED │ │ dans _retry_pending, attente du REPORT │ │ invariant : action absente de _replay_queues │ └───┬───────────────────────┬───────────────────────────────────┘ │ REPORT(success=True) │ REPORT(success=False, error=*) │ verify OK ou skip │ watchdog scan age>ORPHAN_TIMEOUT │ │ │ pop _retry_pending │ resent_count < MAX_RESENDS │ completed_actions++ │ → repush head + resent++ │ current_idx++ │ → dispatched_at=0 ▼ │ → log [BUS] lea:dispatch_ ┌────────┐ │ orphan_resent │ ACKED │ │ │ (term) │ │ resent_count ≥ MAX_RESENDS └────────┘ │ → pop _retry_pending │ → log [BUS] lea:dispatch_ │ orphan_giveup │ → ABANDONED ▼ ┌─────────────────┐ watchdog repush │ ORPHAN_RESENT │ ───────────► PENDING │ (transitoire) │ (avec resent++) └─────────────────┘ REPORT(verify failed AND retry_count/cancel): purge _retry_pending par replay_id (api_stream.py:4489) _replay_queues[session_id] = [] state.status = "cancelled" ``` **Invariants serveurs :** | # | Invariant | Garanti par | |---|---|---| | I1 | Une action en `DISPATCHED` est absente de `_replay_queues` | pop atomique sous `_replay_lock` (api_stream.py:3346-3348) | | I2 | `action_id` unique dans `_retry_pending` à un instant t | clé dict + check `if action_id_sent not in _retry_pending` (api_stream.py:3354) | | I3 | `report_action_result.pop(action_id)` est idempotent | `pop(key, None)` retourne None si déjà acquitté (api_stream.py:3491) | | I4 | Cancel purge bien `_retry_pending` pour ce replay | iter `_retry_pending.items() if v["replay_id"]==replay_id` (api_stream.py:4489-4491) | | I5 | Watchdog re-check sous lock avant repush | pattern `if aid not in self._retry_pending: skip` (AXE_B1_DEEP §3) | | I6 | Pause `paused_need_help` ne distribue aucune action | `get_next_action` retourne `replay_paused=True` (api_stream.py:2951) | --- ## 3. State machine client Léa — une action côté `executor.py` ``` ┌──────────────┐ │ POLLING │ thread `_poll_loop`, every 1s (+backoff) │ (idle) │ GET /replay/next?session_id&machine_id └──────┬───────┘ │ HTTP 200 + action ≠ null ▼ ┌──────────────┐ │ RECEIVED │ data["action"] parsé └──────┬───────┘ │ ┌───────────────┴────────────────┐ │ attempt_id ∈ dedup_set ? │ ▼ ▼ OUI : SKIP NON (log warning, ack synthetique) │ ▼ ┌──────────────┐ │ EXECUTING │ execute_replay_action() │ │ ├─ pre-check window │ │ ├─ resolve target visuel │ │ ├─ click / type / key │ │ └─ screenshot_after └──────┬───────┘ │ ┌─────────────────┼──────────────────┐ │ │ │ success=True success=False system_dialog warning=None error=* détecté │ │ │ ▼ ▼ ▼ ┌─────────────────────────────────┐ │ REPORTING │ │ POST /replay/result + retry │ │ (timeout=10s, allow_redirects=False) └─────────────────┬────────────────┘ │ ┌──────────┴──────────┐ HTTP 200 fail/timeout │ │ ▼ ▼ ┌──────────┐ ┌──────────────┐ │ ACKED │ │ REPORT_RETRY │ (PAS implémenté │ + LRU │ │ (in-memory) │ v1 ; à v2) │ store │ └──────┬───────┘ │ attempt │ │ │ _id │ ▼ └──────────┘ (perte report : serveur watchdog rattrapera via orphan) Pendant POLLING : si data.replay_paused → afficher PauseDialog Pendant EXECUTING : timeout par étape gérée par execute_replay_action (resolve serveur 30s, _wait_for_screen_change 1000ms+, capture 0.5s) ``` **Invariants clients :** | # | Invariant | Garanti par | |---|---|---| | C1 | Une action reçue est TOUJOURS reportée (succès ou échec) | try/except global executor.py:2429-2503, fallback `result={success:False, error=…}` | | C2 | Un seul `poll_and_execute` à la fois | `self._replay_lock.acquire(blocking=False)` executor.py:2291 | | C3 | Pas de blocage event tray UI pendant exécution | thread dédié `_poll_loop` | | C4 | Idempotence côté action (v2) | dedup_set LRU bornée 256 entrées sur `attempt_id` (§6.2) — **À AJOUTER** | | C5 | Pause UI déclenchée uniquement sur signal serveur explicite | `data.get("replay_paused")` executor.py:2346 | --- ## 4. Contrats JSON ### 4.1. Payload DISPATCH serveur → client (GET /replay/next OU SSE event) **Cas nominal : action visuelle** | Champ | Type | Obligatoire | Description | Source code | |---|---|---|---|---| | `action` | object \| null | oui | l'action ou `null` si rien | api_stream.py:3436 | | `session_id` | str | oui | session active | api_stream.py:3438 | | `machine_id` | str | oui | machine cible | api_stream.py:3439 | | `action.action_id` | str | oui | identifiant unique step, ex. `step_4c0663941f22` | DB workflow + suffixes `_retry1/_resume` | | `action.attempt_id` | str | **À AJOUTER** | UUID hex 16, nouveau à chaque dispatch (initial OU resend) | n/a, v2 | | `action.type` | enum | oui | `click`/`type`/`key_combo`/`wait`/`scroll`/`pause_for_human`/`extract_text`/... | core/executor.py:2422 | | `action.target_spec` | object | si visuelle | `{by_text,vlm_description,anchor_image_base64,resolve_order,window_title,uia_target}` | api_stream.py:3364 | | `action.parameters` | object | dep. type | `{text,keys,duration_ms,condition,…}` | dépend du type | | `action.expected_window_before` | str | non | titre fenêtre attendue avant clic | api_stream.py:3366 | | `action.expected_window_title` | str | non | titre fenêtre attendue après clic | api_stream.py:3369 | | `action.success_strict` | bool | non | mode strict (skip OCR fuzzy) | api_stream.py:3387 | | `action.intention` | str | non | description humaine | api_stream.py:3379 | | `action.monitor_resolution` | object | oui (QW1) | `{idx,offset_x,offset_y,w,h,source}` | api_stream.py:3403 | | `action.from_node` | str | non | id node WorkflowGraph (active pre-check) | api_stream.py:3229 | | `action.dispatch_meta` | object | **À AJOUTER** | `{first_dispatched_at,resent_count,last_resent_at}` pour visibilité client | v2 | | `precheck` | object | non | résultat pre-check serveur `{match,similarity,popup_detected}` | api_stream.py:3441 | | `server_busy` | bool | non | lock occupé, retry plus tard | api_stream.py:2944 | | `replay_paused` | bool | non | replay en pause supervisée | api_stream.py:2960 | | `pause_message` | str | si paused | message à afficher dans bulle | api_stream.py:2961 | | `replay_id` | str | si paused | pour ack ciblé via /resume | api_stream.py:2962 | | `auth_detected` | bool | non | injection automatique d'actions d'auth | api_stream.py:3304 | **Exemple complet (v2 cible) :** ```json { "action": { "action_id": "step_4c0663941f22", "attempt_id": "a8f3c2d1e9b4f720", "type": "click", "target_spec": { "by_text": "Imagerie", "resolve_order": ["ocr","template","vlm"], "anchor_image_base64": "iVBORw0KGgo…", "window_title": "MOREL Catherine — Easily Assure", "uia_target": null }, "parameters": {}, "expected_window_before": "MOREL Catherine — Easily Assure", "expected_window_title": "MOREL Catherine — Easily Assure", "success_strict": true, "intention": "Cliquer onglet Imagerie", "monitor_resolution": {"idx":1,"offset_x":0,"offset_y":0,"w":2560,"h":1600,"source":"action_hint"}, "from_node": "node_tab_imagerie", "dispatch_meta": { "first_dispatched_at": 1779015600.123, "resent_count": 0, "last_resent_at": 0.0 } }, "session_id": "sess_demo_42", "machine_id": "DESKTOP-58D5CAC" } ``` **Cas pause supervisée :** ```json { "action": null, "session_id": "sess_demo_42", "machine_id": "DESKTOP-58D5CAC", "replay_paused": true, "pause_message": "Je n'y arrive pas (« Coller ou saisir... »)", "replay_id": "replay_free_68ca51ab" } ``` **Cas idle / server_busy :** ```json {"action": null, "session_id":"sess_demo_42", "machine_id":"…", "server_busy": true} ``` ### 4.2. Payload REPORT client → serveur (POST /replay/result) | Champ | Type | Obligatoire | Description | Source code | |---|---|---|---|---| | `session_id` | str | oui | | api_stream.py:628 | | `action_id` | str | oui | identifiant de l'action acquittée | api_stream.py:629 | | `attempt_id` | str | **À AJOUTER** | echo du `attempt_id` reçu (corrélation watchdog ↔ client) | v2 | | `success` | bool | oui | résultat global | api_stream.py:630 | | `error` | str \| null | dep. success | message court (`target_not_found`, `system_dialog:uac_consent`, …) | api_stream.py:631 | | `warning` | str \| null | non | `no_screen_change`/`popup_handled`/`visual_resolve_failed`/`wrong_window` | api_stream.py:632 | | `screenshot_after` | str \| null | recommandé | base64 PNG ou path | api_stream.py:634 | | `screenshot_before` | str \| null | recommandé (clic) | base64 PNG du frame pre-action (Critic) | api_stream.py:635 | | `actual_position` | object | si visuel | `{x_pct: float, y_pct: float}` coords cliquées | api_stream.py:636 | | `resolution_method` | str | si visuel | `server_resolve_hybrid`/`template_match`/... | api_stream.py:638 | | `resolution_score` | float | si visuel | 0.0–1.0 | api_stream.py:639 | | `resolution_elapsed_ms` | float | si visuel | latence cascade | api_stream.py:640 | | `target_description` | str | si fail | description humaine pour bulle pause | api_stream.py:642 | | `target_spec` | object | si fail | echo target_spec pour reconstruction | api_stream.py:643 | | `correction` | object | si pédagogique | `{x_pct,y_pct,uia_snapshot,crop_b64}` mode supervisé | api_stream.py:645 | | `system_dialog` | object | si dialog | `{category,matched_signal,matched_value,reason,context}` | api_stream.py:650 | | `needs_human` | bool | non | force pause supervisée | api_stream.py:651 | **Exemple succès :** ```json { "session_id": "sess_demo_42", "action_id": "step_4c0663941f22", "attempt_id": "a8f3c2d1e9b4f720", "success": true, "actual_position": {"x_pct":0.2305,"y_pct":0.2805}, "resolution_method": "server_resolve_hybrid_text_direct", "resolution_score": 0.80, "resolution_elapsed_ms": 412.7, "screenshot_before": "iVBO…", "screenshot_after": "iVBO…" } ``` **Exemple échec target_not_found :** ```json { "session_id": "sess_demo_42", "action_id": "step_36346c1c40b9", "attempt_id": "b9e4d3c2f0a5e831", "success": false, "error": "target_not_found", "warning": "visual_resolve_failed", "target_description": "Coller ou saisir le dossier patient", "target_spec": {"by_text":"Coller ou saisir le dossier patient", "…":"…"}, "screenshot_after": "iVBO…" } ``` **Exemple system_dialog (UAC) :** ```json { "session_id":"sess_demo_42", "action_id":"step_xxx", "attempt_id":"…", "success": false, "error": "system_dialog:uac_consent", "system_dialog": { "category": "uac_consent", "matched_signal": "window_title", "matched_value": "Contrôle de compte d'utilisateur", "reason": "UAC consent prompt blocking click", "context": "handle_popup_vlm" }, "needs_human": true, "screenshot_after": "iVBO…" } ``` ### 4.3. Payload re-dispatch (orphan resent) Identique au DISPATCH normal **sauf** `dispatch_meta` enrichi : ```json { "action": { "action_id": "step_4c0663941f22", // INCHANGÉ "attempt_id": "c2d5e8f1a3b7c049", // NOUVEAU (UUID frais) "type": "click", "...": "…", "dispatch_meta": { "first_dispatched_at": 1779015600.123, "resent_count": 1, "last_resent_at": 1779015630.987, "resend_reason": "orphan_timeout" } } } ``` **Règle :** `action_id` reste stable (preserve idempotence côté serveur via `_retry_pending`). **Seul `attempt_id` change** (permet au client de distinguer un vrai re-dispatch d'un doublon réseau). ### 4.4. Payload escalation pause supervisée Envoyé par le serveur dans la réponse au prochain poll après bascule `paused_need_help` (cf. §4.1 cas pause). Le client doit afficher la bulle et arrêter d'exécuter jusqu'à reception d'un nouveau dispatch (qui sera l'action de resume). **Payload /replay/{replay_id}/resume (POST) :** corps optionnel `{"acknowledged_check_ids": ["chk_1","chk_2"]}` (QW4). Réponse : ```json { "status": "resumed", "replay_id": "replay_free_68ca51ab", "session_id": "sess_demo_42", "remaining_actions": 12 } ``` **Payload /replay/{replay_id}/cancel (POST) :** corps vide. Réponse : ```json {"status": "cancelled", "replay_id": "…", "session_id": "…"} ``` --- ## 5. Matrice des cas limites — la table principale du document Notation : **S** = état serveur (PENDING/DISPATCHED/ORPHAN/ACKED/ABANDONED/PAUSE), **C** = état client (POLLING/RECEIVED/EXECUTING/REPORTING/IDLE/DEAD). | # | Scénario | État serveur (avant→après) | État client (avant→après) | Comportement attendu (v2 contrat) | Risque idempotence | Status code / contournement | |---|---|---|---|---|---|---| | **a** | Client coupe AVANT réception réponse `/replay/next` (bug 8 mai) | DISPATCHED→ORPHAN→PENDING→DISPATCHED | POLLING→POLLING (timeout) | Watchdog détecte age>30s, re-dispatch (`attempt_id` neuf, `resent_count=1`). Bulle "action retentée" facultative. | **Faible** : action_id stable, client n'a JAMAIS exécuté → pas de double-effet. | OK via AXE_B1_DEEP §3 | | **b** | Client coupe APRÈS réception, AVANT exécution | DISPATCHED→ORPHAN→PENDING→DISPATCHED | RECEIVED→DEAD | Watchdog re-dispatch. Si client revit, reçoit nouvelle attempt → exécute. Si client mort, watchdog finit en ABANDONED. | **Faible** : action perdue avant tout effet. | OK | | **c** | Client exécute, coupe AVANT envoi report | DISPATCHED→ORPHAN→PENDING→DISPATCHED | EXECUTING→REPORTING→DEAD (avant POST) | Watchdog re-dispatch. **2e exécution probable** côté client. Idempotence action requise (§6.3). | **ÉLEVÉ** : double clic, double saisie possibles. Cible critique : `type` → préfixer `Ctrl+A`. | dedup_set côté client (§6.2) BLOQUE la 2e si même `action_id` reçu < 256 messages | | **d** | Client report success, serveur ne reçoit pas (HTTP 502, timeout serveur côté POST) | DISPATCHED→ORPHAN→PENDING | EXECUTING→REPORTING(echec POST)→IDLE | Client doit **retenter** le POST (boucle interne avec backoff). v1 : un seul essai (executor.py:2476 timeout=10s, pas de retry). **À AJOUTER v2** : retry 3× backoff [1,3,7]s. Sinon watchdog re-dispatch + dedup_set côté client→ack synthétique. | Moyen | À ajouter retry POST côté client | | **e** | Client report success=false, retry budget restant | DISPATCHED→FAILED(verify)→PENDING(retry_N+1) | EXECUTING→REPORTING→POLLING | `_schedule_retry` crée nouvelle entrée `{action_id}_retry{N+1}` (replay_engine.py:2604), repush head. `verify_failed` ne consomme PAS le budget orphan. | Géré | OK | | **f** | Watchdog re-dispatch ALORS QUE client envoie report tardif | DISPATCHED→ORPHAN→repush en cours | EXECUTING→REPORTING(en vol) | Race window. Re-check sous lock dans watchdog `if aid not in _retry_pending: skip` (AXE_B1_DEEP §3 _scan_once). Si pop arrive 1er : repush skip. Si repush arrive 1er : pop ignoré (`no_active_replay`). | Faible | Géré par I3 + I5 | | **g** | Watchdog re-dispatch, mais client a déjà ré-exécuté (cas c+f combinés) | DISPATCHED→ORPHAN→repush | EXECUTING(1)→REPORTING(1)→RECEIVED(2)→EXECUTING(2) | dedup_set client détecte 2e `action_id` identique → log warning + ack synthétique `{success:true, warning:"already_executed"}`. **Sans dedup** : double exécution = bug applicatif. | **CRITIQUE sans dedup** | dedup_set v2 obligatoire | | **h** | Client mort silencieux (Léa crash, NoMachine freeze) | DISPATCHED→ORPHAN→PENDING→…→ABANDONED→PAUSE | DEAD | Watchdog MAX_RESENDS=2 puis ABANDONED. **Hook v1.1** : si ≥2 give-ups en 60s sur même session → bascule replay_state.status=paused_need_help + message "Léa ne répond plus" (AXE_B1_DEEP §6 R4). | OK avec hook | À ajouter hook dead_client_signal | | **i** | Serveur restart pendant actions en `_retry_pending` | TOUT en mémoire → PERTE | POLLING → reçoit 404 / 503 / ConnectionError | `_retry_pending` est in-memory. Au restart : queue vide, replay_state perdu (sauf si persisté en DB — vérifier). Client backoff exponentiel ; quand serveur revient, replay_state restaurable depuis SQLite mais `_retry_pending` non. **Décision v1** : rebuild best-effort — `_replay_states` sauvegardés en SQLite ont les `completed_actions`, on relance depuis `current_action_index+1`. Pas de rejeu des actions en vol. **Lacune connue** : si action mid-flight ; à valider avec Dom. | n/a (état perdu) | **À TRANCHER avec Dom** : persistance _retry_pending ? | | **j** | Polls clients simultanés en course (2 process Léa, ou retry rapide) | DISPATCHED (1 seul vainqueur du lock) | 2× POLLING | `_replay_lock.acquire(timeout=4.5)` : 1er gagne, 2e reçoit `{server_busy:true}` (api_stream.py:2944). Client backoff. Ordre des steps préservé (lock global). | Faible | OK | | **k** | Action arrivée 2× côté client (double-clic même bouton) | DISPATCHED (attempt_1) puis DISPATCHED (attempt_2) | RECEIVED→DEDUP_CHECK→SKIP (2e) | dedup_set client = LRU 256 sur `action_id`. 2e réception → ack synthétique success=true warning="already_executed", PAS de ré-exécution. | Bloqué côté client | dedup_set v2 | | **l** | Pause supervisée serveur déclenchée pendant action en vol | DISPATCHED→PAUSE | EXECUTING→REPORTING (résultat ignoré côté serveur ?) | Le serveur applique la pause sur le step SUIVANT (boucle `while queue` voit `paused`). L'action en vol s'achève normalement, report traité (pop+verify), puis prochain poll → `replay_paused=true`. **PAS de cancel de l'action en vol** (pas de protocole serveur→client pour interrompre une action en cours). | Faible | OK | | **m** | Cancel replay côté VWB UI pendant action en vol | DISPATCHED→cancelled (purge _retry_pending) | EXECUTING→REPORTING | Cancel purge `_retry_pending` par replay_id (api_stream.py:4489) ET vide `_replay_queues[session_id]`. Le report tardif arrive : `pop(action_id)→None` → réponse `no_active_replay` (api_stream.py:3488). Client log info. Pas d'erreur. | Faible | OK | | **n** | Cap MAX_RESENDS atteint | ORPHAN→ABANDONED | DEAD ou EXECUTING (cas g) | Log `[BUS] lea:dispatch_orphan_giveup`. v1 = action perdue silencieusement, replay continue (peut bloquer step suivant si dépendance). **Politique v2 :** si action critique (type ∈ {click,type,t2a_decision}) → bascule `paused_need_help` immédiatement avec message "Léa n'a pas répondu, vérifie". Si non critique (wait,scroll) → log seul, continue. | n/a | **À TRANCHER** : seuil par type ? | | **o** | Action non-visuelle (`extract_text`, `t2a_decision`) vs visuelle (`click`) | Non-visuelle : pas de DISPATCHED, exécutée *server-side* dans la même boucle `get_next_action` (api_stream.py:3132-3197) | Jamais reçue par le client | **Contrats distincts** : non-visuelles n'entrent JAMAIS dans `_retry_pending`. Le watchdog n'a rien à scanner pour elles. Si extract_text plante (Ollama 503), `queue.pop(0)` + log warning + continue (api_stream.py:3195) → action serveur perdue silencieusement. **Risque pas couvert par watchdog.** | n/a | **À TRANCHER** : retry serveur sur actions serveur ? séparé du watchdog actions visuelles | | **p** | Workflow se termine alors qu'action est encore en `_retry_pending` | DISPATCHED en cours → workflow.completed | n/a | `_replay_states[replay_id].status = "completed"`. Si une action est encore en `_retry_pending`, le watchdog la verra orphan ; au resend, `get_next_action` ne trouvera pas de `owning_replay` (status `running` requis ligne 2974) → queue vide retournée. **Fuite mémoire** : entrée `_retry_pending` jamais purgée tant que pas de cancel ou age > MAX_RESENDS. **Mitigation** : ajouter purge `_retry_pending` sur transition vers completed/error/failed (analogue à cancel ligne 4489). | Faible (mais leak) | **À AJOUTER v2** : purge à la complétion | | **q** | Précheck "wait" injecté (popup détectée) — n'est PAS une action workflow | Pas dans `_retry_pending` (action synthétique) | RECEIVED→EXECUTING(wait 2000ms)→REPORTING(success=true) | wait_action a un `action_id=precheck_wait_<6hex>` non stockée côté serveur. Le report arrive : `pop(action_id)→None`, action ignorée gracieusement. | Faible | OK | | **r** | Replay paused, client continue à poller | PAUSE | POLLING reçoit `replay_paused:true` à chaque tick | Client affiche bulle 1 fois (dedup sur `_last_pause_msg_shown` executor.py:2351), continue à poller. CPU loss négligeable. | Faible | OK | | **s** | Reverse-proxy NPM bufferise SSE | DISPATCHED, event jamais reçu | POLLING/SSE silencieux | `X-Accel-Buffering: no` côté server response. Ping 15s force flush. AXE_B1 §4 §8 risques. | Faible avec headers | OK avec headers | | **t** | NoMachine timeout idle (>60s) | DISPATCHED dormant | SSE→reconnect via Last-Event-ID | sseclient-py auto-reconnect, `Last-Event-ID` header repris au reconnect → serveur peut sauter les events déjà acquittés. v1 polling : pas de Last-Event-ID, juste réacheminement via watchdog. | Faible | OK | | **u** | Bearer token expire/révoqué pendant un replay actif | DISPATCHED en attente | POLLING reçoit 401 | Client doit re-auth (hors scope v1 : tokens longue durée). v1 : crash + tray notification. Watchdog côté serveur continue à scanner — actions partent en ABANDONED après MAX. | Faible | hors scope v1 | --- ## 6. Sémantique d'idempotence ### 6.1. Couches d'idempotence | Couche | Mécanisme | Effet | Implémenté ? | |---|---|---|---| | **Serveur — pop sur report** | `_retry_pending.pop(action_id, None)` retourne None silencieux si déjà acquitté | report en double n'augmente pas `completed_actions` 2× | ✅ api_stream.py:3491 | | **Serveur — re-check watchdog** | `if aid not in _retry_pending: skip` sous lock | re-dispatch annulé si report arrivé entre snapshot et repush | ✅ AXE_B1_DEEP §3 | | **Serveur — cancel purge** | itération `_retry_pending` par replay_id | aucun ghost-resend après cancel | ✅ api_stream.py:4489 | | **Action — `action_id` stable** | identifiant unique step (`step_` puis suffixes `_retry{N}` ou `_resume`) | clé du `pop` côté serveur, clé du dedup côté client | ✅ DB workflow + replay_engine.py:2609 | | **Action — `attempt_id` rotatif** | UUID nouveau à chaque DISPATCH (initial + chaque resend) | distingue un re-dispatch légitime d'un doublon réseau, permet stats orphan | ❌ **À AJOUTER v2** | | **Client — dedup_set LRU 256** | `set` bornée de `(action_id)` ou `(action_id, attempt_id)` récemment exécutés | bloque ré-exécution en cas g/k | ❌ **À AJOUTER v2 obligatoire** | | **Action — idempotence intrinsèque** | clear field avant `type`, idempotence native du `click` sur tab actif | minimise dégât en cas de double exécution résiduelle | ⚠ **À documenter dans VWB**, pas dans code | ### 6.2. Spec dedup_set client (v2) ```python # agent_v1/core/executor.py — à ajouter from collections import OrderedDict class ActionDedupSet: """LRU bornée d'action_id récemment exécutées. Bloque ré-exécution si action arrive 2 fois (orphan resent + double réseau). """ def __init__(self, max_size: int = 256): self._store: OrderedDict[str, float] = OrderedDict() # action_id → ts self._max = max_size def seen(self, action_id: str) -> bool: if action_id in self._store: # Touch (LRU) self._store.move_to_end(action_id) return True return False def mark(self, action_id: str) -> None: self._store[action_id] = time.time() self._store.move_to_end(action_id) if len(self._store) > self._max: self._store.popitem(last=False) ``` **Usage dans `poll_and_execute_inner` AVANT `execute_replay_action` :** ```python if self._dedup.seen(action.get("action_id","")): logger.warning(f"[DEDUP] action {action.get('action_id')} déjà exécutée — ack synthétique") self._post_synthetic_ack(action, server_url, replay_result_url, success=True, warning="already_executed") return True self._dedup.mark(action.get("action_id","")) # … execute_replay_action(action) … ``` ### 6.3. Idempotence intrinsèque par type d'action | Type | Idempotent nativement ? | Mitigation si exécuté 2× | |---|---|---| | `click` sur tab/bouton actif | OUI (le tab reste actif) | aucune | | `click` sur bouton "Submit"/"Valider" | NON (double formulaire) | **dedup_set CRITIQUE** + dialog confirm côté app | | `type` texte | NON (double saisie) | préfixer `Ctrl+A` (clear) + dedup_set | | `keyboard_shortcut` Ctrl+S | dep. (1 sauvegarde = 1 dialog) | dedup_set | | `keyboard_shortcut` Ctrl+V | NON (double collage) | dedup_set + clear avant | | `scroll` | OUI mais déplace 2× | tolérable, dedup_set conseillé | | `wait` | OUI | aucun risque | | `extract_text` (server-side) | OUI (lecture pure) | n/a | | `t2a_decision` (server-side, LLM) | OUI mais re-coût LLM ($/temps) | retry serveur, pas client | --- ## 7. Timeouts et seuils | Nom | Défaut | Env var | Effet | Source | |---|---:|---|---|---| | `client_poll_timeout` | 30 s | non, en dur | `requests.get(/replay/next, timeout=30)` côté Léa | executor.py:2320 | | `client_report_timeout` | 10 s | non, en dur | `requests.post(/replay/result, timeout=10)` | executor.py:2480 | | `client_resolve_timeout` | 30 s | non, en dur | appel serveur `/resolve_target` | executor.py:1898 | | `server_replay_lock_timeout` | 4.5 s | non, en dur | `_async_replay_lock(timeout=4.5)` → 503 ou server_busy | api_stream.py:539, 2938 | | `server_action_server_side_timeout` | 180 s | non, en dur | `asyncio.wait_for(extract_text/t2a, 180)` | api_stream.py:3141 | | `server_paste_and_execute_timeout` | 30 s | non, en dur | paste+execute ydotool | api_stream.py:3192 | | `server_precheck_timeout` | 0.5 s | non, en dur | CLIP embed pre-check | api_stream.py:3250 | | `heartbeat_max_age` | varie | `RPA_HEARTBEAT_MAX_AGE_SECONDS` | utilité pre-check | api_stream.py:3235 | | `WATCHDOG_SCAN_INTERVAL_S` | 10 s | `RPA_WATCHDOG_SCAN_INTERVAL_S` | période scan orphan | AXE_B1_DEEP §11 | | `WATCHDOG_ORPHAN_TIMEOUT_S` | 30 s | `RPA_WATCHDOG_ORPHAN_TIMEOUT_S` | age sans report → orphan | AXE_B1_DEEP §11 | | `WATCHDOG_MAX_RESENDS` | 2 | `RPA_WATCHDOG_MAX_RESENDS` | give-up après N resends | AXE_B1_DEEP §11 | | `WATCHDOG_REPUSH_POSITION` | `head` | `RPA_WATCHDOG_REPUSH_POSITION` | head/tail | AXE_B1_DEEP §11 | | `WATCHDOG_ENABLED` | `1` | `RPA_WATCHDOG_ENABLED` | kill-switch | AXE_B1_DEEP §11 | | `REPLAY_STATE_TTL_SECONDS` | varie | `RPA_REPLAY_STATE_TTL` | purge states finis | api_stream.py:726 | | `MAX_REPLAY_STATES` | n | en dur | borne `_replay_states` | api_stream.py:735 | | `MAX_RETRIES_PER_ACTION` | 3 | en dur | budget retry métier `_schedule_retry` | replay_engine.py:2591 | | `_poll_backoff_min` | varie | n/a | reset après HTTP 200 | executor.py:2334 | | `_poll_backoff_max` | varie | n/a | plafond backoff exponentiel | executor.py:2326 | | `_poll_backoff_factor` | 2.0 | n/a | facteur multiplicatif | executor.py:2326 | | `SSE_PING_INTERVAL_S` (cible) | 15 | env futur | heartbeat SSE | AXE_B1 §4 | **Cohérence des seuils :** - `client_poll_timeout (30s) > server_replay_lock_timeout (4.5s)` → OK, le client attend bien la réponse server_busy. - `server_action_server_side_timeout (180s) > client_poll_timeout (30s)` → SI extract_text dure 35s sans dispatcher d'action visuelle entre temps, le client coupe MAIS le serveur continue ; au prochain poll le serveur a fini, dispatche l'action visuelle suivante. **Pas de perte tant que l'action visuelle dispatch est rapide après extract_text.** Bug 8 mai = extract_text + dispatch click dans la MÊME réponse → 5s timeout dépassé → fix `timeout=30` adopté. - `WATCHDOG_ORPHAN_TIMEOUT_S (30s) > client_poll_timeout (30s)` → frontière dangereuse. **Recommandation : remonter à 45s** pour laisser le temps au client de retenter au moins 1 poll naturellement avant que le watchdog résende. --- ## 8. Transitions vers pause supervisée et resume ### 8.1. Déclencheurs `status = "paused_need_help"` | Déclencheur | Source | État avant | État après | Champ enrichi | |---|---|---|---|---| | `pause_for_human` en mode supervised ou safety_checks présents | api_stream.py:3066-3111 | running | paused_need_help | `safety_checks`, `pause_payload`, `failed_action.reason="user_request"` | | Report `system_dialog:*` (UAC/CredUI/SmartScreen) | api_stream.py:3785-3870 | running | paused_need_help | `failed_action.reason="system_dialog"`, message contextualisé | | Report `warning="wrong_window"` | api_stream.py:3872-3920 | running | paused_need_help | `failed_action.reason="wrong_window"` | | Report `success=false` + `error="target_not_found"` après MAX_RETRIES_PER_ACTION (3) | api_stream.py:3949-4030 | running | paused_need_help | `failed_action.target_description` | | Hook v1.1 dead client signal (2+ giveups en 60s) | À AJOUTER (AXE_B1_DEEP §6 R4) | running | paused_need_help | `failed_action.reason="dead_client"` | | Hook v2 (cas n) : N orphan giveup sur action type critique | À DÉCIDER avec Dom | running | paused_need_help | `failed_action.reason="orphan_max_resends"` | ### 8.2. État de `_retry_pending` au moment de la pause - **Pause via `pause_for_human`** : aucune action en vol (pause arrive *avant* dispatch). - **Pause via report failed** : l'action qui a déclenché la pause vient d'être `pop`pée. `_retry_pending` est **vide pour cet action_id** (déjà acquittée). Aucune purge supplémentaire nécessaire. - **Pause via watchdog hook (v1.1)** : `_retry_pending` peut contenir des entrées orphelines avec age > MAX. **Politique :** purger en transition (à ajouter dans le hook). ### 8.3. /resume — reconstruction de l'action `resume_replay` (api_stream.py:4361-4474) : 1. Vérifie state.status == `paused_need_help` (sinon 409). 2. Vérifie acquittement safety_checks required (sinon 400). 3. Reset state : `status="running"`, `failed_action=None`, `pause_message=None`, `safety_checks=[]`. 4. Reconstruit l'action : - Priorité 1 : `failed_action.original_action` si présent. - Priorité 2 : `_retry_pending.pop(failed_action.action_id, {}).get("action")`. - Priorité 3 : minimum `{action_id, type, target_spec, visual_mode}`. 5. Nouveau `action_id = "{original}_resume"`. 6. Enregistre dans `_retry_pending[resume_id] = {action,retry_count:0,replay_id,reason:"resume_after_pause"}`. 7. Insère en tête `_replay_queues[session_id]`. **Lacune v1 :** le nouveau `action_id` (`_resume`) **n'a pas de `attempt_id`** explicite. Au prochain dispatch, le watchdog démarre le compteur à 0. Cohérent. ### 8.4. Event bus `[BUS]` | Event | Quand | Payload (log structuré) | Source | |---|---|---|---| | `[BUS] lea:safety_checks_generated` | Pause `pause_for_human` avec checks | `replay= count=N sources=[…]` | api_stream.py:3081 | | `[BUS] lea:monitor_routed` | Dispatch action visuelle (résolution monitor) | `replay= action= idx=N source=<…>` | api_stream.py:3419 | | `[BUS] lea:dispatch_orphan_resent` (v1.1) | Watchdog repush | `action_id=X resent=N/MAX age=Ts session machine replay` | AXE_B1_DEEP §3 | | `[BUS] lea:dispatch_orphan_giveup` (v1.1) | Watchdog abandon | `action_id=X resent=N age_total=Ts session machine replay` | AXE_B1_DEEP §3 | | `[BUS] lea:dead_client_signal` (v2) | Hook ≥2 giveups/60s | `session= dead_count=N period=60s` | À AJOUTER | Tous les events sont consommables via `journalctl --user -u rpa-streaming -f | grep '\[BUS\]'`. Pas de bus pub/sub réel (pattern QW1/QW4 = log structuré). --- ## 9. Compatibilité polling actuel ↔ futur SSE Le contrat des §4 / §5 / §6 / §8 est **invariant** par rapport au transport. Voici ce qui change vs ce qui ne change pas : | Aspect | Polling (v1 actuel) | SSE (cible AXE_B1) | Change ? | |---|---|---|---| | Endpoint dispatch | `GET /api/v1/traces/stream/replay/next` (1 réponse JSON) | `GET /api/v1/traces/stream/replay/events` (stream `text/event-stream`) | OUI | | Format payload action | JSON dans body | JSON dans `data:` field d'un `ServerSentEvent` (event=`action`) | NON (même schéma) | | Endpoint report | `POST /api/v1/traces/stream/replay/result` | identique | NON | | ID corrélation | `action_id` + (v2) `attempt_id` | identique + `id:` SSE = `action_id` | NON | | Détection déco client | Indirecte (pas de poll suivant) | `await request.is_disconnected()` immédiat | OUI (gain) | | Détection déco serveur | Timeout client 30s | `EventSource.onerror` + reconnect natif | OUI (gain) | | Reprise après reconnect | Pas de Last-Event-ID, watchdog seul | `Last-Event-ID` header automatique côté sseclient-py | OUI (gain) | | Watchdog `_retry_pending` | **ACTIF** | **ACTIF** (ceinture+bretelles, cf. AXE_B1_DEEP §12) | NON | | dedup_set client | **ACTIF v2** | **ACTIF v2** | NON | | `_replay_lock` serveur | Tient pendant exécution serveur (extract_text…) | Idem (les actions server-side restent dans la même boucle) | NON | | Bulle pause client | Reçue via `replay_paused:true` au prochain poll | Reçue via event `event=paused` ou via `replay_paused:true` dans event `action` | NON (même UX) | **Flag de bascule :** `RPA_REPLAY_TRANSPORT=poll|sse` côté client (executor.py choisit poll vs `replay_subscriber.py`) et serveur (les 2 endpoints coexistent — pas de mutual exclusion). Permet rollback 1-ligne. **Garantie de migration :** un client v2 polling et un client v2 SSE consomment **strictement le même contrat de message**. Le watchdog serveur scanne `_retry_pending` indépendamment du transport. Tous les invariants I1–I6 et C1–C5 tiennent identiquement. **Seul écart pratique :** en SSE, `WATCHDOG_ORPHAN_TIMEOUT_S` peut descendre à 15s (déconnexion détectée plus tôt). En polling, garder 30s (laisser une chance au polling naturel). --- ## 10. Précédents externes — fiches courtes ### 10.1. AWS SQS — visibility timeout - **Contrat :** message reçu devient *invisible* pour `VisibilityTimeout` secondes (défaut 30s). Si pas `DeleteMessage` avant expiration → redevient visible, redélivrable. - **Modèle :** at-least-once delivery (standard queues), exactly-once (FIFO via `MessageDeduplicationId`). - **Idempotence :** **côté consommateur obligatoire** (chez AWS « your processing logic must be idempotent »). DLQ pour les empoisonnés. - **Cap :** `ChangeMessageVisibility` pour étendre dynamiquement. Limite dure 12h. - **Notre mapping :** `_retry_pending[action_id] = {dispatched_at}` = visibility timeout in-memory. `WATCHDOG_ORPHAN_TIMEOUT_S = 30s` = `VisibilityTimeout`. `WATCHDOG_MAX_RESENDS = 2` = `maxReceiveCount` avant DLQ. ABANDONED = DLQ équivalent (mais sans queue physique, juste log). - **Source :** [SQS visibility timeout doc](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html) (consulté 2026-05-24) ### 10.2. NATS JetStream — pull consumer ack - **Contrat :** `AckExplicit` par défaut. `AckWait` (défaut 30s) = délai avant redélivrance. `MaxDeliver` = N attempts max. `MaxAckPending` = window flow control (défaut 1000). - **NAK :** redélivrance immédiate (ou `nakWithDelay`). - **Backoff :** liste `[5s, 30s, 300s, …]` qui *override* `AckWait`. Si liste plus courte que `MaxDeliver`, dernier délai répété. - **Notre mapping :** `AckWait` = `WATCHDOG_ORPHAN_TIMEOUT_S`. `MaxDeliver` = `WATCHDOG_MAX_RESENDS+1`. **Pas de NAK explicite chez nous** : un report success=false suit la voie retry métier (`_schedule_retry`), pas la voie transport. **Pas de backoff** dans la v1 du watchdog (justification AXE_B1_DEEP §5 : démo médicale, réactivité prime). Adoptable si besoin. - **Source :** [NATS JetStream Consumers doc](https://docs.nats.io/nats-concepts/jetstream/consumers) (consulté 2026-05-24) ### 10.3. Skyvern — `execute_step` + `handle_failed_step` - **Contrat :** boucle récursive `execute_step` (forge/agent.py lignes 1094–1577). À chaque step : - `step.status == failed` → `handle_failed_step()` retourne *next step* (retry) ou *None* (terminal). - `step.status == completed` → `handle_completed_step()` décide advance vs verify vs finalize. - **Cap :** `max_steps_per_run` global, hiérarchie task → org → settings (ligne 1169-1176). - **Idempotence :** PR récente a *retiré* le retry interne du `fail_task` (transition status uniquement). Skyvern délègue le retry au LLM via re-emit du prochain action_use. - **Différence avec nous :** Skyvern = monolithe local (browser CDP), pas de transport HTTP entre dispatcher et exécuteur. Notre cas nécessite un layer transport en plus, d'où `_retry_pending` qui n'a pas d'équivalent direct. - **Source :** [Skyvern agent.py main](https://github.com/Skyvern-AI/skyvern/blob/main/skyvern/forge/agent.py), [PR #434 better catch exceptions](https://github.com/Skyvern-AI/skyvern/pull/434) ### 10.4. browser-use — action_id + idempotency guard - **Contrat :** `max_failures` config (défaut 3). Action cache court-terme keyed sur `(command, selector, value)` pour éviter side-effects dupliqués si retry rapide. - **Pattern d'idempotency key :** « *deterministic key before execution, generated from workflow run ID, step index, and action type* » (cf. mightybot blog). - **Notre mapping :** `action_id` déterministe = `step_` + suffixes. dedup_set client = équivalent action cache court-terme. - **Différence :** browser-use est intra-process (loop Python contrôle Chromium via CDP local). Notre cas inter-process inter-machine. - **Source :** [browser-use AGENTS.md](https://github.com/browser-use/browser-use/blob/main/AGENTS.md), [Idempotent AI agents — buildmvpfast 2026](https://www.buildmvpfast.com/blog/idempotent-ai-agent-retry-safe-patterns-production-workflow-2026) ### 10.5. Anthropic Computer Use SDK — tool_use_id binding - **Contrat :** chaque `tool_use` retourné par Claude a un `id` ; le code applicatif doit retourner un `tool_result` avec `tool_use_id` identique (loop.py lignes 234-254). - **Retry :** uniquement au niveau API (`max_retries=4` côté client Anthropic, ligne 182). **Pas de retry au niveau tool execution** — c'est le modèle qui re-décide au prochain tour. - **Idempotence :** non garantie par le SDK. Délégué à l'application (« deduplication or idempotency key handling visible in this loop : none »). - **Notre mapping :** `tool_use_id` ↔ `action_id`. Mais notre boucle est *server-driven* (queue d'actions pré-compilée par VWB), pas LLM-driven. Plus déterministe, donc plus simple à idempotenter. - **Source :** [computer-use-demo/loop.py main](https://github.com/anthropics/claude-quickstarts/blob/main/computer-use-demo/computer_use_demo/loop.py), [Tool use Claude API docs](https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview) ### 10.6. Playwright MCP — SSE remote transport - **Contrat :** transport stdio (local) OU HTTP/SSE (remote). Tools/list au handshake, tool/call par event SSE descendant, tool/result POST remontant. - **Issue connue 2026 :** « SSE stream disconnected » après idle (cline/cline #8367). Mitigation = ping applicatif. - **Timeout :** 30s par défaut sur CDP endpoint connect. - **Notre mapping :** très proche du pattern cible AXE_B1 §4 (SSE descendant + POST ack). Confirme robustesse du choix techno. Précaution : prévoir reconnect natif (sseclient-py). - **Source :** [microsoft/playwright-mcp](https://github.com/microsoft/playwright-mcp), [cline issue #8367 SSE disconnect](https://github.com/cline/cline/issues/8367) ### 10.7. Synthèse comparative | Système | ID corrélation | Visibility/Ack timeout | Max retry transport | Dedup client | Modèle delivery | |---|---|---|---|---|---| | **AWS SQS std** | MessageId + ReceiptHandle | VisibilityTimeout 30s | maxReceiveCount (DLQ) | obligatoire app | at-least-once | | **NATS JetStream** | StreamSeq + ConsumerSeq | AckWait 30s | MaxDeliver | obligatoire app | at-least-once | | **Skyvern** | step.step_id | n/a (monolithe) | max_steps_per_run | n/a | exactly-once local | | **browser-use** | (cmd, selector, value) | n/a | max_failures=3 | action cache | exactly-once local | | **Anthropic CU** | tool_use_id | n/a | max_retries client (API) | non garanti | exactly-once par tour | | **Playwright MCP** | request_id | 30s CDP | n/a (LLM décide) | non garanti | best-effort | | **Nous (v2 cible)** | `action_id` + `attempt_id` | ORPHAN_TIMEOUT 30s | MAX_RESENDS=2 | dedup_set 256 LRU | at-least-once + dedup → effectif exactly-once | --- ## 11. Sources ### Code interne (lecture seule, lignes vérifiées 2026-05-24) - `agent_v0/server_v1/api_stream.py:520-559` — `_replay_lock`, `_async_replay_lock`, `_replay_queues`, `_replay_states`, `_machine_replay_target` - `agent_v0/server_v1/api_stream.py:626-651` — `ReplayResultReport` Pydantic schema - `agent_v0/server_v1/api_stream.py:2906-3443` — `get_next_action` (DISPATCH path) - `agent_v0/server_v1/api_stream.py:3132-3197` — actions server-side `extract_text/t2a_decision/...` - `agent_v0/server_v1/api_stream.py:3354-3359` — création `_retry_pending` (à enrichir AXE_B1_DEEP §4.1) - `agent_v0/server_v1/api_stream.py:3446-3491` — `report_action_result`, pop idempotent - `agent_v0/server_v1/api_stream.py:3785-3870` — bascule `paused_need_help` sur system_dialog - `agent_v0/server_v1/api_stream.py:4361-4474` — `resume_replay` + safety_checks - `agent_v0/server_v1/api_stream.py:4477-4494` — `cancel_replay` + purge - `agent_v0/server_v1/replay_engine.py:2583-2642` — `_schedule_retry` (retry métier, distinct du retry transport) - `agent_v0/agent_v1/core/executor.py:2275-2503` — `poll_and_execute` + `_poll_and_execute_inner` - `agent_v0/agent_v1/core/executor.py:2308-2321` — `requests.get(/replay/next, timeout=30)` (fix 8 mai) - `agent_v0/agent_v1/core/executor.py:2476-2501` — `requests.post(/replay/result, timeout=10)` - `agent_v0/agent_v1/network/streamer.py:1-120` — streaming events/screenshots (canal séparé du replay) ### Docs internes - `docs/recherche/AXE_B1_REPLAY_TRANSPORT.md` (2026-05-23) — choix SSE vs WebSocket, pseudo-code endpoint - `docs/recherche/AXE_B1_DEEP_WATCHDOG.md` (2026-05-24) — implémentation watchdog complète - `docs/REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md` — diagnostic 9 actions perdues, racine du contrat - `docs/SYNTHESE_TECHNOS_REPLAY_2026-05-23.md` §4 — synthèse replay - `docs/LESSONS_LEARNED_GHT_2026-05.md` — bugs P0 post-démo - `docs/SMOKE_TEST_FINALIZE_REPLAY_2026-05-20.md` — contrat finalize → replay ### Sources externes (consultées 2026-05-24) **Patterns queue / visibility timeout / idempotence** - [Amazon SQS visibility timeout](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html) - [Amazon SQS exactly-once processing FIFO](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/FIFO-queues-exactly-once-processing.html) - [Amazon SQS message deduplication ID](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/using-messagededuplicationid-property.html) - [NATS JetStream Consumers](https://docs.nats.io/nats-concepts/jetstream/consumers) - [NATS JetStream Model Deep Dive](https://docs.nats.io/using-nats/developer/develop_jetstream/model_deep_dive) - [How to Handle SQS Visibility Timeout (oneuptime 2026-01-27)](https://oneuptime.com/blog/post/2026-01-27-sqs-message-visibility-timeout/view) - [Achieving idempotency in AWS serverless (Albaqali)](https://qasimalbaqali.medium.com/achieving-idempotency-in-the-aws-serverless-space-d0671a521479) **Frameworks RPA / Computer Use** - [Skyvern forge/agent.py main](https://github.com/Skyvern-AI/skyvern/blob/main/skyvern/forge/agent.py) — `execute_step` 1094-1577 - [Skyvern webeye/actions/handler.py](https://github.com/Skyvern-AI/skyvern/blob/main/skyvern/webeye/actions/handler.py) - [Skyvern PR #434 better catch exceptions](https://github.com/Skyvern-AI/skyvern/pull/434) - [Skyvern retry run webhook docs](https://www.skyvern.com/docs/api-reference/api-reference/agent/retry-run-webhook) - [Anthropic computer-use-demo loop.py](https://github.com/anthropics/claude-quickstarts/blob/main/computer-use-demo/computer_use_demo/loop.py) — dispatch 234-254 - [Anthropic Tool use overview docs](https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview) - [Microsoft Playwright MCP](https://github.com/microsoft/playwright-mcp) - [Cline SSE disconnect issue #8367](https://github.com/cline/cline/issues/8367) - [browser-use AGENTS.md main](https://github.com/browser-use/browser-use/blob/main/AGENTS.md) - [browser-use issue #3615 agent not stopping](https://github.com/browser-use/browser-use/issues/3615) **Patterns idempotence agents AI** - [Idempotent AI Agents — buildmvpfast 2026](https://www.buildmvpfast.com/blog/idempotent-ai-agent-retry-safe-patterns-production-workflow-2026) - [Fault-Tolerant AI Agent Pipelines — MightyBot](https://mightybot.ai/blog/fault-tolerant-ai-agent-pipelines/) - [Action Verification and Retries in LLM Agent Loops — ingramhaus](https://ingramhaus.com/action-verification-and-retries-in-llm-agent-execution-loops) - [Idempotency in Distributed Systems — aloknecessary](https://aloknecessary.github.io/blogs/idempotency-distributed-systems/) - [Stripe API idempotent requests](https://docs.stripe.com/api/idempotent_requests) **Transport SSE / FastAPI** - [sse-starlette GitHub](https://github.com/sysid/sse-starlette) - [FastAPI lifespan events](https://fastapi.tiangolo.com/advanced/events/) - [Stop streaming response when client disconnects (FastAPI #7572)](https://github.com/fastapi/fastapi/discussions/7572) --- ## 12. Décisions non tranchables sans Dom (sortir explicitement du contrat v2) Ces points sont identifiés mais demandent un arbitrage produit : | # | Cas limite | Question | Recommandation Claude | |---|---|---|---| | D1 | Cas (i) — restart serveur pendant actions en `_retry_pending` | Faut-il persister `_retry_pending` (SQLite) pour rebuild ? Ou accepter perte transport au restart ? | **Accepter perte v2** : le restart serveur est volontaire (`systemctl restart`), Pauline relance le replay depuis VWB. Surcoût persistance > bénéfice. | | D2 | Cas (n) — politique abandon | Bascule en `paused_need_help` après MAX_RESENDS atteint ? Pour quels types d'action ? | **OUI pour `click`/`type`/`t2a_decision`** (critiques). **Log seul pour `wait`/`scroll`** (continuer). À ajouter dans hook watchdog v1.1. | | D3 | Cas (o) — actions server-side perdues | Retry serveur sur `extract_text` qui timeout ? Watchdog dédié actions server-side ? | **Différer** : v1 = un seul try + log warning, comportement actuel acceptable. v2 envisageable si bench Ollama montre instabilités fréquentes. | | D4 | Cas (p) — purge `_retry_pending` à la complétion workflow | Ajouter purge automatique en transition vers `completed/error/failed` ? | **OUI**, simple à ajouter analogue à cancel (api_stream.py:4489). | | D5 | dedup_set côté client | Implémenter v2 obligatoire ? Quelle taille LRU ? Inclure `attempt_id` ou juste `action_id` ? | **OUI obligatoire v2.** Taille 256 (couvre largement les workflows GHT 50 steps). Key = `action_id` seul (le `attempt_id` n'apporte rien côté dedup — l'objectif est de bloquer la double exécution même action). | | D6 | `attempt_id` côté serveur | Générer UUID à chaque dispatch (initial + resend) ? Stocker l'historique ? | **OUI v2.** Génération à chaque DISPATCH dans `get_next_action`. Pas d'historique nécessaire (logs structurés `[BUS] lea:dispatch_orphan_resent` suffisent). | | D7 | Migration backward-compat | Si client v1 (sans dedup_set, sans `attempt_id` echo) parle à serveur v2, casse-t-il ? | **NON** : `attempt_id` est optionnel côté serveur (toléré absent). dedup_set est purement défensif côté client. Migration progressive sans rupture. | | D8 | Cas (l) — protocole d'interruption serveur→client d'une action en vol | Ajouter mécanisme `cancel_in_flight` ? | **NON v1, v2** : pas nécessaire pour la démo. Pause supervisée sur step suivant suffit. | --- ## 13. Liens vers autres specs en cours - **`spec_validator`** (à venir) — un Validator strict (sémantique post-action) ne peut être fiable que si toutes les actions arrivent. **SPEC_TRANSPORT est prérequis logique de spec_validator.** Le contrat REPORT.warning peut s'enrichir de codes de Validator (`semantic_fail`, `expected_text_not_found`) sans casser ce contrat. - **`spec_popups`** (à venir) — la détection popup côté serveur (pre-check) ET côté client (DialogHandler) émet des actions synthétiques `wait` ou des reports `warning="popup_handled"`. **Cas (q) du §5 documente la non-interférence.** Le contrat dialog/popup s'imbrique sur les mêmes endpoints sans extension. - **AXE_B2 (Validator)** : couvre le côté `verify_action` + Critic sémantique (déjà partiellement codé dans `replay_verifier.py`). À spécifier en parallèle. - **AXE_B4 (ORA Observe-Reason-Act)** : pousse aussi dans `_replay_queues` → bénéficie gratuitement du watchdog et du contrat. - **AXE_D2 (Dialog/Popup)** : `system_dialog` + `wrong_window` + DialogHandler — branches bascule pause supervisée déjà tracées §8.1. --- *Document de spécification contractuelle. Lecture seule sur code, aucune modification. À valider par Dom avant implémentation v2 (dedup_set, attempt_id, hooks watchdog).*