feat: add global device queue and readiness tracking

This commit is contained in:
somew1nd committed 2026-08-24 14:34:17 -07:00
1 parent a62ac4aef1
commit 42b8988661
57 files changed
+3606 -716

No files matched your search

+4
View File
@@ -91,6 +91,10 @@ class ReadinessEngine:
return None
return await probe.probe()
async def run_device_submission_probe(self) -> ProbeResult:
"""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()
@@ -17,6 +17,7 @@
import asyncio
import os
from pathlib import Path
import re
import shutil
import subprocess
from typing import Any
@@ -120,6 +121,242 @@ class AdbDeviceProbe(BaseProbe):
logger.debug(f"Failed to read adb version: {e}")
return "Installed"
@staticmethod
def _parse_device_lock_state(policy_output: str, trust_output: str) -> bool | None:
"""Parse Android Keyguard state from dumpsys output.
``KeyguardServiceDelegate.showing`` also catches swipe-only lock screens,
while Trust's current-user ``deviceLocked`` covers secure locks and
screen-off transitions. Any positive signal wins so an occluded lock
screen cannot accidentally be treated as ready.
"""
policy_showing: bool | None = None
policy_lines = policy_output.splitlines()
for index, raw_line in enumerate(policy_lines):
if raw_line.strip() != "KeyguardServiceDelegate":
continue
for delegate_line in policy_lines[index + 1 : index + 16]:
match = re.fullmatch(
r"showing\s*=\s*(true|false|1|0)",
delegate_line.strip(),
flags=re.IGNORECASE,
)
if match:
policy_showing = match.group(1).lower() in {"true", "1"}
break
break
# Older Android releases expose differently named policy fields.
legacy_matches = re.findall(
r"\b(?:mShowingLockscreen|mKeyguardShowing|keyguardShowing|"
r"isKeyguardLocked|showingAndNotOccluded)\s*=\s*(true|false|1|0)",
policy_output,
flags=re.IGNORECASE,
)
legacy_states = [value.lower() in {"true", "1"} for value in legacy_matches]
trust_match = re.search(
r"^.*\(current\).*?\bdeviceLocked\s*=\s*(true|false|1|0)\b",
trust_output,
flags=re.IGNORECASE | re.MULTILINE,
)
if trust_match is None:
trust_match = re.search(
r"\bdeviceLocked\s*=\s*(true|false|1|0)\b",
trust_output,
flags=re.IGNORECASE,
)
trust_locked = (
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):
return True
if states:
return False
return None
async def _get_device_lock_state(
self,
adb_path: str,
serial: str,
timeout_seconds: float = 2.0,
) -> bool | None:
"""Query Keyguard state without changing or waking the target device."""
async def run_dumpsys(*service_args: str) -> str:
proc = await asyncio.create_subprocess_exec(
adb_path,
"-s",
serial,
"shell",
"dumpsys",
*service_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")
try:
policy_output, trust_output = await asyncio.gather(
run_dumpsys("window", "policy"),
run_dumpsys("trust"),
)
return self._parse_device_lock_state(policy_output, trust_output)
except Exception as exc:
logger.debug(f"Failed to query lock state for {serial}: {exc}")
return None
async def _get_device_states(
self,
adb_path: str,
timeout_seconds: float = 1.0,
) -> list[tuple[str, str]] | None:
"""Return only ADB serial/state pairs for the submission fast path.
``None`` means the command could not be completed within the bounded
submission window. An empty list means ADB responded with no devices.
"""
proc = None
try:
proc = await asyncio.create_subprocess_exec(
adb_path,
"devices",
"-l",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(
proc.communicate(), timeout=timeout_seconds
)
if proc.returncode != 0:
return None
states: list[tuple[str, str]] = []
lines = stdout.decode(errors="replace").splitlines()
for raw_line in lines[1:]:
parts = raw_line.strip().split()
if len(parts) >= 2:
states.append((parts[0], parts[1]))
return states
except TimeoutError:
if proc is not None:
proc.kill()
await proc.communicate()
return None
except Exception as exc:
logger.debug(f"Failed submission-time ADB device check: {exc}")
return None
async def probe_submission_readiness(self) -> ProbeResult:
"""Run the minimal fail-safe device check required before enqueueing.
The full diagnostics probe enriches device metadata, scans packages,
and discovers emulators. None of that is needed to reject a locked
device at submission time, so this path only checks ADB connectivity
and Keyguard state with strict time bounds.
"""
adb_path = toolchain.resolve("adb")
if not adb_path:
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.FAIL,
is_blocker=self.is_blocker,
summary="ADB Not Found",
description="Android Debug Bridge (adb) is not installed or not in PATH.",
metadata={"installed": False, "submission_probe": True},
)
device_states = await self._get_device_states(adb_path)
if device_states is None:
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.WARN,
is_blocker=self.is_blocker,
summary="Lock State Unknown",
description=(
"ADB did not respond quickly enough to verify the Android lock-screen state."
),
metadata={"installed": True, "submission_probe": True},
)
ready_serials = [serial for serial, state in device_states if state == "device"]
if not ready_serials:
states = {state for _, state in device_states}
summary = "Device Unauthorized" if "unauthorized" in states else "No Device Found"
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.WARN,
is_blocker=self.is_blocker,
summary=summary,
description="No authorized Android device is currently ready.",
metadata={
"installed": True,
"submission_probe": True,
"devices": [
{"serial": serial, "state": state}
for serial, state in device_states
],
},
)
serial = (
self._target_serial
if self._target_serial in ready_serials
else ready_serials[0]
)
is_locked = await self._get_device_lock_state(
adb_path,
serial,
timeout_seconds=1.0,
)
summary = (
"Device Locked"
if is_locked is True
else "Connected"
if is_locked is False
else "Lock State Unknown"
)
status = ProbeStatus.PASS if is_locked is False else ProbeStatus.WARN
description = (
f"ADB connected to {serial}. Ready for task submission."
if is_locked is False
else f"Android device {serial} is locked. Unlock it before running a task."
if is_locked is True
else f"Android lock-screen state could not be verified for {serial}."
)
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=status,
is_blocker=self.is_blocker,
summary=summary,
description=description,
metadata={
"installed": True,
"submission_probe": True,
"active_device": {"serial": serial, "is_locked": is_locked},
},
)
async def _parse_adb_devices(self, adb_path: str) -> list[DeviceInfo]:
"""Execute `adb devices -l` and extract structured device metadata."""
devices: list[DeviceInfo] = []
@@ -197,8 +434,12 @@ class AdbDeviceProbe(BaseProbe):
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
lock_state_task = asyncio.create_task(
self._get_device_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()
@@ -430,6 +671,37 @@ class AdbDeviceProbe(BaseProbe):
metadata["active_device"] = active_dev.model_dump()
if active_dev.is_locked is not False:
is_locked = active_dev.is_locked is True
summary = "Device Locked" if is_locked else "Lock State Unknown"
description = (
f"ADB connected to {display_name}, but the Android lock screen is active. "
"Unlock the device and enter the home screen before running a task."
if is_locked
else f"ADB connected to {display_name}, but Android lock-screen state could not "
"be verified. Keep the device unlocked on the home screen and check again."
)
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.WARN,
is_blocker=self.is_blocker,
summary=summary,
description=description,
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Unlock Device" if is_locked else "Verify Device Screen",
payload=(
"Unlock the Android device and leave it on the home screen. "
"Readiness is checked automatically every few seconds."
),
)
],
)
return ProbeResult(
id=self.probe_id,
category=self.category,
+4
View File
@@ -64,6 +64,10 @@ class DeviceInfo(BaseModel):
screen_resolution: str | None = Field(
default=None, description="Screen width x height (e.g. 1080x2400)"
)
is_locked: bool | None = Field(
default=None,
description="Whether Android Keyguard currently blocks access; None when undetermined",
)
is_emulator: bool = Field(default=False, description="Whether the device is an emulator")
installed_packages: list[str] = Field(
default_factory=list, description="List of recognized installed package names"