60 KiB
SPEC TRANSPORT — Contrat dispatch / ack / retry / orphan / resume
Date : 2026-05-24 Auteur : Claude (recherche dispatchée, lecture seule sur code) Statut : spécification contractuelle. Aucune modif code. Toutes les sections sont en tables ou state machines, prose minimale. Pré-requis :
docs/recherche/AXE_B1_REPLAY_TRANSPORT.md(transport SSE/WebSocket — choix techno)docs/recherche/AXE_B1_DEEP_WATCHDOG.md(implémentation watchdog)docs/REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md(bug 9 actions perdues)docs/SYNTHESE_TECHNOS_REPLAY_2026-05-23.md§4
Ce doc fige le contrat entre api_stream.py (FastAPI Linux) et agent_v1/core/executor.py + agent_v1/network/streamer.py (Léa Windows). Il est isomorphe poll ↔ SSE (cf. §9).
1. TL;DR + diagramme d'ensemble
Une action visuelle est un message borné par 2 IDs : action_id (unique par step de replay, déterministe) + attempt_id (UUID, incrémenté à chaque re-dispatch transport). Le serveur tient une mini-visibility-timeout in-memory (_retry_pending). Le client tient un LRU dedup_set des attempt_id récemment exécutés. La pause supervisée (paused_need_help) est l'état absorbant en cas d'épuisement des essais ou de signal explicite (system_dialog, wrong_window, target_not_found). Le contrat est identique en polling (transport actuel) et en SSE (cible AXE_B1).
┌───────────── server (Linux, FastAPI :5005) ─────────────┐
│ │
_replay_queues │ ┌──────────┐ DISPATCH ┌─────────────┐ │
[session_id] │ │ PENDING │ ──────────────►│ DISPATCHED │ │
│ └──────────┘ │ (_retry_ │ │
│ ▲ │ pending) │ │
│ │ repush head └─────┬───────┘ │
│ │ (watchdog) │ REPORT(success) │
│ ┌────┴─────┐ age>30s, resent │ verify OK │
│ │ ORPHAN │ ◄─── timeout ◄────────┤ │
│ │ (resent_ │ │ │
│ │ count++)│ ▼ │
│ └──────────┘ ┌──────────┐ │
│ │ resent ≥ MAX │ ACKED │ │
│ ▼ └──────────┘ │
│ ┌──────────┐ REPORT(fail+system_dialog/wrong_window) │
│ │ ABANDONED│◄──┬────────────────┐ │
│ └──────────┘ │ │ │
│ │ ▼ ▼ │
│ │ ┌──────────────┐ ┌──────────┐ │
│ └──►│ PAUSE_NEED_ │ │ FAILED │ │
│ │ HELP │ │ (retry │ │
│ └─────┬────────┘ │ budget │ │
│ │ /resume │ out) │ │
│ ▼ └──────────┘ │
│ (resume_action en tête queue) │
└──────────────────────────────────────────────────────────┘
▲ │
│ POST /replay/result │ GET /replay/next (poll)
│ │ ou SSE event
│ ▼
┌───────────┴──────────── client Léa (Windows) ────────────┐
│ ┌──────────┐ parse ┌──────────┐ execute_replay_ │
│ │ RECEIVED │──────────►│ DEDUP │ action() │
│ └──────────┘ │ CHECK │ │
│ └────┬─────┘ │
│ hit LRU │ miss │
│ │ ▼ │
│ │ ┌──────────┐ │
│ │ │EXECUTING │ │
│ │ └────┬─────┘ │
│ ▼ ▼ │
│ ┌──────────────┐ │
│ │ REPORTING │ → POST /replay/result │
│ └──────────────┘ │
└──────────────────────────────────────────────────────────┘
2. State machine serveur — une action dans _retry_pending
start
│
▼
┌───────────────────────────────────────────────────────────────┐
│ PENDING │
│ présent dans _replay_queues[session_id], pas encore │
│ extrait par get_next_action │
└───────────────────────┬───────────────────────────────────────┘
│ get_next_action → pop queue + write
│ _retry_pending[action_id]={dispatched_at=now}
│ + log [REPLAY] DISPATCH
▼
┌───────────────────────────────────────────────────────────────┐
│ DISPATCHED │
│ dans _retry_pending, attente du REPORT │
│ invariant : action absente de _replay_queues │
└───┬───────────────────────┬───────────────────────────────────┘
│ REPORT(success=True) │ REPORT(success=False, error=*)
│ verify OK ou skip │ watchdog scan age>ORPHAN_TIMEOUT
│ │
│ pop _retry_pending │ resent_count < MAX_RESENDS
│ completed_actions++ │ → repush head + resent++
│ current_idx++ │ → dispatched_at=0
▼ │ → log [BUS] lea:dispatch_
┌────────┐ │ orphan_resent
│ ACKED │ │
│ (term) │ │ resent_count ≥ MAX_RESENDS
└────────┘ │ → pop _retry_pending
│ → log [BUS] lea:dispatch_
│ orphan_giveup
│ → ABANDONED
▼
┌─────────────────┐ watchdog repush
│ ORPHAN_RESENT │ ───────────► PENDING
│ (transitoire) │ (avec resent++)
└─────────────────┘
REPORT(verify failed AND retry_count<MAX_RETRIES_PER_ACTION):
_schedule_retry crée action_id_retry{N+1}, repush head
le nouveau action_id entre en PENDING (nouvelle entrée)
l'ancien action_id sort de DISPATCHED via pop
REPORT(system_dialog | wrong_window | target_not_found):
──► PAUSE_NEED_HELP (replay_state.status)
_retry_pending non purgé sur-le-champ (peut être réutilisé par /resume)
ABANDONED:
entrée _retry_pending supprimée
──► si politique = pause sur Nème giveup → PAUSE_NEED_HELP
sinon : action perdue, replay continue sur l'action suivante
CANCELLED (POST /replay/<id>/cancel):
purge _retry_pending par replay_id (api_stream.py:4489)
_replay_queues[session_id] = []
state.status = "cancelled"
Invariants serveurs :
| # | Invariant | Garanti par |
|---|---|---|
| I1 | Une action en DISPATCHED est absente de _replay_queues |
pop atomique sous _replay_lock (api_stream.py:3346-3348) |
| I2 | action_id unique dans _retry_pending à un instant t |
clé dict + check if action_id_sent not in _retry_pending (api_stream.py:3354) |
| I3 | report_action_result.pop(action_id) est idempotent |
pop(key, None) retourne None si déjà acquitté (api_stream.py:3491) |
| I4 | Cancel purge bien _retry_pending pour ce replay |
iter _retry_pending.items() if v["replay_id"]==replay_id (api_stream.py:4489-4491) |
| I5 | Watchdog re-check sous lock avant repush | pattern if aid not in self._retry_pending: skip (AXE_B1_DEEP §3) |
| I6 | Pause paused_need_help ne distribue aucune action |
get_next_action retourne replay_paused=True (api_stream.py:2951) |
3. State machine client Léa — une action côté executor.py
┌──────────────┐
│ POLLING │ thread `_poll_loop`, every 1s (+backoff)
│ (idle) │ GET /replay/next?session_id&machine_id
└──────┬───────┘
│ HTTP 200 + action ≠ null
▼
┌──────────────┐
│ RECEIVED │ data["action"] parsé
└──────┬───────┘
│
┌───────────────┴────────────────┐
│ attempt_id ∈ dedup_set ? │
▼ ▼
OUI : SKIP NON
(log warning, ack synthetique) │
▼
┌──────────────┐
│ EXECUTING │ execute_replay_action()
│ │ ├─ pre-check window
│ │ ├─ resolve target visuel
│ │ ├─ click / type / key
│ │ └─ screenshot_after
└──────┬───────┘
│
┌─────────────────┼──────────────────┐
│ │ │
success=True success=False system_dialog
warning=None error=* détecté
│ │ │
▼ ▼ ▼
┌─────────────────────────────────┐
│ REPORTING │
│ POST /replay/result + retry │
│ (timeout=10s, allow_redirects=False)
└─────────────────┬────────────────┘
│
┌──────────┴──────────┐
HTTP 200 fail/timeout
│ │
▼ ▼
┌──────────┐ ┌──────────────┐
│ ACKED │ │ REPORT_RETRY │ (PAS implémenté
│ + LRU │ │ (in-memory) │ v1 ; à v2)
│ store │ └──────┬───────┘
│ attempt │ │
│ _id │ ▼
└──────────┘ (perte report : serveur watchdog
rattrapera via orphan)
Pendant POLLING : si data.replay_paused → afficher PauseDialog
Pendant EXECUTING : timeout par étape gérée par execute_replay_action
(resolve serveur 30s, _wait_for_screen_change 1000ms+, capture 0.5s)
Invariants clients :
| # | Invariant | Garanti par |
|---|---|---|
| C1 | Une action reçue est TOUJOURS reportée (succès ou échec) | try/except global executor.py:2429-2503, fallback result={success:False, error=…} |
| C2 | Un seul poll_and_execute à la fois |
self._replay_lock.acquire(blocking=False) executor.py:2291 |
| C3 | Pas de blocage event tray UI pendant exécution | thread dédié _poll_loop |
| C4 | Idempotence côté action (v2) | dedup_set LRU bornée 256 entrées sur attempt_id (§6.2) — À AJOUTER |
| C5 | Pause UI déclenchée uniquement sur signal serveur explicite | data.get("replay_paused") executor.py:2346 |
4. Contrats JSON
4.1. Payload DISPATCH serveur → client (GET /replay/next OU SSE event)
Cas nominal : action visuelle
| Champ | Type | Obligatoire | Description | Source code |
|---|---|---|---|---|
action |
object | null | oui | l'action ou null si rien |
api_stream.py:3436 |
session_id |
str | oui | session active | api_stream.py:3438 |
machine_id |
str | oui | machine cible | api_stream.py:3439 |
action.action_id |
str | oui | identifiant unique step, ex. step_4c0663941f22 |
DB workflow + suffixes _retry1/_resume |
action.attempt_id |
str | À AJOUTER | UUID hex 16, nouveau à chaque dispatch (initial OU resend) | n/a, v2 |
action.type |
enum | oui | click/type/key_combo/wait/scroll/pause_for_human/extract_text/... |
core/executor.py:2422 |
action.target_spec |
object | si visuelle | {by_text,vlm_description,anchor_image_base64,resolve_order,window_title,uia_target} |
api_stream.py:3364 |
action.parameters |
object | dep. type | {text,keys,duration_ms,condition,…} |
dépend du type |
action.expected_window_before |
str | non | titre fenêtre attendue avant clic | api_stream.py:3366 |
action.expected_window_title |
str | non | titre fenêtre attendue après clic | api_stream.py:3369 |
action.success_strict |
bool | non | mode strict (skip OCR fuzzy) | api_stream.py:3387 |
action.intention |
str | non | description humaine | api_stream.py:3379 |
action.monitor_resolution |
object | oui (QW1) | {idx,offset_x,offset_y,w,h,source} |
api_stream.py:3403 |
action.from_node |
str | non | id node WorkflowGraph (active pre-check) | api_stream.py:3229 |
action.dispatch_meta |
object | À AJOUTER | {first_dispatched_at,resent_count,last_resent_at} pour visibilité client |
v2 |
precheck |
object | non | résultat pre-check serveur {match,similarity,popup_detected} |
api_stream.py:3441 |
server_busy |
bool | non | lock occupé, retry plus tard | api_stream.py:2944 |
replay_paused |
bool | non | replay en pause supervisée | api_stream.py:2960 |
pause_message |
str | si paused | message à afficher dans bulle | api_stream.py:2961 |
replay_id |
str | si paused | pour ack ciblé via /resume | api_stream.py:2962 |
auth_detected |
bool | non | injection automatique d'actions d'auth | api_stream.py:3304 |
Exemple complet (v2 cible) :
{
"action": {
"action_id": "step_4c0663941f22",
"attempt_id": "a8f3c2d1e9b4f720",
"type": "click",
"target_spec": {
"by_text": "Imagerie",
"resolve_order": ["ocr","template","vlm"],
"anchor_image_base64": "iVBORw0KGgo…",
"window_title": "MOREL Catherine — Easily Assure",
"uia_target": null
},
"parameters": {},
"expected_window_before": "MOREL Catherine — Easily Assure",
"expected_window_title": "MOREL Catherine — Easily Assure",
"success_strict": true,
"intention": "Cliquer onglet Imagerie",
"monitor_resolution": {"idx":1,"offset_x":0,"offset_y":0,"w":2560,"h":1600,"source":"action_hint"},
"from_node": "node_tab_imagerie",
"dispatch_meta": {
"first_dispatched_at": 1779015600.123,
"resent_count": 0,
"last_resent_at": 0.0
}
},
"session_id": "sess_demo_42",
"machine_id": "DESKTOP-58D5CAC"
}
Cas pause supervisée :
{
"action": null,
"session_id": "sess_demo_42",
"machine_id": "DESKTOP-58D5CAC",
"replay_paused": true,
"pause_message": "Je n'y arrive pas (« Coller ou saisir... »)",
"replay_id": "replay_free_68ca51ab"
}
Cas idle / server_busy :
{"action": null, "session_id":"sess_demo_42", "machine_id":"…", "server_busy": true}
4.2. Payload REPORT client → serveur (POST /replay/result)
| Champ | Type | Obligatoire | Description | Source code |
|---|---|---|---|---|
session_id |
str | oui | api_stream.py:628 | |
action_id |
str | oui | identifiant de l'action acquittée | api_stream.py:629 |
attempt_id |
str | À AJOUTER | echo du attempt_id reçu (corrélation watchdog ↔ client) |
v2 |
success |
bool | oui | résultat global | api_stream.py:630 |
error |
str | null | dep. success | message court (target_not_found, system_dialog:uac_consent, …) |
api_stream.py:631 |
warning |
str | null | non | no_screen_change/popup_handled/visual_resolve_failed/wrong_window |
api_stream.py:632 |
screenshot_after |
str | null | recommandé | base64 PNG ou path | api_stream.py:634 |
screenshot_before |
str | null | recommandé (clic) | base64 PNG du frame pre-action (Critic) | api_stream.py:635 |
actual_position |
object | si visuel | {x_pct: float, y_pct: float} coords cliquées |
api_stream.py:636 |
resolution_method |
str | si visuel | server_resolve_hybrid/template_match/... |
api_stream.py:638 |
resolution_score |
float | si visuel | 0.0–1.0 | api_stream.py:639 |
resolution_elapsed_ms |
float | si visuel | latence cascade | api_stream.py:640 |
target_description |
str | si fail | description humaine pour bulle pause | api_stream.py:642 |
target_spec |
object | si fail | echo target_spec pour reconstruction | api_stream.py:643 |
correction |
object | si pédagogique | {x_pct,y_pct,uia_snapshot,crop_b64} mode supervisé |
api_stream.py:645 |
system_dialog |
object | si dialog | {category,matched_signal,matched_value,reason,context} |
api_stream.py:650 |
needs_human |
bool | non | force pause supervisée | api_stream.py:651 |
Exemple succès :
{
"session_id": "sess_demo_42",
"action_id": "step_4c0663941f22",
"attempt_id": "a8f3c2d1e9b4f720",
"success": true,
"actual_position": {"x_pct":0.2305,"y_pct":0.2805},
"resolution_method": "server_resolve_hybrid_text_direct",
"resolution_score": 0.80,
"resolution_elapsed_ms": 412.7,
"screenshot_before": "iVBO…",
"screenshot_after": "iVBO…"
}
Exemple échec target_not_found :
{
"session_id": "sess_demo_42",
"action_id": "step_36346c1c40b9",
"attempt_id": "b9e4d3c2f0a5e831",
"success": false,
"error": "target_not_found",
"warning": "visual_resolve_failed",
"target_description": "Coller ou saisir le dossier patient",
"target_spec": {"by_text":"Coller ou saisir le dossier patient", "…":"…"},
"screenshot_after": "iVBO…"
}
Exemple system_dialog (UAC) :
{
"session_id":"sess_demo_42",
"action_id":"step_xxx",
"attempt_id":"…",
"success": false,
"error": "system_dialog:uac_consent",
"system_dialog": {
"category": "uac_consent",
"matched_signal": "window_title",
"matched_value": "Contrôle de compte d'utilisateur",
"reason": "UAC consent prompt blocking click",
"context": "handle_popup_vlm"
},
"needs_human": true,
"screenshot_after": "iVBO…"
}
4.3. Payload re-dispatch (orphan resent)
Identique au DISPATCH normal sauf dispatch_meta enrichi :
{
"action": {
"action_id": "step_4c0663941f22", // INCHANGÉ
"attempt_id": "c2d5e8f1a3b7c049", // NOUVEAU (UUID frais)
"type": "click",
"...": "…",
"dispatch_meta": {
"first_dispatched_at": 1779015600.123,
"resent_count": 1,
"last_resent_at": 1779015630.987,
"resend_reason": "orphan_timeout"
}
}
}
Règle : action_id reste stable (preserve idempotence côté serveur via _retry_pending). Seul attempt_id change (permet au client de distinguer un vrai re-dispatch d'un doublon réseau).
4.4. Payload escalation pause supervisée
Envoyé par le serveur dans la réponse au prochain poll après bascule paused_need_help (cf. §4.1 cas pause). Le client doit afficher la bulle et arrêter d'exécuter jusqu'à reception d'un nouveau dispatch (qui sera l'action de resume).
Payload /replay/{replay_id}/resume (POST) : corps optionnel {"acknowledged_check_ids": ["chk_1","chk_2"]} (QW4). Réponse :
{
"status": "resumed",
"replay_id": "replay_free_68ca51ab",
"session_id": "sess_demo_42",
"remaining_actions": 12
}
Payload /replay/{replay_id}/cancel (POST) : corps vide. Réponse :
{"status": "cancelled", "replay_id": "…", "session_id": "…"}
5. Matrice des cas limites — la table principale du document
Notation : S = état serveur (PENDING/DISPATCHED/ORPHAN/ACKED/ABANDONED/PAUSE), C = état client (POLLING/RECEIVED/EXECUTING/REPORTING/IDLE/DEAD).
| # | Scénario | État serveur (avant→après) | État client (avant→après) | Comportement attendu (v2 contrat) | Risque idempotence | Status code / contournement |
|---|---|---|---|---|---|---|
| a | Client coupe AVANT réception réponse /replay/next (bug 8 mai) |
DISPATCHED→ORPHAN→PENDING→DISPATCHED | POLLING→POLLING (timeout) | Watchdog détecte age>30s, re-dispatch (attempt_id neuf, resent_count=1). Bulle "action retentée" facultative. |
Faible : action_id stable, client n'a JAMAIS exécuté → pas de double-effet. | OK via AXE_B1_DEEP §3 |
| b | Client coupe APRÈS réception, AVANT exécution | DISPATCHED→ORPHAN→PENDING→DISPATCHED | RECEIVED→DEAD | Watchdog re-dispatch. Si client revit, reçoit nouvelle attempt → exécute. Si client mort, watchdog finit en ABANDONED. | Faible : action perdue avant tout effet. | OK |
| c | Client exécute, coupe AVANT envoi report | DISPATCHED→ORPHAN→PENDING→DISPATCHED | EXECUTING→REPORTING→DEAD (avant POST) | Watchdog re-dispatch. 2e exécution probable côté client. Idempotence action requise (§6.3). | ÉLEVÉ : double clic, double saisie possibles. Cible critique : type → préfixer Ctrl+A. |
dedup_set côté client (§6.2) BLOQUE la 2e si même action_id reçu < 256 messages |
| d | Client report success, serveur ne reçoit pas (HTTP 502, timeout serveur côté POST) | DISPATCHED→ORPHAN→PENDING | EXECUTING→REPORTING(echec POST)→IDLE | Client doit retenter le POST (boucle interne avec backoff). v1 : un seul essai (executor.py:2476 timeout=10s, pas de retry). À AJOUTER v2 : retry 3× backoff [1,3,7]s. Sinon watchdog re-dispatch + dedup_set côté client→ack synthétique. | Moyen | À ajouter retry POST côté client |
| e | Client report success=false, retry budget restant | DISPATCHED→FAILED(verify)→PENDING(retry_N+1) | EXECUTING→REPORTING→POLLING | _schedule_retry crée nouvelle entrée {action_id}_retry{N+1} (replay_engine.py:2604), repush head. verify_failed ne consomme PAS le budget orphan. |
Géré | OK |
| f | Watchdog re-dispatch ALORS QUE client envoie report tardif | DISPATCHED→ORPHAN→repush en cours | EXECUTING→REPORTING(en vol) | Race window. Re-check sous lock dans watchdog if aid not in _retry_pending: skip (AXE_B1_DEEP §3 _scan_once). Si pop arrive 1er : repush skip. Si repush arrive 1er : pop ignoré (no_active_replay). |
Faible | Géré par I3 + I5 |
| g | Watchdog re-dispatch, mais client a déjà ré-exécuté (cas c+f combinés) | DISPATCHED→ORPHAN→repush | EXECUTING(1)→REPORTING(1)→RECEIVED(2)→EXECUTING(2) | dedup_set client détecte 2e action_id identique → log warning + ack synthétique {success:true, warning:"already_executed"}. Sans dedup : double exécution = bug applicatif. |
CRITIQUE sans dedup | dedup_set v2 obligatoire |
| h | Client mort silencieux (Léa crash, NoMachine freeze) | DISPATCHED→ORPHAN→PENDING→…→ABANDONED→PAUSE | DEAD | Watchdog MAX_RESENDS=2 puis ABANDONED. Hook v1.1 : si ≥2 give-ups en 60s sur même session → bascule replay_state.status=paused_need_help + message "Léa ne répond plus" (AXE_B1_DEEP §6 R4). | OK avec hook | À ajouter hook dead_client_signal |
| i | Serveur restart pendant actions en _retry_pending |
TOUT en mémoire → PERTE | POLLING → reçoit 404 / 503 / ConnectionError | _retry_pending est in-memory. Au restart : queue vide, replay_state perdu (sauf si persisté en DB — vérifier). Client backoff exponentiel ; quand serveur revient, replay_state restaurable depuis SQLite mais _retry_pending non. Décision v1 : rebuild best-effort — _replay_states sauvegardés en SQLite ont les completed_actions, on relance depuis current_action_index+1. Pas de rejeu des actions en vol. Lacune connue : si action mid-flight ; à valider avec Dom. |
n/a (état perdu) | À TRANCHER avec Dom : persistance _retry_pending ? |
| j | Polls clients simultanés en course (2 process Léa, ou retry rapide) | DISPATCHED (1 seul vainqueur du lock) | 2× POLLING | _replay_lock.acquire(timeout=4.5) : 1er gagne, 2e reçoit {server_busy:true} (api_stream.py:2944). Client backoff. Ordre des steps préservé (lock global). |
Faible | OK |
| k | Action arrivée 2× côté client (double-clic même bouton) | DISPATCHED (attempt_1) puis DISPATCHED (attempt_2) | RECEIVED→DEDUP_CHECK→SKIP (2e) | dedup_set client = LRU 256 sur action_id. 2e réception → ack synthétique success=true warning="already_executed", PAS de ré-exécution. |
Bloqué côté client | dedup_set v2 |
| l | Pause supervisée serveur déclenchée pendant action en vol | DISPATCHED→PAUSE | EXECUTING→REPORTING (résultat ignoré côté serveur ?) | Le serveur applique la pause sur le step SUIVANT (boucle while queue voit paused). L'action en vol s'achève normalement, report traité (pop+verify), puis prochain poll → replay_paused=true. PAS de cancel de l'action en vol (pas de protocole serveur→client pour interrompre une action en cours). |
Faible | OK |
| m | Cancel replay côté VWB UI pendant action en vol | DISPATCHED→cancelled (purge _retry_pending) | EXECUTING→REPORTING | Cancel purge _retry_pending par replay_id (api_stream.py:4489) ET vide _replay_queues[session_id]. Le report tardif arrive : pop(action_id)→None → réponse no_active_replay (api_stream.py:3488). Client log info. Pas d'erreur. |
Faible | OK |
| n | Cap MAX_RESENDS atteint | ORPHAN→ABANDONED | DEAD ou EXECUTING (cas g) | Log [BUS] lea:dispatch_orphan_giveup. v1 = action perdue silencieusement, replay continue (peut bloquer step suivant si dépendance). Politique v2 : si action critique (type ∈ {click,type,t2a_decision}) → bascule paused_need_help immédiatement avec message "Léa n'a pas répondu, vérifie". Si non critique (wait,scroll) → log seul, continue. |
n/a | À TRANCHER : seuil par type ? |
| o | Action non-visuelle (extract_text, t2a_decision) vs visuelle (click) |
Non-visuelle : pas de DISPATCHED, exécutée server-side dans la même boucle get_next_action (api_stream.py:3132-3197) |
Jamais reçue par le client | Contrats distincts : non-visuelles n'entrent JAMAIS dans _retry_pending. Le watchdog n'a rien à scanner pour elles. Si extract_text plante (Ollama 503), queue.pop(0) + log warning + continue (api_stream.py:3195) → action serveur perdue silencieusement. Risque pas couvert par watchdog. |
n/a | À TRANCHER : retry serveur sur actions serveur ? séparé du watchdog actions visuelles |
| p | Workflow se termine alors qu'action est encore en _retry_pending |
DISPATCHED en cours → workflow.completed | n/a | _replay_states[replay_id].status = "completed". Si une action est encore en _retry_pending, le watchdog la verra orphan ; au resend, get_next_action ne trouvera pas de owning_replay (status running requis ligne 2974) → queue vide retournée. Fuite mémoire : entrée _retry_pending jamais purgée tant que pas de cancel ou age > MAX_RESENDS. Mitigation : ajouter purge _retry_pending sur transition vers completed/error/failed (analogue à cancel ligne 4489). |
Faible (mais leak) | À AJOUTER v2 : purge à la complétion |
| q | Précheck "wait" injecté (popup détectée) — n'est PAS une action workflow | Pas dans _retry_pending (action synthétique) |
RECEIVED→EXECUTING(wait 2000ms)→REPORTING(success=true) | wait_action a un action_id=precheck_wait_<6hex> non stockée côté serveur. Le report arrive : pop(action_id)→None, action ignorée gracieusement. |
Faible | OK |
| r | Replay paused, client continue à poller | PAUSE | POLLING reçoit replay_paused:true à chaque tick |
Client affiche bulle 1 fois (dedup sur _last_pause_msg_shown executor.py:2351), continue à poller. CPU loss négligeable. |
Faible | OK |
| s | Reverse-proxy NPM bufferise SSE | DISPATCHED, event jamais reçu | POLLING/SSE silencieux | X-Accel-Buffering: no côté server response. Ping 15s force flush. AXE_B1 §4 §8 risques. |
Faible avec headers | OK avec headers |
| t | NoMachine timeout idle (>60s) | DISPATCHED dormant | SSE→reconnect via Last-Event-ID | sseclient-py auto-reconnect, Last-Event-ID header repris au reconnect → serveur peut sauter les events déjà acquittés. v1 polling : pas de Last-Event-ID, juste réacheminement via watchdog. |
Faible | OK |
| u | Bearer token expire/révoqué pendant un replay actif | DISPATCHED en attente | POLLING reçoit 401 | Client doit re-auth (hors scope v1 : tokens longue durée). v1 : crash + tray notification. Watchdog côté serveur continue à scanner — actions partent en ABANDONED après MAX. | Faible | hors scope v1 |
6. Sémantique d'idempotence
6.1. Couches d'idempotence
| Couche | Mécanisme | Effet | Implémenté ? |
|---|---|---|---|
| Serveur — pop sur report | _retry_pending.pop(action_id, None) retourne None silencieux si déjà acquitté |
report en double n'augmente pas completed_actions 2× |
✅ api_stream.py:3491 |
| Serveur — re-check watchdog | if aid not in _retry_pending: skip sous lock |
re-dispatch annulé si report arrivé entre snapshot et repush | ✅ AXE_B1_DEEP §3 |
| Serveur — cancel purge | itération _retry_pending par replay_id |
aucun ghost-resend après cancel | ✅ api_stream.py:4489 |
Action — action_id stable |
identifiant unique step (step_<hex> puis suffixes _retry{N} ou _resume) |
clé du pop côté serveur, clé du dedup côté client |
✅ DB workflow + replay_engine.py:2609 |
Action — attempt_id rotatif |
UUID nouveau à chaque DISPATCH (initial + chaque resend) | distingue un re-dispatch légitime d'un doublon réseau, permet stats orphan | ❌ À AJOUTER v2 |
| Client — dedup_set LRU 256 | set bornée de (action_id) ou (action_id, attempt_id) récemment exécutés |
bloque ré-exécution en cas g/k | ❌ À AJOUTER v2 obligatoire |
| Action — idempotence intrinsèque | clear field avant type, idempotence native du click sur tab actif |
minimise dégât en cas de double exécution résiduelle | ⚠ À documenter dans VWB, pas dans code |
6.2. Spec dedup_set client (v2)
# agent_v1/core/executor.py — à ajouter
from collections import OrderedDict
class ActionDedupSet:
"""LRU bornée d'action_id récemment exécutées.
Bloque ré-exécution si action arrive 2 fois (orphan resent + double réseau).
"""
def __init__(self, max_size: int = 256):
self._store: OrderedDict[str, float] = OrderedDict() # action_id → ts
self._max = max_size
def seen(self, action_id: str) -> bool:
if action_id in self._store:
# Touch (LRU)
self._store.move_to_end(action_id)
return True
return False
def mark(self, action_id: str) -> None:
self._store[action_id] = time.time()
self._store.move_to_end(action_id)
if len(self._store) > self._max:
self._store.popitem(last=False)
Usage dans poll_and_execute_inner AVANT execute_replay_action :
if self._dedup.seen(action.get("action_id","")):
logger.warning(f"[DEDUP] action {action.get('action_id')} déjà exécutée — ack synthétique")
self._post_synthetic_ack(action, server_url, replay_result_url,
success=True, warning="already_executed")
return True
self._dedup.mark(action.get("action_id",""))
# … execute_replay_action(action) …
6.3. Idempotence intrinsèque par type d'action
| Type | Idempotent nativement ? | Mitigation si exécuté 2× |
|---|---|---|
click sur tab/bouton actif |
OUI (le tab reste actif) | aucune |
click sur bouton "Submit"/"Valider" |
NON (double formulaire) | dedup_set CRITIQUE + dialog confirm côté app |
type texte |
NON (double saisie) | préfixer Ctrl+A (clear) + dedup_set |
keyboard_shortcut Ctrl+S |
dep. (1 sauvegarde = 1 dialog) | dedup_set |
keyboard_shortcut Ctrl+V |
NON (double collage) | dedup_set + clear avant |
scroll |
OUI mais déplace 2× | tolérable, dedup_set conseillé |
wait |
OUI | aucun risque |
extract_text (server-side) |
OUI (lecture pure) | n/a |
t2a_decision (server-side, LLM) |
OUI mais re-coût LLM ($/temps) | retry serveur, pas client |
7. Timeouts et seuils
| Nom | Défaut | Env var | Effet | Source |
|---|---|---|---|---|
client_poll_timeout |
30 s | non, en dur | requests.get(/replay/next, timeout=30) côté Léa |
executor.py:2320 |
client_report_timeout |
10 s | non, en dur | requests.post(/replay/result, timeout=10) |
executor.py:2480 |
client_resolve_timeout |
30 s | non, en dur | appel serveur /resolve_target |
executor.py:1898 |
server_replay_lock_timeout |
4.5 s | non, en dur | _async_replay_lock(timeout=4.5) → 503 ou server_busy |
api_stream.py:539, 2938 |
server_action_server_side_timeout |
180 s | non, en dur | asyncio.wait_for(extract_text/t2a, 180) |
api_stream.py:3141 |
server_paste_and_execute_timeout |
30 s | non, en dur | paste+execute ydotool | api_stream.py:3192 |
server_precheck_timeout |
0.5 s | non, en dur | CLIP embed pre-check | api_stream.py:3250 |
heartbeat_max_age |
varie | RPA_HEARTBEAT_MAX_AGE_SECONDS |
utilité pre-check | api_stream.py:3235 |
WATCHDOG_SCAN_INTERVAL_S |
10 s | RPA_WATCHDOG_SCAN_INTERVAL_S |
période scan orphan | AXE_B1_DEEP §11 |
WATCHDOG_ORPHAN_TIMEOUT_S |
30 s | RPA_WATCHDOG_ORPHAN_TIMEOUT_S |
age sans report → orphan | AXE_B1_DEEP §11 |
WATCHDOG_MAX_RESENDS |
2 | RPA_WATCHDOG_MAX_RESENDS |
give-up après N resends | AXE_B1_DEEP §11 |
WATCHDOG_REPUSH_POSITION |
head |
RPA_WATCHDOG_REPUSH_POSITION |
head/tail | AXE_B1_DEEP §11 |
WATCHDOG_ENABLED |
1 |
RPA_WATCHDOG_ENABLED |
kill-switch | AXE_B1_DEEP §11 |
REPLAY_STATE_TTL_SECONDS |
varie | RPA_REPLAY_STATE_TTL |
purge states finis | api_stream.py:726 |
MAX_REPLAY_STATES |
n | en dur | borne _replay_states |
api_stream.py:735 |
MAX_RETRIES_PER_ACTION |
3 | en dur | budget retry métier _schedule_retry |
replay_engine.py:2591 |
_poll_backoff_min |
varie | n/a | reset après HTTP 200 | executor.py:2334 |
_poll_backoff_max |
varie | n/a | plafond backoff exponentiel | executor.py:2326 |
_poll_backoff_factor |
2.0 | n/a | facteur multiplicatif | executor.py:2326 |
SSE_PING_INTERVAL_S (cible) |
15 | env futur | heartbeat SSE | AXE_B1 §4 |
Cohérence des seuils :
client_poll_timeout (30s) > server_replay_lock_timeout (4.5s)→ OK, le client attend bien la réponse server_busy.server_action_server_side_timeout (180s) > client_poll_timeout (30s)→ SI extract_text dure 35s sans dispatcher d'action visuelle entre temps, le client coupe MAIS le serveur continue ; au prochain poll le serveur a fini, dispatche l'action visuelle suivante. Pas de perte tant que l'action visuelle dispatch est rapide après extract_text. Bug 8 mai = extract_text + dispatch click dans la MÊME réponse → 5s timeout dépassé → fixtimeout=30adopté.WATCHDOG_ORPHAN_TIMEOUT_S (30s) > client_poll_timeout (30s)→ frontière dangereuse. Recommandation : remonter à 45s pour laisser le temps au client de retenter au moins 1 poll naturellement avant que le watchdog résende.
8. Transitions vers pause supervisée et resume
8.1. Déclencheurs status = "paused_need_help"
| Déclencheur | Source | État avant | État après | Champ enrichi |
|---|---|---|---|---|
pause_for_human en mode supervised ou safety_checks présents |
api_stream.py:3066-3111 | running | paused_need_help | safety_checks, pause_payload, failed_action.reason="user_request" |
Report system_dialog:* (UAC/CredUI/SmartScreen) |
api_stream.py:3785-3870 | running | paused_need_help | failed_action.reason="system_dialog", message contextualisé |
Report warning="wrong_window" |
api_stream.py:3872-3920 | running | paused_need_help | failed_action.reason="wrong_window" |
Report success=false + error="target_not_found" après MAX_RETRIES_PER_ACTION (3) |
api_stream.py:3949-4030 | running | paused_need_help | failed_action.target_description |
| Hook v1.1 dead client signal (2+ giveups en 60s) | À AJOUTER (AXE_B1_DEEP §6 R4) | running | paused_need_help | failed_action.reason="dead_client" |
| Hook v2 (cas n) : N orphan giveup sur action type critique | À DÉCIDER avec Dom | running | paused_need_help | failed_action.reason="orphan_max_resends" |
8.2. État de _retry_pending au moment de la pause
- Pause via
pause_for_human: aucune action en vol (pause arrive avant dispatch). - Pause via report failed : l'action qui a déclenché la pause vient d'être
poppée._retry_pendingest vide pour cet action_id (déjà acquittée). Aucune purge supplémentaire nécessaire. - Pause via watchdog hook (v1.1) :
_retry_pendingpeut 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) :
- Vérifie state.status ==
paused_need_help(sinon 409). - Vérifie acquittement safety_checks required (sinon 400).
- Reset state :
status="running",failed_action=None,pause_message=None,safety_checks=[]. - Reconstruit l'action :
- Priorité 1 :
failed_action.original_actionsi présent. - Priorité 2 :
_retry_pending.pop(failed_action.action_id, {}).get("action"). - Priorité 3 : minimum
{action_id, type, target_spec, visual_mode}.
- Priorité 1 :
- Nouveau
action_id = "{original}_resume". - Enregistre dans
_retry_pending[resume_id] = {action,retry_count:0,replay_id,reason:"resume_after_pause"}. - 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 I1–I6 et C1–C5 tiennent identiquement.
Seul écart pratique : en SSE, WATCHDOG_ORPHAN_TIMEOUT_S peut descendre à 15s (déconnexion détectée plus tôt). En polling, garder 30s (laisser une chance au polling naturel).
10. Précédents externes — fiches courtes
10.1. AWS SQS — visibility timeout
- Contrat : message reçu devient invisible pour
VisibilityTimeoutsecondes (défaut 30s). Si pasDeleteMessageavant 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 :
ChangeMessageVisibilitypour é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=maxReceiveCountavant DLQ. ABANDONED = DLQ équivalent (mais sans queue physique, juste log). - Source : SQS visibility timeout doc (consulté 2026-05-24)
10.2. NATS JetStream — pull consumer ack
- Contrat :
AckExplicitpar 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 overrideAckWait. Si liste plus courte queMaxDeliver, dernier délai répété. - Notre mapping :
AckWait=WATCHDOG_ORPHAN_TIMEOUT_S.MaxDeliver=WATCHDOG_MAX_RESENDS+1. Pas de NAK explicite chez nous : un report success=false suit la voie retry métier (_schedule_retry), pas la voie transport. Pas de backoff dans la v1 du watchdog (justification AXE_B1_DEEP §5 : démo médicale, réactivité prime). Adoptable si besoin. - Source : NATS JetStream Consumers doc (consulté 2026-05-24)
10.3. Skyvern — execute_step + handle_failed_step
- Contrat : boucle récursive
execute_step(forge/agent.py lignes 1094–1577). À chaque step :step.status == failed→handle_failed_step()retourne next step (retry) ou None (terminal).step.status == completed→handle_completed_step()décide advance vs verify vs finalize.
- Cap :
max_steps_per_runglobal, 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_pendingqui n'a pas d'équivalent direct. - Source : Skyvern agent.py main, PR #434 better catch exceptions
10.4. browser-use — action_id + idempotency guard
- Contrat :
max_failuresconfig (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_iddéterministe =step_<hex_workflow>+ suffixes. dedup_set client = équivalent action cache court-terme. - Différence : browser-use est intra-process (loop Python contrôle Chromium via CDP local). Notre cas inter-process inter-machine.
- Source : browser-use AGENTS.md, Idempotent AI agents — buildmvpfast 2026
10.5. Anthropic Computer Use SDK — tool_use_id binding
- Contrat : chaque
tool_useretourné par Claude a unid; le code applicatif doit retourner untool_resultavectool_use_ididentique (loop.py lignes 234-254). - Retry : uniquement au niveau API (
max_retries=4cô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, Tool use Claude API docs
10.6. Playwright MCP — SSE remote transport
- Contrat : transport stdio (local) OU HTTP/SSE (remote). Tools/list au handshake, tool/call par event SSE descendant, tool/result POST remontant.
- Issue connue 2026 : « SSE stream disconnected » après idle (cline/cline #8367). Mitigation = ping applicatif.
- Timeout : 30s par défaut sur CDP endpoint connect.
- Notre mapping : très proche du pattern cible AXE_B1 §4 (SSE descendant + POST ack). Confirme robustesse du choix techno. Précaution : prévoir reconnect natif (sseclient-py).
- Source : microsoft/playwright-mcp, cline issue #8367 SSE disconnect
10.7. Synthèse comparative
| Système | ID corrélation | Visibility/Ack timeout | Max retry transport | Dedup client | Modèle delivery |
|---|---|---|---|---|---|
| AWS SQS std | MessageId + ReceiptHandle | VisibilityTimeout 30s | maxReceiveCount (DLQ) | obligatoire app | at-least-once |
| NATS JetStream | StreamSeq + ConsumerSeq | AckWait 30s | MaxDeliver | obligatoire app | at-least-once |
| Skyvern | step.step_id | n/a (monolithe) | max_steps_per_run | n/a | exactly-once local |
| browser-use | (cmd, selector, value) | n/a | max_failures=3 | action cache | exactly-once local |
| Anthropic CU | tool_use_id | n/a | max_retries client (API) | non garanti | exactly-once par tour |
| Playwright MCP | request_id | 30s CDP | n/a (LLM décide) | non garanti | best-effort |
| Nous (v2 cible) | action_id + attempt_id |
ORPHAN_TIMEOUT 30s | MAX_RESENDS=2 | dedup_set 256 LRU | at-least-once + dedup → effectif exactly-once |
11. Sources
Code interne (lecture seule, lignes vérifiées 2026-05-24)
agent_v0/server_v1/api_stream.py:520-559—_replay_lock,_async_replay_lock,_replay_queues,_replay_states,_machine_replay_targetagent_v0/server_v1/api_stream.py:626-651—ReplayResultReportPydantic schemaagent_v0/server_v1/api_stream.py:2906-3443—get_next_action(DISPATCH path)agent_v0/server_v1/api_stream.py:3132-3197— actions server-sideextract_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 idempotentagent_v0/server_v1/api_stream.py:3785-3870— basculepaused_need_helpsur system_dialogagent_v0/server_v1/api_stream.py:4361-4474—resume_replay+ safety_checksagent_v0/server_v1/api_stream.py:4477-4494—cancel_replay+ purgeagent_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_inneragent_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 endpointdocs/recherche/AXE_B1_DEEP_WATCHDOG.md(2026-05-24) — implémentation watchdog complètedocs/REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md— diagnostic 9 actions perdues, racine du contratdocs/SYNTHESE_TECHNOS_REPLAY_2026-05-23.md§4 — synthèse replaydocs/LESSONS_LEARNED_GHT_2026-05.md— bugs P0 post-démodocs/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
- Amazon SQS exactly-once processing FIFO
- Amazon SQS message deduplication ID
- NATS JetStream Consumers
- NATS JetStream Model Deep Dive
- How to Handle SQS Visibility Timeout (oneuptime 2026-01-27)
- Achieving idempotency in AWS serverless (Albaqali)
Frameworks RPA / Computer Use
- Skyvern forge/agent.py main —
execute_step1094-1577 - Skyvern webeye/actions/handler.py
- Skyvern PR #434 better catch exceptions
- Skyvern retry run webhook docs
- Anthropic computer-use-demo loop.py — dispatch 234-254
- Anthropic Tool use overview docs
- Microsoft Playwright MCP
- Cline SSE disconnect issue #8367
- browser-use AGENTS.md main
- browser-use issue #3615 agent not stopping
Patterns idempotence agents AI
- Idempotent AI Agents — buildmvpfast 2026
- Fault-Tolerant AI Agent Pipelines — MightyBot
- Action Verification and Retries in LLM Agent Loops — ingramhaus
- Idempotency in Distributed Systems — aloknecessary
- Stripe API idempotent requests
Transport SSE / FastAPI
- sse-starlette GitHub
- FastAPI lifespan events
- Stop streaming response when client disconnects (FastAPI #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étiqueswaitou des reportswarning="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é dansreplay_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).