Files
rpa_vision_v3/docs/recherche/SPEC_TRANSPORT_CONTRAT.md

767 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) :**
```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.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 :**
```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_<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)
```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=<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](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 10941577). À 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_<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](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).*