docs: add POC specs, handoffs, and research notes

This commit is contained in:
Dom
2026-06-02 16:28:34 +02:00
parent 18ed6cb751
commit f2e9aac6b7
86 changed files with 27615 additions and 25 deletions

View File

@@ -0,0 +1,766 @@
# 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).*