diff --git a/agent_v0/server_v1/api_stream.py b/agent_v0/server_v1/api_stream.py index 8890c9f74..1f2cbf0e1 100644 --- a/agent_v0/server_v1/api_stream.py +++ b/agent_v0/server_v1/api_stream.py @@ -4400,6 +4400,69 @@ async def resolve_target(request: ResolveTargetRequest): logger.error(f"Décodage screenshot échoué: {e}") return _fallback_response(request, "decode_error", str(e)) + # Détection image tronquée + fallback heartbeat full screen. + # Bug client constaté ce 2026-05-07 (PC Windows 192.168.1.11, agent V1) : + # mss.monitors[1] retourne parfois une bande étroite type 2560x60, 2560x108, + # 600x72 — possiblement la barre des tâches Windows confondue avec un monitor, + # ou un état mss corrompu. Reproductible même PC en mono physique. Cause + # exacte non isolée côté client (cf. session_20260506_handoff_v2.md). + # Les heartbeats (capturer.py, chemin différent de executor.py) restent en + # full screen 2560x1600. On compense ici en remplaçant l'image tronquée + # par le dernier heartbeat avant la cascade _resolve_target_sync. + effective_w = request.screen_width + effective_h = request.screen_height + if img.height < 200 or img.width < 400: + logger.warning( + "[RESOLVE_TARGET] Image client tronquée %dx%d (declared %dx%d) — " + "fallback heartbeat full screen", + img.width, img.height, effective_w, effective_h, + ) + # Source 1 : _last_heartbeat (mémoire, peuplé par /stream/image) + candidate_path = None + candidate_age_s = None + latest_hb = max( + (h for h in _last_heartbeat.values() if h.get("path")), + key=lambda h: h.get("timestamp", 0), + default=None, + ) + if latest_hb and os.path.isfile(latest_hb["path"]): + candidate_path = latest_hb["path"] + candidate_age_s = time.time() - latest_hb.get("timestamp", time.time()) + else: + # Source 2 : scan disque (utile après restart serveur, avant que + # _last_heartbeat ne se repeuple — ou si l'agent V1 ne polle pas) + try: + import glob as _glob + pattern = "/home/dom/ai/rpa_vision_v3/data/training/live_sessions/*/bg_*/shots/heartbeat_*.png" + all_files = _glob.glob(pattern) + files = [ + f for f in all_files + if "_blurred" not in f and os.path.isfile(f) + ] + logger.info( + "[RESOLVE_TARGET] Scan disque : %d match glob, %d non-blurred existants", + len(all_files), len(files), + ) + if files: + files.sort(key=lambda f: os.path.getmtime(f), reverse=True) + candidate_path = files[0] + candidate_age_s = time.time() - os.path.getmtime(candidate_path) + except Exception as e: + logger.warning("[RESOLVE_TARGET] Scan disque heartbeat échoué : %s", e) + + if candidate_path: + try: + img = Image.open(candidate_path) + effective_w, effective_h = img.size + logger.info( + "[RESOLVE_TARGET] Heartbeat fallback OK : %s (%dx%d, age=%.1fs)", + candidate_path, effective_w, effective_h, candidate_age_s or -1, + ) + except Exception as e: + logger.warning("[RESOLVE_TARGET] Ouverture heartbeat échouée : %s", e) + else: + logger.warning("[RESOLVE_TARGET] Aucun heartbeat disponible pour fallback") + # Sauver temporairement pour les analyseurs (ils attendent un chemin fichier) with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: img.save(tmp, format="JPEG", quality=90) @@ -4415,8 +4478,8 @@ async def resolve_target(request: ResolveTargetRequest): _resolve_target_sync, tmp_path, request.target_spec, - request.screen_width, - request.screen_height, + effective_w, + effective_h, request.fallback_x_pct, request.fallback_y_pct, request.strict_mode, @@ -4447,7 +4510,8 @@ async def resolve_target(request: ResolveTargetRequest): logger.error(f"[REPLAY] RESOLVE_EXCEPTION session={request.session_id} error={e}") return _fallback_response(request, "analysis_error", str(e)) finally: - import os + # `os` est déjà importé en haut du fichier — pas de re-import local + # (sinon UnboundLocalError plus haut dans la fonction). try: os.unlink(tmp_path) except OSError: