feat: harden video analysis and runtime resilience

This commit is contained in:
somew1nd committed 2026-08-25 20:58:25 -07:00
1 parent 5ada543a09
commit bb16991f2c
55 files changed
+5023 -437

No files matched your search

+76 -3
View File
@@ -48,6 +48,8 @@ logger = get_logger(__name__)
class ReadinessEngine:
"""Central orchestration engine executing modular readiness probes."""
_REPORT_CACHE_TTL_SECONDS = 2.0
def __init__(self):
self._probes: dict[str, BaseProbe] = {}
self._active_device_serial: str | None = None
@@ -57,6 +59,11 @@ class ReadinessEngine:
self._credentials_probe = LLMCredentialsProbe()
self._ocr_probe = VisionOCRProbe()
self._adb_probe = AdbDeviceProbe()
self._report_cache: SystemReadinessReport | None = None
self._report_cache_time = 0.0
self._report_cache_generation = -1
self._cache_generation = 0
self._report_lock = asyncio.Lock()
# Register default core probes in logical lifecycle order
self.register_probe(self._python_probe)
@@ -77,6 +84,7 @@ class ReadinessEngine:
def set_active_device_serial(self, serial: str | None) -> None:
"""Set user-selected active target device serial."""
self._active_device_serial = serial
self.invalidate_cache()
if hasattr(self._adb_probe, "set_target_serial"):
self._adb_probe.set_target_serial(serial)
@@ -95,9 +103,74 @@ class ReadinessEngine:
"""Run the bounded device gate used by task submission."""
return await self._adb_probe.probe_submission_readiness()
async def run_all(self, categories: list[ProbeCategory] | None = None) -> SystemReadinessReport:
"""Concurrently run all registered diagnostic probes and compile report."""
toolchain.clear_cache()
def invalidate_cache(self) -> None:
"""Invalidate the UI readiness snapshot after an explicit configuration change."""
self._cache_generation += 1
self._report_cache = None
self._report_cache_time = 0.0
self._report_cache_generation = -1
def _cached_report(self, max_age_seconds: float) -> SystemReadinessReport | None:
if self._report_cache is None:
return None
if self._report_cache_generation != self._cache_generation:
return None
if time.monotonic() - self._report_cache_time > max_age_seconds:
return None
return self._report_cache.model_copy(deep=True)
async def run_all(
self,
categories: list[ProbeCategory] | None = None,
*,
force_refresh: bool = False,
) -> SystemReadinessReport:
"""Return a coalesced readiness snapshot.
The dashboard polls frequently, so allowing every HTTP request to launch a
complete toolchain and ADB scan creates a request storm. Full reports are
cached briefly and all concurrent refreshes share one execution. The task
submission gate remains independent and always performs its own bounded
device check.
"""
cacheable = categories is None
request_started = time.monotonic()
if cacheable and not force_refresh:
cached = self._cached_report(self._REPORT_CACHE_TTL_SECONDS)
if cached is not None:
return cached
async with self._report_lock:
# A refresh that completed while this caller waited satisfies even a
# forced request that began before it, coalescing concurrent clicks.
if cacheable and self._report_cache_time >= request_started:
cached = self._cached_report(float("inf"))
if cached is not None:
return cached
if cacheable and not force_refresh:
cached = self._cached_report(self._REPORT_CACHE_TTL_SECONDS)
if cached is not None:
return cached
if force_refresh:
toolchain.clear_cache()
self._adb_probe.invalidate_enrichment_cache()
build_generation = self._cache_generation
report = await self._build_report(categories)
# If a device/configuration change happened during the scan, return
# this result only to its original caller and never publish it as the
# shared snapshot for later requests.
if cacheable and build_generation == self._cache_generation:
self._report_cache = report.model_copy(deep=True)
self._report_cache_time = time.monotonic()
self._report_cache_generation = build_generation
return report
async def _build_report(
self, categories: list[ProbeCategory] | None = None
) -> SystemReadinessReport:
"""Execute the underlying probes and compile an uncached report."""
target_probes = [
probe
for probe in self._probes.values()
+150 -54
View File
@@ -20,6 +20,7 @@ from pathlib import Path
import re
import shutil
import subprocess
import time
from typing import Any
from artemis.core.diagnostics.probes.base import BaseProbe
@@ -40,8 +41,14 @@ logger = get_logger(__name__)
class AdbDeviceProbe(BaseProbe):
"""Deep inspection probe for Android Debug Bridge, connected mobile devices, and local AVD emulators."""
_ENRICHMENT_CACHE_TTL_SECONDS = 60.0
_LAST_LOCK_STATE_TTL_SECONDS = 15.0
def __init__(self, target_serial: str | None = None):
self._target_serial = target_serial
self._device_enrichment_cache: dict[str, tuple[float, dict[str, Any]]] = {}
self._last_lock_states: dict[str, tuple[float, bool]] = {}
self._lock_state_sources: dict[str, str] = {}
@property
def probe_id(self) -> str:
@@ -59,9 +66,13 @@ class AdbDeviceProbe(BaseProbe):
"""Set or update preferred active device serial."""
self._target_serial = serial
def invalidate_enrichment_cache(self) -> None:
"""Refresh static device metadata on an explicit full re-check."""
self._device_enrichment_cache.clear()
def _locate_adb(self) -> str | None:
"""Find the adb binary path from ToolchainResolver."""
return toolchain.resolve("adb", force_refresh=True)
return toolchain.resolve("adb")
def _locate_emulator(self) -> str | None:
"""Find the emulator binary path from PATH or standard SDK environments."""
@@ -170,10 +181,19 @@ class AdbDeviceProbe(BaseProbe):
trust_match.group(1).lower() in {"true", "1"} if trust_match else None
)
states = [state for state in [policy_showing, trust_locked, *legacy_states] if state is not None]
if any(states):
# Prefer the modern, current-user signals. Legacy fields can coexist in
# dumpsys output as stale or display-specific diagnostics and must not
# override an explicit modern unlocked result.
modern_states = [
state for state in [policy_showing, trust_locked] if state is not None
]
if any(modern_states):
return True
if states:
if modern_states:
return False
if any(legacy_states):
return True
if legacy_states:
return False
return None
@@ -218,6 +238,85 @@ class AdbDeviceProbe(BaseProbe):
logger.debug(f"Failed to query lock state for {serial}: {exc}")
return None
async def _get_confirmed_device_lock_state(
self,
adb_path: str,
serial: str,
timeout_seconds: float = 2.0,
) -> bool | None:
"""Confirm a positive lock result so one transient sample cannot flap the UI."""
first = await self._get_device_lock_state(
adb_path, serial, timeout_seconds=timeout_seconds
)
if first is not True:
return first
second = await self._get_device_lock_state(
adb_path, serial, timeout_seconds=timeout_seconds
)
if second is True:
return True
if second is False:
return False
return None
async def _get_dashboard_lock_state(
self,
adb_path: str,
serial: str,
timeout_seconds: float = 2.0,
) -> bool | None:
"""Use a recent confirmed state when one dashboard sample times out.
This smoothing is intentionally dashboard-only. Task submission uses the
fresh confirmed probe and fails closed when the state is unknown.
"""
observed = await self._get_confirmed_device_lock_state(
adb_path, serial, timeout_seconds=timeout_seconds
)
now = time.monotonic()
if observed is not None:
self._last_lock_states[serial] = (now, observed)
self._lock_state_sources[serial] = "observed"
return observed
previous = self._last_lock_states.get(serial)
if previous and now - previous[0] <= self._LAST_LOCK_STATE_TTL_SECONDS:
self._lock_state_sources[serial] = "recent_confirmed"
return previous[1]
self._lock_state_sources[serial] = "unknown"
return None
@staticmethod
async def _run_adb_shell(
adb_path: str,
serial: str,
*args: str,
timeout_seconds: float = 2.5,
) -> str:
"""Run a bounded ADB shell command and always reap timed-out children."""
proc = await asyncio.create_subprocess_exec(
adb_path,
"-s",
serial,
"shell",
*args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
try:
stdout, _ = await asyncio.wait_for(
proc.communicate(), timeout=timeout_seconds
)
except TimeoutError:
proc.kill()
await proc.communicate()
return ""
if proc.returncode != 0:
return ""
return stdout.decode(errors="replace").strip()
async def _get_device_states(
self,
adb_path: str,
@@ -322,7 +421,7 @@ class AdbDeviceProbe(BaseProbe):
if self._target_serial in ready_serials
else ready_serials[0]
)
is_locked = await self._get_device_lock_state(
is_locked = await self._get_confirmed_device_lock_state(
adb_path,
serial,
timeout_seconds=1.0,
@@ -412,66 +511,60 @@ class AdbDeviceProbe(BaseProbe):
except Exception as e:
logger.error(f"Error querying adb devices: {e}")
# For authorized devices, attempt non-blocking quick property enrichment
# Lock state stays live, while static device properties and the installed
# package inventory are cached to keep frequent dashboard probes cheap.
for dev in devices:
if dev.state == "device":
try:
p_prop = await asyncio.create_subprocess_exec(
adb_path,
"-s",
dev.serial,
"shell",
"getprop ro.build.version.release",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
p_size = await asyncio.create_subprocess_exec(
adb_path,
"-s",
dev.serial,
"shell",
"wm size",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
lock_state_task = asyncio.create_task(
self._get_device_lock_state(adb_path, dev.serial)
self._get_dashboard_lock_state(adb_path, dev.serial)
)
out_prop, _ = await p_prop.communicate()
out_size, _ = await p_size.communicate()
dev.is_locked = await lock_state_task
if out_prop:
dev.android_version = out_prop.decode(errors="replace").strip()
if out_size:
size_str = out_size.decode(errors="replace").strip()
if "Physical size:" in size_str:
dev.screen_resolution = size_str.split("Physical size:")[-1].strip()
# Query installed application packages with timeout protection
try:
p_pkg = await asyncio.create_subprocess_exec(
cached = self._device_enrichment_cache.get(dev.serial)
now = time.monotonic()
if cached and now - cached[0] <= self._ENRICHMENT_CACHE_TTL_SECONDS:
enrichment = cached[1]
else:
prop_task = self._run_adb_shell(
adb_path,
"-s",
dev.serial,
"shell",
"pm list packages",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
"getprop",
"ro.build.version.release",
)
out_pkg, _ = await asyncio.wait_for(p_pkg.communicate(), timeout=2.5)
if out_pkg:
pkgs = [
size_task = self._run_adb_shell(
adb_path, dev.serial, "wm", "size"
)
packages_task = self._run_adb_shell(
adb_path,
dev.serial,
"pm",
"list",
"packages",
)
android_version, size_str, packages_output = await asyncio.gather(
prop_task, size_task, packages_task
)
enrichment = {
"android_version": android_version or None,
"screen_resolution": (
size_str.split("Physical size:")[-1].strip()
if "Physical size:" in size_str
else None
),
"installed_packages": [
line.split("package:", 1)[1].strip()
for line in out_pkg.decode(errors="replace").splitlines()
for line in packages_output.splitlines()
if line.startswith("package:")
and line.split("package:", 1)[1].strip()
]
dev.installed_packages = pkgs
except Exception as e_pkg:
logger.debug(f"Failed to query packages for {dev.serial}: {e_pkg}")
except Exception:
pass
],
}
self._device_enrichment_cache[dev.serial] = (now, enrichment)
dev.android_version = enrichment["android_version"]
dev.screen_resolution = enrichment["screen_resolution"]
dev.installed_packages = list(enrichment["installed_packages"])
dev.is_locked = await lock_state_task
except Exception as exc:
logger.debug(f"Failed to enrich device {dev.serial}: {exc}")
return devices
@@ -714,6 +807,9 @@ class AdbDeviceProbe(BaseProbe):
display_name = f"{display_name} ({active_dev.screen_resolution})"
metadata["active_device"] = active_dev.model_dump()
metadata["lock_state_source"] = self._lock_state_sources.get(
active_dev.serial, "unknown"
)
if active_dev.is_locked is not False:
is_locked = active_dev.is_locked is True
@@ -41,8 +41,8 @@ class ToolchainProbe(BaseProbe):
return False
async def probe(self) -> ProbeResult:
ffmpeg_path = toolchain.resolve("ffmpeg", force_refresh=True)
scrcpy_path = toolchain.resolve("scrcpy", force_refresh=True)
ffmpeg_path = toolchain.resolve("ffmpeg")
scrcpy_path = toolchain.resolve("scrcpy")
tools_installed = []
tools_missing = []