fix(dashboard,worker): vérité produit P0 — dashboard+worker+VWB export
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m46s
tests / Tests unitaires (sans GPU) (push) Failing after 2m0s
tests / Tests sécurité (critique) (push) Has been skipped

War-room clôture DGX 2026-06-18 (recadrage Dom : graphe/apprentissage/mémoire/dashboard = surface produit P0).
Le dashboard et le statut worker affichaient des états faux ; corrige pour refléter la vérité du produit.

- dashboard FAISS: distingue index brut / metadata HMAC invalide / runtime / absent (plus de faux "inactif")
- dashboard process-mining: 503 explicite missing_dependency (plus de message trompeur)
- dashboard /api/workflows + system/status: lecture DB VWB v3 canonique (total réel = 24, plus de 0)
- worker /processing/status: véridique (lit _worker_health.json) + statut "idle/armé (lazy)" distinct de "dégradé (échec)"
- VWB export: N steps -> N actions/edges (dernière action n'est plus perdue)
- tests: dashboard routes, worker status truthfulness, export VWB

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-06-18 17:50:12 +02:00
parent 6d5ef51c60
commit ec1fb81054
8 changed files with 626 additions and 56 deletions

View File

@@ -555,6 +555,7 @@ LIVE_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
_DATA_DIR = ROOT_DIR / "data" / "training"
WORKER_QUEUE_FILE = _DATA_DIR / "_worker_queue.txt"
REPLAY_LOCK_FILE = _DATA_DIR / "_replay_active.lock"
WORKER_HEALTH_FILE = _DATA_DIR / "_worker_health.json"
# Instance globale partagée (le StreamProcessor reste dans le serveur HTTP
# pour le CLIP, l'indexation FAISS, la gestion des sessions, le replay —
@@ -807,7 +808,7 @@ def _memory_window_title_for_action(action_meta: Dict[str, Any]) -> str:
def _get_worker_queue_status() -> Dict[str, Any]:
"""Retourne l'état de la queue du worker VLM (pour le monitoring)."""
"""Retourne l'état réel de la queue et du worker VLM (pour le monitoring)."""
queue = []
if WORKER_QUEUE_FILE.exists():
try:
@@ -819,16 +820,108 @@ def _get_worker_queue_status() -> Dict[str, Any]:
except Exception:
pass
health = None
health_error = None
health_age_seconds = None
if WORKER_HEALTH_FILE.exists():
try:
health = json.loads(WORKER_HEALTH_FILE.read_text(encoding="utf-8"))
health_age_seconds = max(0.0, time.time() - WORKER_HEALTH_FILE.stat().st_mtime)
except Exception as exc:
health_error = str(exc)
health_stale = health_age_seconds is None or health_age_seconds > 180
components = (health or {}).get("components") or {}
components_ready = bool(components) and all(bool(v) for v in components.values())
health_status = (health or {}).get("status")
running = bool(health) and not health_stale and health_status != "stopped"
# Distinction VEILLE (armé, lazy) vs DÉGRADÉ (vrai échec).
#
# Les composants lourds (ScreenAnalyzer/CLIP/FAISS/StateEmbedding) sont
# chargés en lazy par run_worker : le processor n'est instancié qu'au
# premier _process_session (cf. run_worker._get_processor / _process_session).
# Un worker neuf qui n'a jamais reçu de session écrit donc status="healthy"
# avec tous les composants à false — c'est l'état NORMAL « en veille », pas
# une panne. L'étiqueter "degraded" fait lire une panne là où il n'y en a pas.
#
# Signal retenu pour « init jamais tentée » : TOUS les composants à false ET
# sessions_processed == 0 ET sessions_failed == 0. Justification : run_worker
# n'appelle _get_processor() (donc l'init lazy) que dans _process_session, qui
# incrémente toujours exactement un compteur (processed / failed / skipped).
# Tant que processed == 0 ET failed == 0, aucune session n'a déclenché une
# init suivie d'un traitement — le worker est armé en attente. Un simple skip
# (dossier/shots absents) passe quand même par _get_processor() : les
# composants se chargent, donc tous-à-false devient faux et on n'entre pas ici.
# run_worker._health_components() écrit toujours les 4 clés (jamais un dict
# vide), d'où le test sur les VALEURS et non sur la présence des clés.
# Si run_worker a lui-même forcé status="degraded" (VLM + ScreenAnalyzer
# absent, cf. run_worker._write_health), c'est un VRAI échec : on le conserve.
stats = (health or {}).get("stats") or {}
init_attempted = bool(stats.get("sessions_processed", 0)) or bool(
stats.get("sessions_failed", 0)
)
components_all_false = bool(components) and not any(
bool(v) for v in components.values()
)
armed = (
running
and not components_ready
and health_status == "healthy"
and components_all_false # aucun composant lourd encore chargé
and not init_attempted
)
status = health_status or "unknown"
if not running:
status = "stale" if health else "unknown"
elif armed:
# En veille : worker sain, composants chargés à la 1re session.
status = "idle"
elif not components_ready:
status = "degraded"
return {
"running": True, # On ne sait pas si le worker process tourne, mais la queue existe
"running": running,
"status": status,
"armed": armed,
"queue_length": len(queue),
"queue": queue,
"replay_lock_active": REPLAY_LOCK_FILE.exists(),
"queue_file": str(WORKER_QUEUE_FILE),
"note": "Le worker VLM tourne dans un process séparé (run_worker.py)",
"health_file": str(WORKER_HEALTH_FILE),
"health_error": health_error,
"health_age_seconds": health_age_seconds,
"health_stale": health_stale,
"worker_pid": (health or {}).get("pid"),
"last_cycle": (health or {}).get("last_cycle"),
"current_session": (health or {}).get("current_session"),
"components": components,
"components_ready": components_ready,
"processing_ready": running and not REPLAY_LOCK_FILE.exists() and components_ready,
"status_hint": _worker_status_hint(status, armed),
"stats": stats,
"note": "Le worker VLM tourne dans un process séparé (agent_v0.server_v1.run_worker).",
}
def _worker_status_hint(status: str, armed: bool) -> str:
"""Message humain pour le statut worker (consommé par le dashboard)."""
if armed or status == "idle":
return "En veille — composants chargés à la 1re session."
if status == "degraded":
return "Worker apprentissage dégradé — init des composants en échec."
if status == "stale":
return "Health file périmé (> 180s) — worker peut-être arrêté."
if status == "stopped":
return "Worker arrêté."
if status == "busy":
return "Traitement d'une session en cours."
if status == "healthy":
return "Worker prêt — composants chargés."
return "État worker inconnu."
# =========================================================================
# Compteur d'analyses en cours par session (pour attendre avant finalize)
# =========================================================================
@@ -1614,13 +1707,14 @@ async def startup():
threading.Thread(target=_smoke_model_health, name="model-health-smoke", daemon=True).start()
# Afficher le token API au démarrage pour que l'utilisateur puisse configurer l'agent
# Ne jamais imprimer le token complet dans journald/stdout.
_token_source = "env RPA_API_TOKEN" if os.environ.get("RPA_API_TOKEN") else "auto-généré"
logger.info(f"API Token ({_token_source}): {API_TOKEN}")
_token_hint = f"{API_TOKEN[:8]}{API_TOKEN[-4:]}" if API_TOKEN else "<absent>"
logger.info("API Token (%s): %s — auth Bearer obligatoire", _token_source, _token_hint)
print(f"\n{'='*60}")
print(f" API Token ({_token_source}):")
print(f" {API_TOKEN}")
print(f" Configurer l'agent : export RPA_API_TOKEN={API_TOKEN}")
print(f" {_token_hint} (masqué)")
print(" Configurer l'agent via .env.local ou l'enrollment; ne pas copier depuis les logs.")
print(f"{'='*60}\n")
worker.start(blocking=False)