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

+91 -16
View File
@@ -14,6 +14,7 @@
import asyncio
import os
import re
try:
from datetime import UTC, datetime
@@ -26,6 +27,7 @@ from platform import system
import shutil
from shutil import which
import sys
import threading
from types import NoneType
from typing import TypeVar, overload
import uuid
@@ -82,6 +84,7 @@ from artemis.utils.media import (
remove_images_from_trace_folder,
remove_steps_json_from_trace_folder,
)
from artemis.utils.startup_progress import publish_startup_progress
logger = get_logger(__name__)
@@ -184,6 +187,7 @@ class Agent:
async def _prewarm_llm_connections(self, api_key: str | None = None):
"""Pre-warms the HTTP2/gRPC connection pools for both Native GenAI and LangChain clients in the background."""
publish_startup_progress("model_warmup", "Warming the model connection")
logger.info("Starting background pre-warming of Gemini API connection pools...")
try:
key = api_key
@@ -192,6 +196,9 @@ class Agent:
if not key:
logger.warning("Skipping LLM pre-warming: No API key available.")
publish_startup_progress(
"model_ready", "Model connection will initialize on first use"
)
return
# 1. Pre-warm Native SDK client
@@ -207,8 +214,12 @@ class Agent:
return_exceptions=True,
)
logger.success("Gemini API connection pools successfully pre-warmed.")
publish_startup_progress("model_ready", "Model connection is ready")
except Exception as e:
logger.warning(f"Failed to pre-warm LLM connections: {e}")
publish_startup_progress(
"model_ready", "Model connection will initialize on first use"
)
async def install_apk(self, apk_path: str | Path) -> None:
"""Install an APK on the connected device.
@@ -447,12 +458,6 @@ class Agent:
agent_config=self._config,
)
self._prepare_tracing(task=task, context=context)
await self._prepare_app_installation(task=task)
await self._prepare_device_environment(context=context)
await self._prepare_app_lock(task=task, context=context)
self._prepare_output_files(task=task)
output_config = None
if request.output_description or request.output_format:
output_config = OutputConfig(
@@ -476,7 +481,46 @@ class Agent:
)
try:
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
await asyncio.to_thread(device_lock.acquire)
queue_cancel_event = threading.Event()
acquire_task = asyncio.create_task(
asyncio.to_thread(
device_lock.acquire,
cancel_event=queue_cancel_event,
)
)
try:
await asyncio.shield(acquire_task)
except asyncio.CancelledError:
queue_cancel_event.set()
try:
await asyncio.shield(acquire_task)
except Exception:
pass
raise
# All device mutation and UI initialization happens only after
# this task reaches the head of the one global FIFO queue.
# Session creation must be inside the same boundary as well:
# DataEngine uses a shared database, so a queued task must not
# publish a new active session while the current task is still
# finishing.
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
if self._ui_adb_client is not None:
publish_startup_progress(
"uiautomator", "Connecting to UI Automator"
)
await asyncio.to_thread(self._ui_adb_client.connect)
publish_startup_progress(
"uiautomator_ready", "UI Automator is ready"
)
await self._ensure_device_unlocked()
publish_startup_progress(
"environment", "Preparing the device environment"
)
self._prepare_tracing(task=task, context=context)
self._prepare_output_files(task=task)
await self._prepare_app_installation(task=task)
await self._prepare_device_environment(context=context)
await self._prepare_app_lock(task=task, context=context)
async with context:
recording_started = False
if self._config.video_recording_tools_enabled:
@@ -503,6 +547,9 @@ class Agent:
except Exception as e:
logger.error(f"[{task_name}] Failed to start screen recording: {e}")
publish_startup_progress(
"environment_ready", "Device environment is ready"
)
try:
if request.profile and request.profile.lower() == "flash":
logger.info(f"[{task_name}] Invoking FlashRunner reactive loop...")
@@ -645,7 +692,11 @@ class Agent:
await self._finalize_tracing_safely(task=task, context=context)
finally:
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
await asyncio.to_thread(device_lock.release)
try:
if self._ui_adb_client is not None:
await asyncio.to_thread(self._ui_adb_client.disconnect)
finally:
await asyncio.to_thread(device_lock.release)
async with self._task_lock:
if self._current_task and not self._current_task.done():
@@ -707,9 +758,35 @@ class Agent:
if not self._initialized and not force:
return
if self._ui_adb_client is not None:
await asyncio.to_thread(self._ui_adb_client.disconnect)
self._initialized = False
logger.info("✅ Artemis agent stopped.")
async def _ensure_device_unlocked(self) -> None:
"""Reject secure keyguard instead of allowing an agent to guess credentials."""
if self._adb_client is None:
raise AgentError("ADB client is not initialized.")
device = self._adb_client.device(serial=self._device_context.device_id)
try:
trust_state = str(await asyncio.to_thread(device.shell, "dumpsys trust"))
except Exception as exc:
logger.warning(f"Could not inspect Android keyguard state: {exc}")
return
# The first deviceLocked value belongs to the current Android user;
# later entries may describe a separately locked work profile.
match = re.search(r"\bdeviceLocked=(?:true|1|false|0)\b", trust_state, re.IGNORECASE)
if match is None:
return
value = match.group(0).split("=", 1)[1].lower()
if value in {"true", "1"}:
raise AgentError(
"Android secure keyguard is locked. Unlock the device manually before "
"running Artemis; automation will not guess a PIN, password, or pattern."
)
async def _prepare_app_installation(self, task: Task) -> str | None:
"""Install app if app_path is specified in the task request.
@@ -1008,19 +1085,17 @@ class Agent:
device_height=height,
)
# Get real device dimensions from the device
if self._ui_adb_client:
# Query dimensions without starting UIAutomator or acquiring an awake
# strategy. Those belong inside the global execution queue lease.
if self._adb_client:
try:
# Use UIAutomator to get actual screen dimensions
screen_data = self._ui_adb_client.get_screen_data()
device_width = screen_data.width
device_height = screen_data.height
device_width, device_height = self._adb_client.device(device_id).window_size()
logger.info(f"Retrieved Android screen dimensions: {device_width}x{device_height}")
except Exception as e:
logger.warning(f"Failed to get Android screen dimensions: {e}, using defaults")
logger.warning(f"Failed to get Android window size: {e}, using defaults")
device_width, device_height = 1080, 2340
else:
logger.warning("UIAutomator client not available, using default dimensions")
logger.warning("ADB client not available, using default dimensions")
device_width, device_height = 1080, 2340
from artemis.platform import platform as pal_platform