feat(update): scaffold MAJ silencieuse + canary par machine (DETTE-022, gated OFF, swap encore stub)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-07-01 12:36:48 +02:00
parent 2a1b1ed80e
commit 61664c9a36
10 changed files with 879 additions and 7 deletions

View File

@@ -18,6 +18,7 @@ from .config import (
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS, LOG_FILE,
SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, MAX_SESSION_DURATION_S,
STREAMING_ENDPOINT, LOG_SHIP_ENABLED, LOG_SHIP_INTERVAL_S,
AUTO_UPDATE_ENABLED, AUTO_UPDATE_INTERVAL_S, AUTO_UPDATE_STAGING_DIR,
)
from .core.captor import EventCaptorV1
from .core.executor import ActionExecutorV1
@@ -158,6 +159,15 @@ class AgentV1:
threading.Thread(target=self._replay_poll_loop, daemon=True).start()
threading.Thread(target=self._background_heartbeat_loop, daemon=True).start()
# DETTE-022 v2 : MAJ silencieuse — boucle de check GATED (défaut OFF).
# Interroge le serveur (canary-aware) et télécharge en STAGING ; le swap
# réel reste réservé révision humaine (updater.apply_update = stub no-op).
# Activable poste par poste via RPA_AUTO_UPDATE_ENABLED, sans rebuild.
if AUTO_UPDATE_ENABLED:
threading.Thread(
target=self._auto_update_loop, daemon=True, name="lea-auto-update"
).start()
# Mini-serveur HTTP pour captures a la demande (port 5006)
self._capture_server = CaptureServer()
self._capture_server.start()
@@ -441,6 +451,67 @@ class AgentV1:
logger.debug(f"[HEARTBEAT] Erreur: {e}")
time.sleep(5)
def _auto_update_loop(self):
"""DETTE-022 v2 — boucle de MAJ silencieuse GATED (défaut OFF).
Interroge périodiquement le serveur (endpoint canary-aware), et si une
MAJ est proposée pour CE poste, la télécharge dans le STAGING après
vérif SHA256. Le swap réel N'EST PAS fait ici : `updater.run_update_cycle`
s'arrête au staging (apply_update = stub réservé révision humaine + swap
hors-process par Lea.bat au prochain démarrage).
SÉCURITÉ — « au bon moment » : on NE stage PAS pendant un enregistrement
ou un replay actif (self.session_id / self._replay_active), pour ne pas
perturber le travail utilisateur ni consommer du réseau au mauvais
moment. Best-effort : aucune exception ne remonte (ne casse jamais Léa).
"""
try:
from .network.updater import run_update_cycle
except Exception as e:
logger.warning("[UPDATE] Module updater indisponible : %s", e)
return
logger.info(
"[UPDATE] Boucle MAJ silencieuse démarrée (intervalle=%.0fs, "
"version=%s) — check seul, swap réservé révision humaine",
AUTO_UPDATE_INTERVAL_S, AGENT_VERSION,
)
while self.running:
# Découpe l'attente pour réagir vite à l'arrêt.
waited = 0.0
step = 1.0
while self.running and waited < AUTO_UPDATE_INTERVAL_S:
time.sleep(step)
waited += step
if not self.running:
break
# « Au bon moment » : jamais en plein travail (enregistrement/replay).
if self.session_id or getattr(self, "_replay_active", False):
logger.debug("[UPDATE] Report du check (session/replay active)")
continue
try:
result = run_update_cycle(
local_version=AGENT_VERSION,
machine_id=self.machine_id,
staging_dir=AUTO_UPDATE_STAGING_DIR,
)
status = result.get("status")
if status == "staged":
logger.info(
"[UPDATE] MAJ %s téléchargée en staging (SHA256=%s) — "
"swap réservé révision humaine, non appliqué",
result.get("target_version"),
result.get("sha256_verified"),
)
elif status not in ("up_to_date", "disabled"):
logger.debug("[UPDATE] Cycle: %s", result)
except Exception as e:
# run_update_cycle est déjà best-effort ; double filet ici.
logger.debug("[UPDATE] Erreur boucle MAJ : %s", e)
def stop_session(self):
# Sauvegarder le session_id avant de l'annuler (pour les logs)
ended_session_id = self.session_id