Backup état complet après enregistrement vidéo démo de bout en bout. À utiliser comme point de référence pour la consolidation post-démo. Changements majeurs de la session 18-19 mai : - AIVA-URGENCE : page autonome avec preset URL + auto-focus chain - Workflow Demo_urgence_3_db : merge linux_db + steps AIVA + pause humaine NoMachine - Bypass LLM (static_result / static_text) dans replay_engine pour démos déterministes sans appel Ollama - Fix api_stream:3013 — replay_paused au premier polling /next - dag_execute : lift duration_ms vers top-level pour wait runtime - NPM bypass auth /aiva-urgence/ via location ^~ (proxy_host/10.conf hors git) - scripts/cancel-replays.sh — workaround Stop VWB qui ne purge pas la queue Anchors visuels (468) forcés dans le commit pour garantir restorabilité. DB workflows actuelle + ~12 .bak DB de la journée incluses. Sujets identifiés pour consolidation post-démo (TODO) : 1. Bug VWB recapture anchor ne régénère pas le PNG 2. Léa client accumule état mémoire (restart périodique requis) 3. Stop VWB ne purge pas la queue serveur (lien manquant vers /replay/cancel) 4. Bug coord client mss tronqué 2560x60 → mapping Y cassé 5. delay_before/delay_after ignorés au runtime (fix partiel duration_ms) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
291 lines
11 KiB
Python
291 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
core/grounding/infigui_server.py — Service grounding persistant InfiGUI
|
|
|
|
Charge InfiGUI-G1-3B en 4-bit une fois (~2.4 GB VRAM), puis sert les requêtes
|
|
de grounding via un Unix socket. Évite le coût de chargement (~10s) à chaque
|
|
appel que paie le subprocess one-shot.
|
|
|
|
Protocole (length-prefixed JSON) :
|
|
Requête : [4 octets uint32 BE = longueur] + payload JSON UTF-8
|
|
Réponse : [4 octets uint32 BE = longueur] + payload JSON UTF-8
|
|
|
|
Opérations supportées (champ "op", défaut "ground") :
|
|
- "ping" → {"ok": true, "vram_gb": float, "uptime_s": float}
|
|
- "ground" → {"x": int|None, "y": int|None, "confidence": float, ...}
|
|
- "shutdown" → {"ok": true} puis arrêt propre du serveur
|
|
|
|
Le payload "ground" reprend exactement le format de l'ancien worker one-shot :
|
|
{"target": str, "description": str, "image_path": str, "anchor_image_path": str}
|
|
|
|
Les images restent passées via fichiers (/tmp/...) — pas de bytes sur le socket.
|
|
|
|
Lancement (manuel ou via systemd user unit rpa-grounding.service) :
|
|
cd ~/ai/rpa_vision_v3
|
|
.venv/bin/python -m core.grounding.infigui_server
|
|
|
|
Variables d'environnement :
|
|
RPA_GROUNDING_SOCKET chemin du socket (défaut: $XDG_RUNTIME_DIR/rpa-grounding.sock
|
|
sinon /tmp/rpa-grounding.sock)
|
|
RPA_GROUNDING_BACKLOG taille listen backlog (défaut: 4)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import signal
|
|
import socket
|
|
import struct
|
|
import sys
|
|
import threading
|
|
import time
|
|
import traceback
|
|
from typing import Any, Dict, Optional
|
|
|
|
# Réutilise la logique de chargement et d'inférence du worker one-shot.
|
|
# load_model() et infer() sont conçus pour être appelés en process indépendant ;
|
|
# on les appelle ici dans un process unique de longue durée.
|
|
from core.grounding.infigui_worker import infer, load_model
|
|
|
|
|
|
# ── Configuration ────────────────────────────────────────────────────────
|
|
|
|
|
|
def _default_socket_path() -> str:
|
|
# /run/rpa/ est la convention "production" (RuntimeDirectory=rpa partagé
|
|
# entre les services systemd). Cohérent avec ui_tars_grounder._default_socket_path.
|
|
if os.path.isdir("/run/rpa"):
|
|
return "/run/rpa/grounding.sock"
|
|
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
|
|
if runtime_dir and os.path.isdir(runtime_dir):
|
|
return os.path.join(runtime_dir, "rpa-grounding.sock")
|
|
return "/tmp/rpa-grounding.sock"
|
|
|
|
|
|
SOCKET_PATH = os.environ.get("RPA_GROUNDING_SOCKET") or _default_socket_path()
|
|
LISTEN_BACKLOG = int(os.environ.get("RPA_GROUNDING_BACKLOG", "4"))
|
|
|
|
# Limite raisonnable pour un payload JSON (la requête contient juste des chemins
|
|
# et du texte court — 4 MB suffit largement pour parer un client buggé).
|
|
MAX_PAYLOAD_BYTES = 4 * 1024 * 1024
|
|
|
|
|
|
# ── Protocole length-prefixed ────────────────────────────────────────────
|
|
|
|
|
|
def _recv_exact(conn: socket.socket, n: int) -> Optional[bytes]:
|
|
"""Lit exactement n octets ou retourne None si la connexion ferme avant."""
|
|
chunks = []
|
|
remaining = n
|
|
while remaining > 0:
|
|
chunk = conn.recv(remaining)
|
|
if not chunk:
|
|
return None
|
|
chunks.append(chunk)
|
|
remaining -= len(chunk)
|
|
return b"".join(chunks)
|
|
|
|
|
|
def recv_message(conn: socket.socket) -> Optional[Dict[str, Any]]:
|
|
header = _recv_exact(conn, 4)
|
|
if header is None:
|
|
return None
|
|
(length,) = struct.unpack(">I", header)
|
|
if length == 0 or length > MAX_PAYLOAD_BYTES:
|
|
raise ValueError(f"Longueur payload invalide: {length}")
|
|
payload = _recv_exact(conn, length)
|
|
if payload is None:
|
|
return None
|
|
return json.loads(payload.decode("utf-8"))
|
|
|
|
|
|
def send_message(conn: socket.socket, obj: Dict[str, Any]) -> None:
|
|
payload = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
|
conn.sendall(struct.pack(">I", len(payload)) + payload)
|
|
|
|
|
|
# ── Serveur ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class InfiGUIServer:
|
|
"""Daemon qui sert les requêtes de grounding sur un Unix socket."""
|
|
|
|
def __init__(self, socket_path: str = SOCKET_PATH):
|
|
self.socket_path = socket_path
|
|
self._server_sock: Optional[socket.socket] = None
|
|
self._stop = threading.Event()
|
|
# CUDA n'est pas thread-safe sur le même modèle ; sérialise les inférences.
|
|
self._infer_lock = threading.Lock()
|
|
self._model = None
|
|
self._processor = None
|
|
self._start_time = time.time()
|
|
self._request_count = 0
|
|
|
|
# ── Lifecycle ────────────────────────────────────────────────────────
|
|
|
|
def start(self) -> None:
|
|
# 1. Charger le modèle AVANT d'ouvrir le socket : si le chargement échoue,
|
|
# on n'expose pas un endpoint à moitié fonctionnel aux clients.
|
|
print(f"[infigui-server] Chargement InfiGUI-G1-3B...")
|
|
self._model, self._processor = load_model()
|
|
|
|
# 2. Ouvrir le Unix socket (suppression d'un éventuel ancien socket fantôme)
|
|
try:
|
|
os.unlink(self.socket_path)
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
self._server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
self._server_sock.bind(self.socket_path)
|
|
# Permissions : owner-only (rw-------) pour éviter les autres utilisateurs
|
|
os.chmod(self.socket_path, 0o600)
|
|
self._server_sock.listen(LISTEN_BACKLOG)
|
|
# Petit timeout sur accept pour que la boucle réagisse aux signaux
|
|
self._server_sock.settimeout(1.0)
|
|
|
|
print(f"[infigui-server] Écoute sur {self.socket_path}")
|
|
|
|
# 3. Signaux d'arrêt propre
|
|
signal.signal(signal.SIGTERM, self._on_signal)
|
|
signal.signal(signal.SIGINT, self._on_signal)
|
|
|
|
# 4. Boucle accept
|
|
try:
|
|
while not self._stop.is_set():
|
|
try:
|
|
conn, _ = self._server_sock.accept()
|
|
except socket.timeout:
|
|
continue
|
|
except OSError:
|
|
if self._stop.is_set():
|
|
break
|
|
raise
|
|
# Une requête à la fois (CUDA non thread-safe). On gère néanmoins
|
|
# la connexion dans un thread pour pouvoir lire/écrire sans bloquer
|
|
# l'accept boucle quand le client traîne — l'inférence elle-même
|
|
# est sérialisée par self._infer_lock.
|
|
threading.Thread(
|
|
target=self._handle_client,
|
|
args=(conn,),
|
|
daemon=True,
|
|
).start()
|
|
finally:
|
|
self._cleanup()
|
|
|
|
def _on_signal(self, signum, _frame) -> None:
|
|
print(f"[infigui-server] Signal {signum} reçu, arrêt...")
|
|
self._stop.set()
|
|
# Casse un éventuel accept() bloqué
|
|
try:
|
|
if self._server_sock is not None:
|
|
self._server_sock.close()
|
|
except OSError:
|
|
pass
|
|
|
|
def _cleanup(self) -> None:
|
|
try:
|
|
if self._server_sock is not None:
|
|
self._server_sock.close()
|
|
except OSError:
|
|
pass
|
|
try:
|
|
os.unlink(self.socket_path)
|
|
except FileNotFoundError:
|
|
pass
|
|
print(f"[infigui-server] Arrêté ({self._request_count} requêtes traitées)")
|
|
|
|
# ── Gestion d'une connexion ──────────────────────────────────────────
|
|
|
|
def _handle_client(self, conn: socket.socket) -> None:
|
|
try:
|
|
# Une connexion = N requêtes (keep-alive). Le client peut envoyer
|
|
# plusieurs grounds successifs sans repayer le coût TCP/socket.
|
|
while not self._stop.is_set():
|
|
try:
|
|
req = recv_message(conn)
|
|
except (ValueError, json.JSONDecodeError) as e:
|
|
self._safe_send(conn, {"error": f"requête invalide: {e}"})
|
|
return
|
|
if req is None:
|
|
return # client a fermé proprement
|
|
|
|
op = req.get("op", "ground")
|
|
if op == "ping":
|
|
self._safe_send(conn, self._do_ping())
|
|
elif op == "shutdown":
|
|
self._safe_send(conn, {"ok": True})
|
|
self._stop.set()
|
|
try:
|
|
if self._server_sock is not None:
|
|
self._server_sock.close()
|
|
except OSError:
|
|
pass
|
|
return
|
|
elif op == "ground":
|
|
resp = self._do_ground(req)
|
|
self._safe_send(conn, resp)
|
|
else:
|
|
self._safe_send(conn, {"error": f"op inconnue: {op}"})
|
|
except Exception as e:
|
|
traceback.print_exc()
|
|
self._safe_send(conn, {"error": str(e)})
|
|
finally:
|
|
try:
|
|
conn.close()
|
|
except OSError:
|
|
pass
|
|
|
|
def _safe_send(self, conn: socket.socket, obj: Dict[str, Any]) -> None:
|
|
try:
|
|
send_message(conn, obj)
|
|
except OSError:
|
|
# Client parti ; pas de quoi paniquer
|
|
pass
|
|
|
|
# ── Opérations ───────────────────────────────────────────────────────
|
|
|
|
def _do_ping(self) -> Dict[str, Any]:
|
|
try:
|
|
import torch
|
|
|
|
vram_gb = round(torch.cuda.memory_allocated() / 1e9, 2) if torch.cuda.is_available() else 0.0
|
|
except Exception:
|
|
vram_gb = 0.0
|
|
return {
|
|
"ok": True,
|
|
"vram_gb": vram_gb,
|
|
"uptime_s": round(time.time() - self._start_time, 1),
|
|
"requests": self._request_count,
|
|
}
|
|
|
|
def _do_ground(self, req: Dict[str, Any]) -> Dict[str, Any]:
|
|
with self._infer_lock:
|
|
self._request_count += 1
|
|
try:
|
|
return infer(self._model, self._processor, req)
|
|
except Exception as e:
|
|
traceback.print_exc()
|
|
return {
|
|
"x": None,
|
|
"y": None,
|
|
"error": str(e),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
server = InfiGUIServer()
|
|
try:
|
|
server.start()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
except Exception as e:
|
|
print(f"[infigui-server] Erreur fatale: {e}")
|
|
traceback.print_exc()
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|