FlyBrain-Lab / src /runtime /watchdog.py
timfromhcs's picture
FlyBrain V5.0.0 Space sync (v1 API, backup, watchdog, fixed tabs)
ee1153c verified
Raw
History Blame Contribute Delete
6.5 kB
"""Long-running supervision (V5 phase_11).
Observes: process/RAM, simulation-loop heartbeat (step advancement),
worker-thread liveness, GPU presence, telemetry flow, backup writability.
Recovery chain (each step logged, bounded — never infinite invisible loops):
RETRY -> RESTORE_LAST_VALID_CHECKPOINT -> RESTART_RUNTIME -> SAFE_STOP
Thresholds are conservative; a healthy engine only produces OK heartbeats.
"""
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
@dataclass
class WatchdogConfig:
interval_sec: float = 5.0
stall_timeout_sec: float = 60.0
ram_percent_limit: float = 90.0
max_restarts: int = 3
def _system_ram_percent() -> float:
import psutil
return float(psutil.virtual_memory().percent)
class Watchdog:
def __init__(self, engine_getter: Callable, backup_service=None,
config: Optional[WatchdogConfig] = None,
ram_reader: Callable[[], float] = _system_ram_percent):
self._engine_getter = engine_getter
self._backups = backup_service
self.config = config or WatchdogConfig()
self._ram_reader = ram_reader
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
self.events: List[Dict[str, Any]] = []
self.restarts = 0
self._last_step = -1
self._last_advance_ts = time.time()
def log(self, kind: str, detail: str, **extra) -> None:
self.events.append({"ts": time.time(), "kind": kind, "detail": detail,
**extra})
if len(self.events) > 500:
self.events.pop(0)
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._loop, daemon=True,
name="FlyBrainWatchdog")
self._thread.start()
self.log("START", "watchdog observing")
def stop(self) -> None:
self._stop.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=2.0)
self._thread = None
self.log("STOP", "watchdog stopped")
def status(self) -> Dict[str, Any]:
eng = self._engine_getter()
return {"running": bool(self._thread and self._thread.is_alive()),
"restarts": self.restarts,
"max_restarts": self.config.max_restarts,
"last_step_seen": self._last_step,
"stream_mode": getattr(eng, "stream_mode", "unknown"),
"recent_events": self.events[-10:]}
# ---- checks ----
def check_once(self) -> Dict[str, Any]:
"""Single supervision pass; returns findings and acts on faults."""
eng = self._engine_getter()
findings: List[str] = []
try:
ram = float(self._ram_reader())
if ram >= self.config.ram_percent_limit:
findings.append(f"RAM_CRITICAL:{ram:.1f}%")
except Exception as e: # noqa: BLE001
findings.append(f"RAM_UNREADABLE:{e}")
try:
step = int(eng.brain.state.step_count)
alive = bool(eng._worker_thread and eng._worker_thread.is_alive())
if eng.is_running:
if step != self._last_step:
self._last_step = step
self._last_advance_ts = time.time()
elif time.time() - self._last_advance_ts > self.config.stall_timeout_sec:
findings.append(f"WORKER_STALLED:step={step}")
if not alive:
findings.append("WORKER_DEAD_WHILE_RUNNING")
else:
self._last_step = step
self._last_advance_ts = time.time()
except Exception as e: # noqa: BLE001
findings.append(f"ENGINE_UNREADABLE:{e}")
gpu_ok = True
try:
if getattr(eng, "use_gpu", False) and getattr(eng.brain, "gpu_engine", None) is None:
gpu_ok = False
findings.append("GPU_REQUESTED_BUT_ABSENT")
except Exception:
pass
if findings:
self.log("FAULT", "; ".join(findings))
self._recover(eng, findings)
else:
self.log("OK", f"step={self._last_step} gpu_ok={gpu_ok}")
return {"findings": findings, "restarts": self.restarts}
def _recover(self, eng, findings: List[str]) -> None:
# 1. RETRY: nothing to retry automatically for stalls; log only.
# 2. RESTORE_LAST_VALID_CHECKPOINT via backup service.
if self._backups is not None:
try:
valid = [b for b in self._backups.list_backups()
if self._backups.verify_backup(b["backup"])["status"] == "VALID"]
if valid:
was_running = bool(eng.is_running)
if was_running:
eng.pause()
self._backups.restore_backup(valid[0]["backup"], eng, None)
self.log("RECOVER", f"restored {valid[0]['backup']}")
if was_running:
eng.start()
return
except Exception as e: # noqa: BLE001
self.log("RECOVER_FAIL", f"restore failed: {e}")
# 3. RESTART_RUNTIME (bounded).
if self.restarts < self.config.max_restarts:
try:
self.restarts += 1
if hasattr(eng, "restart_runtime"):
eng.restart_runtime()
else:
eng.pause()
self.log("RECOVER", f"runtime restarted ({self.restarts}/"
f"{self.config.max_restarts})")
return
except Exception as e: # noqa: BLE001
self.log("RECOVER_FAIL", f"restart failed: {e}")
# 4. SAFE_STOP (terminal, explicit, never a hidden loop).
try:
eng.safe_shutdown()
except Exception:
pass
self.log("SAFE_STOP", "restart budget exhausted; runtime stopped safely")
def _loop(self) -> None:
while not self._stop.is_set():
try:
self.check_once()
except Exception as e: # noqa: BLE001
self.log("ERROR", f"watchdog pass failed: {e}")
self._stop.wait(self.config.interval_sec)