514 lines
19 KiB
Python
514 lines
19 KiB
Python
# agent_v0/server_v1/run_worker.py
|
|
"""
|
|
Worker VLM autonome — tourne dans un process Python SEPARE du serveur HTTP.
|
|
|
|
Résout le problème du GIL : le serveur HTTP (FastAPI) reste réactif car le
|
|
VLM (ScreenAnalyzer, CLIP, FAISS, GraphBuilder) tourne dans ce process dédié.
|
|
|
|
Usage:
|
|
python -m agent_v0.server_v1.run_worker
|
|
|
|
Architecture :
|
|
Process 1 : Serveur HTTP (FastAPI, port 5005) — distribue les replays, reçoit events/images
|
|
Process 2 : Ce worker — analyse VLM des sessions finalisées
|
|
Process 3 : Ollama (port 11434) — LLM local
|
|
|
|
Communication inter-process par fichiers (pas de Redis) :
|
|
- _worker_queue.txt : liste des session_ids à traiter (ajoutés par le serveur HTTP)
|
|
- _replay_active.lock : quand présent, le worker se suspend (le GPU est utilisé par le replay)
|
|
|
|
Le worker :
|
|
1. Scanne _worker_queue.txt pour trouver les sessions à traiter
|
|
2. Vérifie _replay_active.lock avant chaque screenshot (priorité au replay)
|
|
3. Traite les sessions une par une (VLM + CLIP + GraphBuilder)
|
|
4. Sauvegarde les workflows JSON sur disque
|
|
5. Se suspend quand un replay est actif (libère le GPU)
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
logger = logging.getLogger("vlm_worker")
|
|
|
|
# Chemins de base (relatifs au working directory = racine du projet)
|
|
ROOT_DIR = Path(__file__).parent.parent.parent
|
|
DATA_DIR = ROOT_DIR / "data" / "training"
|
|
LIVE_SESSIONS_DIR = DATA_DIR / "live_sessions"
|
|
QUEUE_FILE = DATA_DIR / "_worker_queue.txt"
|
|
REPLAY_LOCK = DATA_DIR / "_replay_active.lock"
|
|
|
|
# Intervalle de polling quand la queue est vide (secondes)
|
|
POLL_INTERVAL = 10
|
|
|
|
# Intervalle de vérification du replay lock (secondes)
|
|
REPLAY_CHECK_INTERVAL = 2
|
|
|
|
# Timeout max d'attente du replay lock avant reprise forcée (secondes)
|
|
REPLAY_WAIT_TIMEOUT = 120
|
|
|
|
|
|
class VLMWorker:
|
|
"""Worker VLM autonome qui traite les sessions finalisées.
|
|
|
|
Tourne en boucle infinie dans un process séparé du serveur HTTP.
|
|
Communique via le filesystem :
|
|
- Lit les session_ids depuis _worker_queue.txt
|
|
- Vérifie _replay_active.lock pour se suspendre
|
|
- Écrit les workflows dans data/training/workflows/
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._running = False
|
|
self._processor = None # Initialisé au premier besoin (lazy loading GPU)
|
|
self._current_session: Optional[str] = None
|
|
self._started_at: str = datetime.now().isoformat()
|
|
|
|
# Stats
|
|
self._stats: Dict[str, int] = {
|
|
"sessions_processed": 0,
|
|
"sessions_failed": 0,
|
|
"sessions_skipped": 0,
|
|
"total_screenshots_analyzed": 0,
|
|
}
|
|
self._completed: List[Dict] = []
|
|
self._failed: List[Dict] = []
|
|
|
|
def _get_processor(self):
|
|
"""Lazy init du StreamProcessor (charge les modèles GPU au premier appel)."""
|
|
if self._processor is None:
|
|
logger.info("Initialisation du StreamProcessor (chargement GPU)...")
|
|
from .stream_processor import StreamProcessor
|
|
self._processor = StreamProcessor(
|
|
data_dir=str(DATA_DIR),
|
|
enable_vlm=True,
|
|
)
|
|
logger.info("StreamProcessor initialisé.")
|
|
return self._processor
|
|
|
|
def start(self):
|
|
"""Boucle principale du worker."""
|
|
self._running = True
|
|
logger.info(
|
|
"VLM Worker démarré — surveillance de %s",
|
|
QUEUE_FILE,
|
|
)
|
|
logger.info(" Replay lock : %s", REPLAY_LOCK)
|
|
logger.info(" Sessions dir : %s", LIVE_SESSIONS_DIR)
|
|
logger.info(" Poll interval : %ds", POLL_INTERVAL)
|
|
|
|
# N2 + N3 : santé initiale + signal READY systemd dès le démarrage
|
|
# (avant tout chargement GPU, pour ne pas dépasser le timeout de start).
|
|
self._write_health("healthy")
|
|
self._sd_notify("READY=1")
|
|
|
|
while self._running:
|
|
try:
|
|
# Vérifier si un replay est actif
|
|
if self._is_replay_active():
|
|
self._wait_for_replay_end()
|
|
continue
|
|
|
|
# Lire la prochaine session de la queue
|
|
session_id = self._read_next_session()
|
|
if session_id:
|
|
self._process_session(session_id)
|
|
else:
|
|
self._write_health("healthy") # N2 : cycle idle
|
|
time.sleep(POLL_INTERVAL)
|
|
|
|
except KeyboardInterrupt:
|
|
logger.info("Interruption clavier, arrêt du worker.")
|
|
self._running = False
|
|
except Exception as e:
|
|
logger.error("Erreur dans la boucle principale : %s", e, exc_info=True)
|
|
time.sleep(5) # Éviter une boucle d'erreurs rapide
|
|
|
|
self._write_health("stopped") # N2 : santé finale
|
|
logger.info("VLM Worker arrêté.")
|
|
|
|
def stop(self):
|
|
"""Arrêt propre du worker."""
|
|
self._running = False
|
|
logger.info("Arrêt demandé.")
|
|
|
|
# =========================================================================
|
|
# N2 — Health file (_worker_health.json)
|
|
# =========================================================================
|
|
#
|
|
# Garde-fou anti-blocage silencieux : expose l'état de santé du worker sur
|
|
# disque pour qu'un superviseur (humain, dashboard, watchdog) détecte un
|
|
# worker dégradé sans avoir à fouiller les logs. Écriture atomique.
|
|
#
|
|
# CONFIDENTIALITÉ (HDS) : n'écrit AUCUNE donnée patient — uniquement des
|
|
# identifiants techniques (session_id), des compteurs et des booléens de
|
|
# composants. Jamais d'OCR, de noms de fichiers screenshots, ni de contenu
|
|
# de session.
|
|
|
|
def _sd_notify(self, state: str) -> bool:
|
|
"""Notifie systemd via $NOTIFY_SOCKET, sans dépendance `systemd.daemon`.
|
|
|
|
Implémentation pure socket (AF_UNIX SOCK_DGRAM) : fonctionne sous systemd
|
|
`Type=notify` pour `READY=1` et le heartbeat `WATCHDOG=1`. No-op silencieux
|
|
hors systemd (variable absente) ou en cas d'erreur — jamais bloquant.
|
|
Retourne True si le message a été émis.
|
|
"""
|
|
addr = os.environ.get("NOTIFY_SOCKET")
|
|
if not addr:
|
|
return False
|
|
try:
|
|
import socket
|
|
|
|
# Namespace abstrait systemd : '@' → octet nul de préfixe
|
|
connect_addr = "\0" + addr[1:] if addr.startswith("@") else addr
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock:
|
|
sock.connect(connect_addr)
|
|
sock.sendall(state.encode("utf-8"))
|
|
return True
|
|
except Exception as e:
|
|
logger.debug("sd_notify(%s) échoué : %s", state, e)
|
|
return False
|
|
|
|
def _health_components(self) -> Dict[str, bool]:
|
|
"""Statut booléen de chaque composant lourd, dérivé du processor."""
|
|
proc = self._processor
|
|
return {
|
|
"screen_analyzer": proc is not None and getattr(proc, "_screen_analyzer", None) is not None,
|
|
"clip_embedder": proc is not None and getattr(proc, "_clip_embedder", None) is not None,
|
|
"faiss_manager": proc is not None and getattr(proc, "_faiss_manager", None) is not None,
|
|
"state_embedding_builder": proc is not None and getattr(proc, "_state_embedding_builder", None) is not None,
|
|
}
|
|
|
|
def _write_health(self, status: str) -> None:
|
|
"""Écrit data/training/_worker_health.json de façon atomique.
|
|
|
|
`status` attendu : healthy | busy | degraded | stopped. Si le worker
|
|
tourne en mode VLM mais que ScreenAnalyzer est absent, le statut est
|
|
forcé à 'degraded' quelle que soit la valeur demandée.
|
|
"""
|
|
try:
|
|
components = self._health_components()
|
|
|
|
proc = self._processor
|
|
vlm_mode = proc is not None and getattr(proc, "_enable_vlm", False)
|
|
if vlm_mode and not components["screen_analyzer"]:
|
|
status = "degraded"
|
|
|
|
queue_path = DATA_DIR / "_worker_queue.txt"
|
|
try:
|
|
queue_length = len(
|
|
[ln for ln in queue_path.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
|
) if queue_path.exists() else 0
|
|
except Exception:
|
|
queue_length = 0
|
|
|
|
payload = {
|
|
"pid": os.getpid(),
|
|
"started_at": self._started_at,
|
|
"last_cycle": datetime.now().isoformat(),
|
|
"current_session": self._current_session,
|
|
"queue_length": queue_length,
|
|
"components": components,
|
|
"stats": dict(self._stats),
|
|
"status": status,
|
|
}
|
|
|
|
health_path = DATA_DIR / "_worker_health.json"
|
|
tmp_path = health_path.with_suffix(".json.tmp")
|
|
tmp_path.write_text(
|
|
json.dumps(payload, ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
tmp_path.rename(health_path)
|
|
except Exception as e:
|
|
# Le health file est un garde-fou, jamais un point de défaillance.
|
|
logger.warning("Écriture health file échouée : %s", e)
|
|
|
|
# N3 : chaque écriture santé sert aussi de heartbeat watchdog systemd
|
|
# (sauf à l'arrêt). No-op hors systemd.
|
|
if status != "stopped":
|
|
self._sd_notify("WATCHDOG=1")
|
|
|
|
# =========================================================================
|
|
# Queue management (fichier _worker_queue.txt)
|
|
# =========================================================================
|
|
|
|
def _read_next_session(self) -> Optional[str]:
|
|
"""Lit et retire le premier session_id de la queue.
|
|
|
|
Format du fichier : une ligne par session_id.
|
|
Retire la ligne traitée de façon atomique (réécriture complète).
|
|
"""
|
|
if not QUEUE_FILE.exists():
|
|
return None
|
|
|
|
try:
|
|
lines = QUEUE_FILE.read_text(encoding="utf-8").strip().splitlines()
|
|
if not lines:
|
|
return None
|
|
|
|
# Prendre le premier session_id non vide
|
|
session_id = None
|
|
remaining = []
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
if session_id is None:
|
|
session_id = line
|
|
else:
|
|
remaining.append(line)
|
|
|
|
# Réécrire le fichier sans la première ligne (atomique via rename)
|
|
tmp_file = QUEUE_FILE.with_suffix(".tmp")
|
|
if remaining:
|
|
tmp_file.write_text(
|
|
"\n".join(remaining) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
else:
|
|
tmp_file.write_text("", encoding="utf-8")
|
|
tmp_file.rename(QUEUE_FILE)
|
|
|
|
if session_id:
|
|
logger.info(
|
|
"Session déqueuée : %s (%d restantes dans la queue)",
|
|
session_id,
|
|
len(remaining),
|
|
)
|
|
return session_id
|
|
|
|
except Exception as e:
|
|
logger.error("Erreur lecture queue %s : %s", QUEUE_FILE, e)
|
|
return None
|
|
|
|
# =========================================================================
|
|
# Replay lock (_replay_active.lock)
|
|
# =========================================================================
|
|
|
|
def _is_replay_active(self) -> bool:
|
|
"""Vérifie si un replay est en cours (fichier lock présent)."""
|
|
return REPLAY_LOCK.exists()
|
|
|
|
def _wait_for_replay_end(self):
|
|
"""Attend que le replay se termine (suppression du fichier lock).
|
|
|
|
Timeout de sécurité : REPLAY_WAIT_TIMEOUT secondes max.
|
|
"""
|
|
start = time.time()
|
|
logger.info(
|
|
"Replay actif détecté (%s), worker en pause...",
|
|
REPLAY_LOCK,
|
|
)
|
|
|
|
while self._running and REPLAY_LOCK.exists():
|
|
elapsed = time.time() - start
|
|
if elapsed > REPLAY_WAIT_TIMEOUT:
|
|
logger.warning(
|
|
"Timeout d'attente du replay (%ds), reprise forcée.",
|
|
REPLAY_WAIT_TIMEOUT,
|
|
)
|
|
break
|
|
# N3 : heartbeat pendant la pause replay (peut durer jusqu'à 120s,
|
|
# sinon le watchdog tuerait un worker pourtant sain et en attente).
|
|
self._sd_notify("WATCHDOG=1")
|
|
time.sleep(REPLAY_CHECK_INTERVAL)
|
|
|
|
elapsed = time.time() - start
|
|
if elapsed > 0.5:
|
|
logger.info("Replay terminé, worker reprend après %.1fs de pause.", elapsed)
|
|
|
|
# =========================================================================
|
|
# Traitement d'une session
|
|
# =========================================================================
|
|
|
|
def _process_session(self, session_id: str):
|
|
"""Traite une session complète (analyse VLM + construction workflow)."""
|
|
self._current_session = session_id
|
|
logger.info("=== Début traitement session %s ===", session_id)
|
|
self._write_health("busy") # N2 : début de session
|
|
start_time = time.time()
|
|
|
|
try:
|
|
proc = self._get_processor()
|
|
|
|
# Vérifier que le dossier session existe
|
|
session_dir = proc._find_session_dir(session_id)
|
|
if not session_dir:
|
|
logger.error(
|
|
"Dossier session %s introuvable, skip.",
|
|
session_id,
|
|
)
|
|
self._stats["sessions_skipped"] += 1
|
|
return
|
|
|
|
shots_dir = session_dir / "shots"
|
|
full_shots = sorted(shots_dir.glob("shot_*_full.png")) if shots_dir.exists() else []
|
|
if not full_shots:
|
|
logger.warning(
|
|
"Aucun screenshot full dans %s, skip.",
|
|
shots_dir,
|
|
)
|
|
self._stats["sessions_skipped"] += 1
|
|
return
|
|
|
|
logger.info(
|
|
"Session %s : %d screenshots full à analyser dans %s",
|
|
session_id,
|
|
len(full_shots),
|
|
shots_dir,
|
|
)
|
|
|
|
# Utiliser reprocess_session du StreamProcessor
|
|
# qui fait : ScreenAnalyzer + CLIP + FAISS + GraphBuilder
|
|
result = proc.reprocess_session(
|
|
session_id,
|
|
progress_callback=self._progress_callback,
|
|
)
|
|
|
|
elapsed = time.time() - start_time
|
|
|
|
if result.get("error"):
|
|
logger.error(
|
|
"Échec session %s après %.1fs : %s",
|
|
session_id,
|
|
elapsed,
|
|
result["error"],
|
|
)
|
|
self._stats["sessions_failed"] += 1
|
|
self._failed.append({
|
|
"session_id": session_id,
|
|
"error": result["error"],
|
|
"elapsed_seconds": round(elapsed, 1),
|
|
"timestamp": datetime.now().isoformat(),
|
|
})
|
|
elif result.get("status") == "insufficient_data":
|
|
logger.warning(
|
|
"Session %s : données insuffisantes (%d states) après %.1fs",
|
|
session_id,
|
|
result.get("states_count", 0),
|
|
elapsed,
|
|
)
|
|
self._stats["sessions_failed"] += 1
|
|
self._failed.append({
|
|
"session_id": session_id,
|
|
"error": "insufficient_data",
|
|
"states_count": result.get("states_count", 0),
|
|
"elapsed_seconds": round(elapsed, 1),
|
|
"timestamp": datetime.now().isoformat(),
|
|
})
|
|
else:
|
|
logger.info(
|
|
"Session %s traitée en %.1fs | workflow=%s | %d nodes, %d edges",
|
|
session_id,
|
|
elapsed,
|
|
result.get("workflow_id", "?"),
|
|
result.get("nodes", 0),
|
|
result.get("edges", 0),
|
|
)
|
|
self._stats["sessions_processed"] += 1
|
|
self._stats["total_screenshots_analyzed"] += result.get("states_analyzed", 0)
|
|
self._completed.append({
|
|
"session_id": session_id,
|
|
"workflow_id": result.get("workflow_id"),
|
|
"workflow_name": result.get("workflow_name"),
|
|
"nodes": result.get("nodes", 0),
|
|
"edges": result.get("edges", 0),
|
|
"states_analyzed": result.get("states_analyzed", 0),
|
|
"elapsed_seconds": round(elapsed, 1),
|
|
"timestamp": datetime.now().isoformat(),
|
|
})
|
|
|
|
except Exception as e:
|
|
elapsed = time.time() - start_time
|
|
logger.error(
|
|
"Exception inattendue pour session %s après %.1fs : %s",
|
|
session_id,
|
|
elapsed,
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
self._stats["sessions_failed"] += 1
|
|
self._failed.append({
|
|
"session_id": session_id,
|
|
"error": f"exception: {e}",
|
|
"elapsed_seconds": round(elapsed, 1),
|
|
"timestamp": datetime.now().isoformat(),
|
|
})
|
|
|
|
finally:
|
|
self._current_session = None
|
|
self._write_health("healthy") # N2 : fin de session (ou degraded auto)
|
|
|
|
logger.info("=== Fin traitement session %s ===", session_id)
|
|
|
|
def _progress_callback(self, session_id: str, current: int, total: int, shot_id: str = ""):
|
|
"""Callback de progression appelé par reprocess_session.
|
|
|
|
Vérifie aussi le replay lock entre chaque screenshot.
|
|
"""
|
|
logger.info(
|
|
"Session %s : screenshot %d/%d%s",
|
|
session_id,
|
|
current,
|
|
total,
|
|
f" ({shot_id})" if shot_id else "",
|
|
)
|
|
|
|
self._write_health("busy") # N2 : heartbeat à chaque screenshot
|
|
|
|
# Vérifier si un replay est devenu actif pendant le traitement
|
|
if self._is_replay_active():
|
|
logger.info(
|
|
"Replay détecté pendant l'analyse de %s, pause...",
|
|
session_id,
|
|
)
|
|
self._wait_for_replay_end()
|
|
|
|
|
|
def main():
|
|
"""Point d'entrée du worker VLM autonome."""
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [VLM-WORKER] %(levelname)s %(message)s",
|
|
)
|
|
|
|
# Réduire le bruit des loggers tiers
|
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
|
|
|
# Créer les dossiers nécessaires
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
LIVE_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
worker = VLMWorker()
|
|
|
|
# Gestion propre des signaux
|
|
def _handle_signal(signum, frame):
|
|
logger.info("Signal %s reçu, arrêt en cours...", signal.Signals(signum).name)
|
|
worker.stop()
|
|
|
|
signal.signal(signal.SIGTERM, _handle_signal)
|
|
signal.signal(signal.SIGINT, _handle_signal)
|
|
|
|
# Afficher l'état au démarrage
|
|
print(f"\n{'='*60}")
|
|
print(f" VLM Worker — Process séparé du serveur HTTP")
|
|
print(f" Queue : {QUEUE_FILE}")
|
|
print(f" Lock : {REPLAY_LOCK}")
|
|
print(f" PID : {os.getpid()}")
|
|
print(f"{'='*60}\n")
|
|
|
|
worker.start()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|