snapshot: WIP 5j replay reliability (B1 watchdog + dialog handlers + grounding drift)

Snapshot avant correction du blocage relance Léa (3 incidents 24h: SSH refusé,
polls morts ×2). Point de rollback stable.

Contenu:
- agent_v1/core/executor.py: 5 patchs dialog handling (saveas drift, close_tab
  hotkey fallback, confirm_save Unicode apostrophe, foreground dialog
  recontextualization, runtime_dialog in-loop) + helpers normalize_window_hint,
  requires_post_verify_window_transition
- agent_v1/core/grounding.py: garde drift template fix (fallback_x/y plumbed)
- server_v1/replay_watchdog.py (NEW): orphan watchdog B1, scan 10s timeout 30s
- server_v1/api_stream.py: dispatched_action plumbing, watchdog lifespan,
  metrics endpoint
- server_v1/replay_engine.py: _schedule_retry préserve original_action +
  dispatched_action
- stream_processor.py: gardes _infer_tab_switch_target (no false switch_tab
  on save_as dialog open) + _attach_expected_window_before
- tests/integration: test_replay_watchdog.py (8 cas), test_stream_processor.py
- tests/unit: test_executor_verify_window_guard.py (start_button, close_tab,
  runtime_dialog, post_verify, transition fallbacks)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-05-24 16:48:37 +02:00
parent 5ea4960e65
commit 7df51d2c79
47 changed files with 9811 additions and 451 deletions

View File

@@ -158,14 +158,25 @@ class CaptureHandler(BaseHTTPRequestHandler):
"""Capture l'ecran principal et le renvoie en base64 JPEG."""
t0 = time.perf_counter()
try:
import mss
from PIL import Image
from ..vision.capturer import (
capture_foreground_window_image,
capture_screen_image,
)
with mss.mss() as sct:
monitor = sct.monitors[1] # ecran principal
raw = sct.grab(monitor)
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
_monitor, img, meta = capture_screen_image()
if img is None:
img, win_meta = capture_foreground_window_image()
meta.update(win_meta)
if img is None:
elapsed_ms = (time.perf_counter() - t0) * 1000
logger.error("Erreur capture : aucun backend exploitable (%s)", meta)
self._send_json(503, {
"error": "capture_unavailable",
"source": meta.get("backend", "unknown"),
"capture_ms": round(elapsed_ms),
"diagnostics": meta,
})
return
# Floutage des données sensibles (conformité AI Act)
if BLUR_SENSITIVE:
@@ -180,15 +191,22 @@ class CaptureHandler(BaseHTTPRequestHandler):
img_b64 = base64.b64encode(buf.getvalue()).decode()
elapsed_ms = (time.perf_counter() - t0) * 1000
logger.info(f"Capture {img.width}x{img.height} en {elapsed_ms:.0f}ms")
logger.info(
"Capture %sx%s via %s en %.0fms",
img.width,
img.height,
meta.get("backend", "unknown"),
elapsed_ms,
)
self._send_json(200, {
"image": img_b64,
"width": img.width,
"height": img.height,
"format": "jpeg",
"source": "windows_live",
"source": meta.get("backend", "windows_live"),
"capture_ms": round(elapsed_ms),
"diagnostics": meta,
})
except Exception as e:

View File

@@ -894,6 +894,34 @@ class ChatWindow:
except Exception:
logger.debug("clear chat history silenced", exc_info=True)
@staticmethod
def _compute_paused_bubble_height(reason_str: str) -> tuple:
"""Calcule la hauteur du Text (en lignes) + si une scrollbar est
nécessaire pour le message d'une bulle paused.
Patch 22 mai 2026 — fix troncature : on prend en compte les \\n
explicites (les `reason` serveur peuvent lister plusieurs
candidats avec un saut de ligne par item) en plus de la longueur
en caractères, et on active la scrollbar dès que le cap est
atteint pour éviter que du contenu disparaisse silencieusement.
Retourne ``(height_lines, needs_scrollbar)``.
"""
if not reason_str:
return 2, False
text = str(reason_str)
# Estimation : ~60 chars/ligne effectifs avec wraplength.
wrapped_lines = (len(text) // 60) + 1
explicit_lines = text.count("\n") + 1
estimated = max(wrapped_lines, explicit_lines)
cap = 12
height = max(2, min(cap, estimated))
# Scrollbar dès que le cap est atteint OU contenu long (filet
# textuel : ≥ 200 chars implique souvent un débordement visuel
# même quand les lignes brutes sont peu nombreuses).
needs_scroll = (estimated >= cap) or (len(text) > 200)
return height, needs_scroll
def _render_paused_bubble(self, payload: Dict[str, Any]) -> None:
tk = self._tk
if getattr(self, "_msg_frame", None) is None:
@@ -923,22 +951,23 @@ class ChatWindow:
# Message scrollable pour les longs reasons (ex: 200+ chars depuis le serveur).
# On utilise un Text en mode read-only avec hauteur calculée selon la longueur.
# Au-delà de 280 chars, scrollbar interne ; sinon Text auto-fitté.
# Patch 22 mai 2026 : prendre en compte les \n explicites (titres
# fenêtre / patterns) et activer la scrollbar dès que le cap de
# hauteur est atteint — sinon les bulles de pause étaient
# tronquées visuellement sans aucun ascenseur visible.
reason_str = str(reason)
# Estimation simple : ~70 chars/ligne avec wraplength
approx_lines = max(2, min(8, (len(reason_str) // 60) + 1))
height_lines, needs_scroll = self._compute_paused_bubble_height(reason_str)
msg_frame = tk.Frame(inner, bg=PAUSED_BG)
msg_frame.pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
reason_text = tk.Text(
msg_frame, bg=PAUSED_BG, fg=PAUSED_FG,
font=FONT_MSG, wrap=tk.WORD, bd=0, height=approx_lines,
font=FONT_MSG, wrap=tk.WORD, bd=0, height=height_lines,
highlightthickness=0, relief=tk.FLAT, cursor="arrow",
)
reason_text.insert("1.0", reason_str)
reason_text.configure(state="disabled")
reason_text.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Scrollbar interne uniquement si le contenu déborde (long messages)
if len(reason_str) > 280:
if needs_scroll:
reason_scroll = tk.Scrollbar(
msg_frame, orient=tk.VERTICAL,
command=reason_text.yview, width=8,
@@ -1019,27 +1048,40 @@ class ChatWindow:
UX fix 8 mai 2026 : on désactive les 2 boutons et on affiche un message
de feedback dès le clic, sans attendre l'ack serveur. Le bus émet en
arrière-plan ; si la connexion est tombée, on log un warning visible.
Fallback HTTP 22 mai 2026 : si le bus SocketIO est déconnecté, on
retombe sur un POST direct ``/replay/{id}/resume`` via
``server_client``. Si les deux échouent, on ré-active les boutons
et on saute l'auto-hide pour permettre à l'utilisateur de
réessayer manuellement (sinon le replay reste figé côté serveur).
"""
if not replay_id:
self._update_paused_feedback("⚠ replay_id manquant — impossible de relancer")
return
emitted = False
if self._bus is not None and self._bus.connected:
emitted = self._bus.resume_replay(replay_id)
# Feedback immédiat : disable boutons + message
emitted, channel = self._dispatch_paused_action(
replay_id,
bus_method="resume_replay",
client_method="resume_replay",
)
self._disable_paused_buttons()
if emitted:
self._update_paused_feedback("→ Reprise demandée…")
logger.info("paused_bubble: lea:replay_resume émis pour %s", replay_id)
else:
self._update_paused_feedback("⚠ Bus indisponible — réessayez dans 5s")
logger.warning("paused_bubble: bus déconnecté, resume non émis")
# UX fix mai 2026 : minimiser la fenêtre vers le systray après 500ms
# (laisse à l'utilisateur le temps de voir "Reprise demandée…").
try:
self._root.after(500, self._do_hide)
except Exception:
logger.debug("auto-hide on resume silenced", exc_info=True)
logger.info(
"paused_bubble: replay_resume émis pour %s via %s",
replay_id, channel,
)
try:
self._root.after(500, self._do_hide)
except Exception:
logger.debug("auto-hide on resume silenced", exc_info=True)
return
# Échec sur les deux canaux : laisser l'utilisateur réessayer.
self._update_paused_feedback("⚠ Serveur injoignable — réessayez")
self._enable_paused_buttons()
logger.warning(
"paused_bubble: bus et HTTP indisponibles, resume non émis "
"pour %s", replay_id,
)
def _on_paused_abort(self, replay_id: str) -> None:
"""Bouton Annuler : émettre lea:replay_abort + fermeture locale immédiate.
@@ -1048,17 +1090,30 @@ class ChatWindow:
n'envoie pas de lea:resumed pour un abort, donc sans cette fermeture
locale la bulle restait coincée — c'était la cause de "Annuler ne
fonctionne pas" rapportée par Dom).
Fallback HTTP 22 mai 2026 : symétrique de ``_on_paused_resume`` —
si le bus est déconnecté, POST direct ``/replay/{id}/cancel``.
L'abort ferme la bulle localement quelle que soit l'issue (l'état
serveur sera réconcilié au prochain poll /replay/next).
"""
emitted = False
if self._bus is not None and self._bus.connected:
emitted = self._bus.abort_replay(replay_id)
emitted, channel = self._dispatch_paused_action(
replay_id,
bus_method="abort_replay",
client_method="abort_replay",
)
self._disable_paused_buttons()
if emitted:
self._update_paused_feedback("✗ Annulé")
logger.info("paused_bubble: lea:replay_abort émis pour %s", replay_id)
logger.info(
"paused_bubble: replay_abort émis pour %s via %s",
replay_id, channel,
)
else:
self._update_paused_feedback("✗ Annulé (bus indisponible)")
logger.warning("paused_bubble: bus déconnecté, abort non émis")
self._update_paused_feedback("✗ Annulé (serveur injoignable)")
logger.warning(
"paused_bubble: bus et HTTP indisponibles, abort non émis "
"pour %s", replay_id,
)
# Fermer la bulle en local (l'abort n'a pas de lea:resumed associé)
self._close_active_paused_bubble(reason="abort_local")
# UX fix mai 2026 : minimiser la fenêtre après 500ms (cohérence
@@ -1068,6 +1123,34 @@ class ChatWindow:
except Exception:
logger.debug("auto-hide on abort silenced", exc_info=True)
def _dispatch_paused_action(
self,
replay_id: str,
bus_method: str,
client_method: str,
) -> tuple:
"""Envoyer une action de bulle paused via bus puis fallback HTTP.
Retourne ``(emitted, channel)`` où ``channel`` vaut ``"bus"``,
``"http"`` ou ``""`` (aucun chemin n'a abouti).
"""
if self._bus is not None and getattr(self._bus, "connected", False):
try:
if getattr(self._bus, bus_method)(replay_id):
return True, "bus"
except Exception:
logger.debug("paused_bubble: bus %s silenced", bus_method, exc_info=True)
if self._server_client is not None and hasattr(self._server_client, client_method):
try:
if getattr(self._server_client, client_method)(replay_id):
return True, "http"
except Exception:
logger.debug(
"paused_bubble: server_client %s silenced",
client_method, exc_info=True,
)
return False, ""
def _disable_paused_buttons(self) -> None:
if not self._active_paused_bubble:
return
@@ -1077,6 +1160,19 @@ class ChatWindow:
except Exception:
logger.debug("disable paused buttons silenced", exc_info=True)
def _enable_paused_buttons(self) -> None:
"""Ré-activer les boutons Continuer/Annuler de la bulle paused
active. Appelé quand l'envoi a échoué sur tous les canaux —
l'utilisateur doit pouvoir réessayer manuellement.
"""
if not self._active_paused_bubble:
return
try:
self._active_paused_bubble["btn_resume"].config(state="normal")
self._active_paused_bubble["btn_abort"].config(state="normal")
except Exception:
logger.debug("enable paused buttons silenced", exc_info=True)
def _update_paused_feedback(self, text: str) -> None:
if not self._active_paused_bubble:
return

View File

@@ -504,6 +504,100 @@ class SmartTrayV1:
threading.Thread(target=_replay, daemon=True).start()
def _launch_replay_request(
self,
replay_request: Dict[str, Any],
replay_name: str,
) -> None:
"""Lance un replay direct depuis un payload `replay_request` serveur."""
endpoint = (replay_request or {}).get("endpoint", "")
session_id = (replay_request or {}).get("session_id", "")
machine_id = (replay_request or {}).get("machine_id") or self.machine_id
if endpoint != "/api/v1/traces/stream/replay-session" or not session_id:
logger.warning("Replay request non supporté: %s", replay_request)
self._notifier.notify(
"Léa",
"Je ne peux pas lancer ce test automatique pour le moment.",
)
return
def _replay():
if self.server_client is None:
return
with self._state_lock:
self._replay_active = True
self._update_icon()
self._notifier.notify(
"Léa",
f"Le système d'intelligence artificielle exécute la "
f"tâche '{replay_name}' sur votre écran.",
)
try:
import requests
auth_headers = {}
if self.server_client is not None:
auth_headers = self.server_client._auth_headers()
resp = requests.post(
f"{self.server_client._stream_base}{endpoint}",
params={
"session_id": session_id,
"machine_id": machine_id,
},
headers=auth_headers,
timeout=30,
allow_redirects=False,
)
if resp.ok:
logger.info(
"Replay direct démarré pour session %s (machine=%s)",
session_id,
machine_id,
)
else:
self._notifier.notify(
"Léa",
"Hmm, le serveur a refusé le test immédiat.",
)
except Exception as e:
logger.error("Erreur lancement replay direct : %s", e)
self._notifier.notify(
"Léa",
f"Oups, un problème : {e}",
)
finally:
with self._state_lock:
self._replay_active = False
self._update_icon()
threading.Thread(target=_replay, daemon=True).start()
def offer_finalize_replay(
self,
replay_request: Dict[str, Any],
replay_name: str,
) -> None:
"""Proposer à l'utilisateur de tester immédiatement la tâche apprise."""
if not replay_request or not replay_request.get("session_id"):
return
def _offer():
self._notifier.notify(
"Léa",
f"J'ai compris la tâche '{replay_name}'. Voulez-vous la tester ?",
)
if not _ask_consent(
"Léa — Test immédiat",
f"J'ai compris la tâche '{replay_name}'. "
"Voulez-vous la tester maintenant ?",
):
return
self._launch_replay_request(replay_request, replay_name)
threading.Thread(target=_offer, daemon=True).start()
def _on_emergency_stop(self, _icon=None, _item=None) -> None:
"""Arret d'urgence — stoppe TOUTES les activites de l'agent immediatement.