Files
rpa_vision_v3/docs/recherche/SPEC_TRANSPORT_CONTRAT.md

60 KiB
Raw Blame History

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<MAX_RETRIES_PER_ACTION):
            _schedule_retry crée action_id_retry{N+1}, repush head
            le nouveau action_id entre en PENDING (nouvelle entrée)
            l'ancien action_id sort de DISPATCHED via pop

        REPORT(system_dialog | wrong_window | target_not_found):
            ──► PAUSE_NEED_HELP (replay_state.status)
            _retry_pending non purgé sur-le-champ (peut être réutilisé par /resume)

        ABANDONED:
            entrée _retry_pending supprimée
            ──► si politique = pause sur Nème giveup → PAUSE_NEED_HELP
            sinon : action perdue, replay continue sur l'action suivante

        CANCELLED (POST /replay/<id>/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) :

{
  "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 :

{
  "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 :

{"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.01.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 :

{
  "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 :

{
  "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) :

{
  "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 :

{
  "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 :

{
  "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 :

{"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_<hex> 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)

# 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 :

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 poppé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=<id> count=N sources=[…] api_stream.py:3081
[BUS] lea:monitor_routed Dispatch action visuelle (résolution monitor) replay=<id> action=<id> 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=<S> 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 I1I6 et C1C5 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 (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 (consulté 2026-05-24)

10.3. Skyvern — execute_step + handle_failed_step

  • Contrat : boucle récursive execute_step (forge/agent.py lignes 10941577). À chaque step :
    • step.status == failedhandle_failed_step() retourne next step (retry) ou None (terminal).
    • step.status == completedhandle_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, PR #434 better catch exceptions

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_<hex_workflow> + 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, Idempotent AI agents — buildmvpfast 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_idaction_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, Tool use Claude API docs

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, cline issue #8367 SSE disconnect

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-651ReplayResultReport Pydantic schema
  • agent_v0/server_v1/api_stream.py:2906-3443get_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-3491report_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-4474resume_replay + safety_checks
  • agent_v0/server_v1/api_stream.py:4477-4494cancel_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-2503poll_and_execute + _poll_and_execute_inner
  • agent_v0/agent_v1/core/executor.py:2308-2321requests.get(/replay/next, timeout=30) (fix 8 mai)
  • agent_v0/agent_v1/core/executor.py:2476-2501requests.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

Frameworks RPA / Computer Use

Patterns idempotence agents AI

Transport SSE / FastAPI


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).