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

+76 -14
View File
@@ -21,7 +21,6 @@ import os
from pathlib import Path
import psutil
import re
import signal
import sys
import time
import traceback
@@ -87,7 +86,7 @@ class ValidatorNode:
self.ctx = ctx
def _kill_mcp_server_instantly(self):
"""Instantly terminate MCP ADB server child processes using SIGKILL."""
"""Instantly terminate MCP ADB server child processes."""
try:
current_process = psutil.Process()
@@ -97,12 +96,50 @@ class ValidatorNode:
cmdline = child.cmdline()
if any("adb_server.py" in part for part in cmdline):
logger.warning(f"Instantly killing MCP Server child process: {child.pid}")
os.kill(child.pid, signal.SIGKILL)
child.kill()
except psutil.NoSuchProcess:
pass
except Exception as e:
logger.error(f"Failed to instantly kill MCP server child processes: {e}")
async def _close_failed_mcp_session(
self,
*,
session,
session_entered: bool,
client_ctx,
client_entered: bool,
) -> bool:
"""Exit partially-entered MCP contexts in reverse order.
The MCP stdio transport owns an AnyIO task group and cancellation
scope. Dropping an entered transport without calling ``__aexit__``
leaves that scope attached to the validator task and can cancel an
unrelated action later in the same node.
Returns ``True`` when every entered context closed cleanly.
"""
self.ctx.mcp_session = None
self.ctx.mcp_client_ctx = None
cleanup_succeeded = True
for manager, entered, label in (
(session, session_entered, "MCP session"),
(client_ctx, client_entered, "MCP stdio transport"),
):
if not entered or manager is None:
continue
try:
await manager.__aexit__(None, None, None)
except asyncio.CancelledError:
cleanup_succeeded = False
logger.warning(f"{label} cleanup was cancelled.")
except Exception as cleanup_error:
cleanup_succeeded = False
logger.warning(f"Failed to close {label}: {cleanup_error}")
return cleanup_succeeded
async def _get_mcp_session(self):
if getattr(self.ctx, "mcp_session", None) is not None:
return self.ctx.mcp_session
@@ -111,30 +148,55 @@ class ValidatorNode:
venv_python = root_dir / ".venv" / "bin" / "python3"
python_exe = str(venv_python) if venv_python.exists() else sys.executable
server_env = os.environ.copy()
# The parent Artemis process already owns the device-awake policy.
# Starting it again in this short-lived child can block the MCP
# handshake on cross-process ADB/lease cleanup.
server_env["ARTEMIS_KEEP_DEVICE_AWAKE"] = "false"
server_params = StdioServerParameters(
command=python_exe,
args=[str(root_dir / "mcp" / "adb_server.py")],
env=os.environ.copy(),
env=server_env,
)
logger.info("Starting persistent MCP server session...")
self.ctx.mcp_client_ctx = stdio_client(server_params)
client_ctx = stdio_client(server_params)
session = None
client_entered = False
session_entered = False
self.ctx.mcp_client_ctx = client_ctx
try:
read, write = await self.ctx.mcp_client_ctx.__aenter__()
self.ctx.mcp_session = ClientSession(read, write)
await self.ctx.mcp_session.__aenter__()
read, write = await client_ctx.__aenter__()
client_entered = True
session = ClientSession(read, write)
self.ctx.mcp_session = session
await session.__aenter__()
session_entered = True
# Handshake timeout protection
await asyncio.wait_for(self.ctx.mcp_session.initialize(), timeout=15.0)
return self.ctx.mcp_session
await asyncio.wait_for(session.initialize(), timeout=15.0)
return session
except asyncio.CancelledError:
await self._close_failed_mcp_session(
session=session,
session_entered=session_entered,
client_ctx=client_ctx,
client_entered=client_entered,
)
raise
except Exception as e:
err_stack = traceback.format_exc()
logger.critical(
"CRITICAL: Failed to initialize MCP server session:"
f" {e}\n{err_stack}. Cleaning up child processes."
)
self._kill_mcp_server_instantly()
self.ctx.mcp_session = None
self.ctx.mcp_client_ctx = None
cleanup_succeeded = await self._close_failed_mcp_session(
session=session,
session_entered=session_entered,
client_ctx=client_ctx,
client_entered=client_entered,
)
if not cleanup_succeeded:
self._kill_mcp_server_instantly()
logger.warning(
"Falling back to robust local in-process execution bypassing MCP protocol."
@@ -600,7 +662,7 @@ class ValidatorNode:
return await self._execute_validation_loop(state)
except asyncio.CancelledError:
logger.warning(
"Validator task cancelled (Stop requested). Instantly killing MCP server processes."
"Validator task cancelled. Instantly killing MCP server processes."
)
self._kill_mcp_server_instantly()
raise
+14 -168
View File
@@ -21,10 +21,8 @@ Handles Maestro blocker detection and removal before connecting.
import base64
from io import BytesIO
import os
import re as _re
import subprocess
import threading
import time
from typing import TYPE_CHECKING
import xml.etree.ElementTree as ET
@@ -33,6 +31,7 @@ from PIL import Image
from pydantic import BaseModel
import uiautomator2 as u2
from artemis.runtime.awake_service import ensure_device_awake
from artemis.utils.logger import get_logger
if TYPE_CHECKING:
@@ -42,129 +41,6 @@ logger = get_logger(__name__)
MAESTRO_PACKAGE = "dev.mobile.maestro"
AWAKE_STRATEGY_STAY_ON = "stay_on"
AWAKE_STRATEGY_WAKE_LOCK = "wake_lock"
AWAKE_STRATEGY_HEARTBEAT = "heartbeat"
AWAKE_HEARTBEAT_INTERVAL_SECONDS = 5.0
def _run_awake_adb_command(
device_id: str, args: list[str], description: str
) -> subprocess.CompletedProcess[str] | None:
"""Run one non-fatal ADB command used by the awake strategy."""
try:
result = subprocess.run(
["adb", "-s", device_id, *args],
capture_output=True,
text=True,
timeout=10,
check=False,
)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
logger.warning(
f"Could not {description} on {device_id}: {detail or 'ADB command failed'}"
)
return result
except Exception as e:
logger.warning(f"Could not {description} on {device_id}: {e}")
return None
def _keep_device_awake(device_id: str) -> str | None:
"""Best-effort preparation of an Android device for UI automation.
``svc power stayon true`` asks Android to keep the display on while the
device has any external power source. It avoids changing the user's
normal battery-powered screen timeout. The wake and dismiss commands
also recover a device that went to sleep before Artemis connected;
``wm dismiss-keyguard`` cannot bypass a secure PIN/password keyguard.
The primary strategy is verified through PowerManager. If it is not
actually active, Artemis tries a shell-owned bright-screen wake lock. If
that command is unavailable, the caller starts a periodic KEYCODE_WAKEUP
heartbeat. Neither fallback changes the user's screen-timeout setting.
This must never make device connection fail. Some vendor Android builds
restrict one or more shell commands, so failures are logged only.
"""
enabled = os.environ.get("ARTEMIS_KEEP_DEVICE_AWAKE", "true").strip().lower()
if enabled in {"0", "false", "no", "off"}:
return None
commands = (
("enable stay-awake while powered", ["shell", "svc", "power", "stayon", "true"]),
("wake display", ["shell", "input", "keyevent", "KEYCODE_WAKEUP"]),
("dismiss non-secure keyguard", ["shell", "wm", "dismiss-keyguard"]),
)
for description, args in commands:
_run_awake_adb_command(device_id, args, description)
power_state = _run_awake_adb_command(
device_id, ["shell", "dumpsys", "power"], "verify stay-awake state"
)
if (
power_state is not None
and power_state.returncode == 0
and _re.search(r"\bmStayOn=true\b", power_state.stdout)
):
logger.info(f"Stay-awake primary strategy verified on {device_id}")
return AWAKE_STRATEGY_STAY_ON
logger.warning(
f"Stay-awake primary strategy is not active on {device_id}; "
"trying a screen-bright wake lock"
)
_run_awake_adb_command(
device_id,
[
"shell",
"cmd",
"power",
"set-wakelock",
"acquire",
"-d",
"0",
"SCREEN_BRIGHT_WAKE_LOCK",
],
"acquire a screen-bright wake lock",
)
wake_locks = _run_awake_adb_command(
device_id,
["shell", "cmd", "power", "set-wakelock", "list"],
"verify the screen-bright wake lock",
)
if (
wake_locks is not None
and wake_locks.returncode == 0
and _re.search(r"SCREEN_BRIGHT_WAKE_LOCK:.*\bheld=true\b", wake_locks.stdout)
):
logger.info(f"Screen-bright wake lock verified on {device_id}")
return AWAKE_STRATEGY_WAKE_LOCK
logger.warning(
f"Screen-bright wake lock is unavailable on {device_id}; "
f"using a {AWAKE_HEARTBEAT_INTERVAL_SECONDS:g}-second wakeup heartbeat"
)
return AWAKE_STRATEGY_HEARTBEAT
def _release_screen_wake_lock(device_id: str) -> None:
"""Release the shell-owned screen wake lock acquired for this session."""
_run_awake_adb_command(
device_id,
[
"shell",
"cmd",
"power",
"set-wakelock",
"release",
"-d",
"0",
"SCREEN_BRIGHT_WAKE_LOCK",
],
"release the screen-bright wake lock",
)
class _CyFunctionDetectorMeta(type):
@@ -383,40 +259,6 @@ class UIAutomatorClient:
self._device_id = device_id
self._device: Device | None = None
self._awake_strategy: str | None = None
self._heartbeat_stop_event: threading.Event | None = None
self._heartbeat_thread: threading.Thread | None = None
def _start_awake_heartbeat(self) -> None:
"""Start a daemon heartbeat that safely refreshes Android user activity."""
if self._heartbeat_thread is not None and self._heartbeat_thread.is_alive():
return
stop_event = threading.Event()
self._heartbeat_stop_event = stop_event
def heartbeat() -> None:
while not stop_event.wait(AWAKE_HEARTBEAT_INTERVAL_SECONDS):
_run_awake_adb_command(
self._device_id,
["shell", "input", "keyevent", "KEYCODE_WAKEUP"],
"send the stay-awake heartbeat",
)
self._heartbeat_thread = threading.Thread(
target=heartbeat,
name=f"artemis-awake-{self._device_id}",
daemon=True,
)
self._heartbeat_thread.start()
def _stop_awake_heartbeat(self) -> None:
"""Stop the session heartbeat without delaying shutdown."""
if self._heartbeat_stop_event is not None:
self._heartbeat_stop_event.set()
if self._heartbeat_thread is not None:
self._heartbeat_thread.join(timeout=2.0)
self._heartbeat_stop_event = None
self._heartbeat_thread = None
def _ensure_connected(self) -> "Device":
"""Ensure connection to the device, handling Maestro blocker.
@@ -436,18 +278,25 @@ class UIAutomatorClient:
# Ensure Maestro is not blocking us
_ensure_maestro_not_installed(self._device_id)
# Configure this automation session before UIAutomator2 reads the screen.
# Enroll the device in the containing Artemis service lifetime before
# UIAutomator2 reads the screen. Client disconnects do not release it.
if self._awake_strategy is None:
self._awake_strategy = _keep_device_awake(self._device_id)
if self._awake_strategy == AWAKE_STRATEGY_HEARTBEAT:
self._start_awake_heartbeat()
self._awake_strategy = ensure_device_awake(self._device_id)
# Connect to device
logger.info(f"Connecting UIAutomator2 to device: {self._device_id}")
self._device = u2.connect(self._device_id)
try:
self._device = u2.connect(self._device_id)
except Exception:
self._awake_strategy = None
raise
logger.info("UIAutomator2 connected successfully")
return self._device
def connect(self) -> None:
"""Connect and activate this session's awake strategy without an action."""
self._ensure_connected()
def press_key(self, key: str):
"""Press a key on the device.
@@ -565,10 +414,7 @@ class UIAutomatorClient:
)
def disconnect(self) -> None:
"""Disconnect from the device."""
self._stop_awake_heartbeat()
if self._awake_strategy == AWAKE_STRATEGY_WAKE_LOCK:
_release_screen_wake_lock(self._device_id)
"""Disconnect this client without ending the host's awake lifetime."""
self._awake_strategy = None
self._device = None
logger.info("UIAutomator2 client disconnected")
+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"
+9
View File
@@ -242,6 +242,15 @@ class DataEngine:
pid=os.getpid(),
)
self.storage.create_session(session)
try:
from artemis.runtime import DeviceExecutionLock
DeviceExecutionLock.annotate_active_owner(
session_id=str(session_id),
ingress=os.getenv("ARTEMIS_TASK_INGRESS") or "sdk",
)
except Exception as exc:
logger.debug(f"Could not annotate active device owner: {exc}")
self.current_step_number = 0
logger.info(f"Session started: {session_id}")
self._publish("session_started", session.model_dump())
+29 -22
View File
@@ -26,6 +26,7 @@ from mcp_server.base import mcp as agent_mcp
import mcp_server.tools # noqa: F401
from mcp_server.utils import env_utils
from artemis.mcp.adb_server import mcp as adb_mcp
from artemis.runtime import shutdown_awake_service, start_awake_service
from artemis.utils.logger import get_logger
from rich.console import Console
from rich.syntax import Syntax
@@ -723,27 +724,33 @@ def mcp_command(
console.print(syntax)
raise typer.Exit(0)
st = server_type.lower()
if st in ("agent", "mobile", "artemis", "default"):
logger.info(f"Starting Artemis Mobile Agent MCP Server over {transport}...")
if transport.lower() == "sse":
agent_mcp.run(transport="sse", host=host, port=port)
else:
agent_mcp.run(transport="stdio")
elif st == "adb":
logger.info(f"Starting Artemis ADB MCP Server over {transport}...")
if transport.lower() == "sse":
adb_mcp.run(transport="sse", host=host, port=port)
else:
adb_mcp.run(transport="stdio")
elif st == "xml":
from artemis.mcp.xml_search_server import mcp as xml_mcp
start_awake_service()
try:
st = server_type.lower()
if st in ("agent", "mobile", "artemis", "default"):
logger.info(f"Starting Artemis Mobile Agent MCP Server over {transport}...")
if transport.lower() == "sse":
agent_mcp.run(transport="sse", host=host, port=port)
else:
agent_mcp.run(transport="stdio")
elif st == "adb":
logger.info(f"Starting Artemis ADB MCP Server over {transport}...")
if transport.lower() == "sse":
adb_mcp.run(transport="sse", host=host, port=port)
else:
adb_mcp.run(transport="stdio")
elif st == "xml":
from artemis.mcp.xml_search_server import mcp as xml_mcp
logger.info(f"Starting Artemis XML Fuzzy Search MCP Server over {transport}...")
if transport.lower() == "sse":
xml_mcp.run(transport="sse", host=host, port=port)
logger.info(f"Starting Artemis XML Fuzzy Search MCP Server over {transport}...")
if transport.lower() == "sse":
xml_mcp.run(transport="sse", host=host, port=port)
else:
xml_mcp.run(transport="stdio")
else:
xml_mcp.run(transport="stdio")
else:
logger.error(f"Unsupported MCP server type: {server_type}. Use 'agent', 'adb', or 'xml'.")
raise typer.Exit(1)
logger.error(
f"Unsupported MCP server type: {server_type}. Use 'agent', 'adb', or 'xml'."
)
raise typer.Exit(1)
finally:
shutdown_awake_service()
+4
View File
@@ -23,6 +23,7 @@ from typing import Annotated
from adbutils import AdbClient
from langchain_core.callbacks.base import Callbacks
from artemis.config import initialize_llm_config, settings
from artemis.utils.startup_progress import publish_startup_progress
from artemis import Agent, Builders
from artemis.sdk.types.task import AgentProfile
from artemis.utils.cli_helpers import display_device_status
@@ -73,6 +74,7 @@ async def execute_task(
explorer_flash_mode: Override Explorer version mode for Flash execution profile.
explorer_pro_mode: Override Explorer version mode for Pro execution profile.
"""
publish_startup_progress("configuration", "Loading the run configuration")
llm_config = initialize_llm_config()
agent_profile = AgentProfile(name="default", llm_config=llm_config)
config = Builders.AgentConfig.with_default_profile(profile=agent_profile)
@@ -117,11 +119,13 @@ async def execute_task(
agent: Agent | None = None
try:
publish_startup_progress("device_check", "Checking the Android device")
agent = Agent(config=config.build())
await agent.init(
retry_count=int(os.getenv("ARTEMIS_HEALTH_RETRIES", 5)),
retry_wait_seconds=int(os.getenv("ARTEMIS_HEALTH_DELAY", 2)),
)
publish_startup_progress("device_ready", "Android device connected")
task = agent.new_task(goal)
if locked_app_package:
+15 -6
View File
@@ -15,6 +15,8 @@
"""UI Launch and Web Console CLI Command (artemis ui)."""
from pathlib import Path
import shutil
import subprocess
import sys
import threading
import time
@@ -27,6 +29,13 @@ from rich.panel import Panel
import typer
def _resolve_npm_executable(platform_name: str | None = None) -> str | None:
"""Return a directly executable npm path for the current platform."""
platform_name = platform_name or sys.platform
candidates = ("npm.cmd", "npm.exe", "npm") if platform_name == "win32" else ("npm",)
return next((path for candidate in candidates if (path := shutil.which(candidate))), None)
def _showcase_build_required(showcase_dir: Path) -> bool:
"""Return whether the compiled Angular app is missing or older than its inputs."""
base_dist = showcase_dir / "dist"
@@ -123,16 +132,16 @@ def ui_command(
showcase_dir = ROOT_DIR / "apps" / "showcase_ui"
if _showcase_build_required(showcase_dir):
import shutil
import subprocess
if shutil.which("npm"):
npm_executable = _resolve_npm_executable()
if npm_executable:
console.print(
" [yellow]🎨 Showcase UI sources changed. Compiling Angular Showcase UI...[/yellow]"
)
try:
subprocess.run(["npm", "install", "--silent"], cwd=showcase_dir, check=True)
subprocess.run(["npm", "run", "build"], cwd=showcase_dir, check=True)
subprocess.run(
[npm_executable, "install", "--silent"], cwd=showcase_dir, check=True
)
subprocess.run([npm_executable, "run", "build"], cwd=showcase_dir, check=True)
console.print(" [green]✓ Showcase UI built successfully.[/green]\n")
except Exception as e:
console.print(f" [red]⚠ Failed to auto-build Showcase UI: {e}[/red]\n")
+7 -1
View File
@@ -470,4 +470,10 @@ async def get_ui_hierarchy(ctx: Context) -> str:
if __name__ == "__main__":
mcp.run(transport="stdio")
from artemis.runtime import shutdown_awake_service, start_awake_service
start_awake_service()
try:
mcp.run(transport="stdio")
finally:
shutdown_awake_service()
+7 -1
View File
@@ -284,4 +284,10 @@ def search_by_coordinates(image_hash: str, x: int, y: int) -> str:
if __name__ == "__main__":
mcp.run()
from artemis.runtime import shutdown_awake_service, start_awake_service
start_awake_service()
try:
mcp.run()
finally:
shutdown_awake_service()
+10
View File
@@ -15,11 +15,21 @@
"""Artemis cross-platform runtime and process supervisor subsystem."""
from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock
from artemis.runtime.awake_service import (
ensure_device_awake,
screen_awake_service,
shutdown_awake_service,
start_awake_service,
)
from artemis.runtime.supervisor import ProcessSupervisor, process_supervisor
__all__ = [
"DeviceBusyError",
"DeviceExecutionLock",
"ensure_device_awake",
"ProcessSupervisor",
"process_supervisor",
"screen_awake_service",
"shutdown_awake_service",
"start_awake_service",
]
+288
View File
@@ -0,0 +1,288 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Cross-process ownership for Android's shell screen-bright wake lock."""
from __future__ import annotations
from contextlib import contextmanager
from datetime import UTC, datetime
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import time
import uuid
from artemis.config.paths import get_temp_dir
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
_WAKE_LOCK_TYPE = "SCREEN_BRIGHT_WAKE_LOCK"
_WAKE_LOCK_RE = re.compile(
rf"{_WAKE_LOCK_TYPE}:.*\bheld=(true|false)\b.*\brefCount=(\d+)\b",
re.IGNORECASE,
)
class ScreenAwakeLease:
"""Share exactly one Android shell wake-lock reference across clients.
``cmd power set-wakelock`` exposes one ref-counted shell wake lock for a
display/type pair. Calling it independently from every process leaks
references whenever a process crashes. PID-aware lease files make the
Android reference a process-shared resource instead: the first live client
normalizes stale references and acquires one, later clients only register,
and the last client drains the shared reference on disconnect.
"""
_MUTEX_TIMEOUT_SECONDS = 10.0
_MUTEX_POLL_SECONDS = 0.05
_MALFORMED_GRACE_SECONDS = 5.0
_MAX_REFERENCE_DRAIN = 256
def __init__(self, device_id: str):
self.device_id = device_id
self.token = uuid.uuid4().hex
device_hash = hashlib.sha256(device_id.encode("utf-8")).hexdigest()[:16]
root = get_temp_dir("awake-leases")
self.lease_dir = root / f"{device_hash}.leases"
self.mutex_path = root / f"{device_hash}.mutex"
self.lease_path = self.lease_dir / f"{self.token}.lease"
self._acquired = False
@staticmethod
def _process_created_at(pid: int | None = None) -> float:
try:
import psutil
return float(psutil.Process(pid or os.getpid()).create_time())
except Exception:
return 0.0
@staticmethod
def _owner_is_alive(payload: dict) -> bool:
try:
import psutil
pid = int(payload["pid"])
process = psutil.Process(pid)
if not process.is_running():
return False
created_at = float(payload.get("process_created_at", 0.0))
return created_at <= 0 or abs(process.create_time() - created_at) < 1.0
except Exception:
return False
@staticmethod
def _read_payload(path: Path) -> dict | None:
try:
value = json.loads(path.read_text(encoding="utf-8"))
return value if isinstance(value, dict) else None
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return None
@classmethod
def _path_is_stale(cls, path: Path) -> bool:
payload = cls._read_payload(path)
if payload is not None:
return not cls._owner_is_alive(payload)
try:
return time.time() - path.stat().st_mtime >= cls._MALFORMED_GRACE_SECONDS
except OSError:
return True
def _payload(self) -> dict:
return {
"pid": os.getpid(),
"process_created_at": self._process_created_at(),
"token": self.token,
"device_id": self.device_id,
"created_at": datetime.now(UTC).isoformat(),
}
@contextmanager
def _mutex(self):
self.mutex_path.parent.mkdir(parents=True, exist_ok=True)
started_at = time.monotonic()
acquired = False
while not acquired:
try:
fd = os.open(self.mutex_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
if self._path_is_stale(self.mutex_path):
try:
self.mutex_path.unlink(missing_ok=True)
except OSError:
pass
continue
if time.monotonic() - started_at >= self._MUTEX_TIMEOUT_SECONDS:
raise TimeoutError("Timed out acquiring the Artemis awake-lease mutex.")
time.sleep(self._MUTEX_POLL_SECONDS)
else:
try:
os.write(fd, json.dumps(self._payload()).encode("utf-8"))
finally:
os.close(fd)
acquired = True
try:
yield
finally:
payload = self._read_payload(self.mutex_path)
if payload is not None and payload.get("token") == self.token:
try:
self.mutex_path.unlink(missing_ok=True)
except OSError:
pass
def _run(self, args: list[str], description: str) -> subprocess.CompletedProcess[str] | None:
try:
result = subprocess.run(
["adb", "-s", self.device_id, *args],
capture_output=True,
text=True,
timeout=10,
check=False,
)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
logger.warning(
f"Could not {description} on {self.device_id}: "
f"{detail or 'ADB command failed'}"
)
return result
except Exception as exc:
logger.warning(f"Could not {description} on {self.device_id}: {exc}")
return None
def _wake_lock_state(self) -> tuple[bool, int] | None:
result = self._run(
["shell", "cmd", "power", "set-wakelock", "list"],
"inspect the shared screen-bright wake lock",
)
if result is None or result.returncode != 0:
return None
match = _WAKE_LOCK_RE.search(result.stdout)
if match is None:
return (False, 0)
return (match.group(1).lower() == "true", int(match.group(2)))
def _set_wake_lock(self, action: str) -> bool:
result = self._run(
[
"shell",
"cmd",
"power",
"set-wakelock",
action,
"-d",
"0",
_WAKE_LOCK_TYPE,
],
f"{action} the shared screen-bright wake lock",
)
return result is not None and result.returncode == 0
def _drain_references(self) -> bool:
"""Release legacy or crashed-process references until none remain."""
for _ in range(self._MAX_REFERENCE_DRAIN):
state = self._wake_lock_state()
if state is None:
return False
held, count = state
if not held or count <= 0:
return True
if not self._set_wake_lock("release"):
return False
logger.warning(
f"Refused to drain more than {self._MAX_REFERENCE_DRAIN} shared wake-lock "
f"references on {self.device_id}"
)
return False
def _live_leases(self) -> list[Path]:
self.lease_dir.mkdir(parents=True, exist_ok=True)
live: list[Path] = []
for path in self.lease_dir.glob("*.lease"):
if self._path_is_stale(path):
try:
path.unlink(missing_ok=True)
except OSError:
pass
else:
live.append(path)
return live
def acquire(self) -> bool:
if self._acquired:
return True
try:
with self._mutex():
live = self._live_leases()
state = self._wake_lock_state()
if state is None:
return False
if not live:
# No registered owner means every existing reference is
# legacy or belongs to a crashed process.
if not self._drain_references():
return False
state = (False, 0)
held, count = state
if not held or count <= 0:
if not self._set_wake_lock("acquire"):
return False
verified = self._wake_lock_state()
if verified is None or not verified[0] or verified[1] <= 0:
return False
self.lease_path.write_text(
json.dumps(self._payload(), ensure_ascii=False), encoding="utf-8"
)
self._acquired = True
return True
except Exception as exc:
logger.warning(f"Could not acquire shared awake lease on {self.device_id}: {exc}")
return False
def release(self) -> None:
if not self._acquired:
return
try:
with self._mutex():
self.lease_path.unlink(missing_ok=True)
if not self._live_leases():
self._drain_references()
except Exception as exc:
logger.warning(f"Could not release shared awake lease on {self.device_id}: {exc}")
finally:
self._acquired = False
def cleanup_unowned_references(self) -> bool:
"""Drain legacy shell wake locks only when no live old host owns them."""
try:
with self._mutex():
if self._live_leases():
return False
return self._drain_references()
except Exception as exc:
logger.warning(
f"Could not clean unowned screen wake locks on {self.device_id}: {exc}"
)
return False
+300
View File
@@ -0,0 +1,300 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""USB-scoped screen-awake policy shared by every local Artemis entrypoint."""
from __future__ import annotations
import os
import re
import subprocess
import threading
from typing import Iterable
from adbutils import AdbClient
from artemis.config import settings
from artemis.runtime.awake_lease import ScreenAwakeLease
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
AWAKE_STRATEGY_USB = "usb_stay_on"
AWAKE_STRATEGY_HEARTBEAT = "host_heartbeat"
AWAKE_HEARTBEAT_INTERVAL_SECONDS = 5.0
DEVICE_DISCOVERY_INTERVAL_SECONDS = 2.0
def _awake_enabled() -> bool:
enabled = os.environ.get("ARTEMIS_KEEP_DEVICE_AWAKE", "true").strip().lower()
return enabled not in {"0", "false", "no", "off"}
def _run_awake_adb_command(
device_id: str, args: list[str], description: str
) -> subprocess.CompletedProcess[str] | None:
"""Run one non-fatal ADB command used by the USB awake policy."""
try:
result = subprocess.run(
["adb", "-s", device_id, *args],
capture_output=True,
text=True,
timeout=10,
check=False,
)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
logger.warning(
f"Could not {description} on {device_id}: {detail or 'ADB command failed'}"
)
return result
except Exception as exc:
logger.warning(f"Could not {description} on {device_id}: {exc}")
return None
def _configure_usb_stay_awake(device_id: str) -> str | None:
"""Enable USB stay-awake, falling back to an effective host heartbeat.
The policy is intentionally scoped to USB power rather than an Artemis
process or task. Android applies the normal screen timeout as soon as USB
power is removed, even though no ADB command can be sent after unplugging.
AC, wireless, and dock charging retain the user's normal behavior.
"""
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
return None
# Migration cleanup: previous Artemis versions used a persistent shell
# wake lock. Drain it only after every live legacy owner has exited.
ScreenAwakeLease(device_id).cleanup_unowned_references()
commands = (
(
"enable USB-scoped stay-awake policy",
["shell", "svc", "power", "stayon", "usb"],
),
("wake display", ["shell", "input", "keyevent", "KEYCODE_WAKEUP"]),
("dismiss non-secure keyguard", ["shell", "wm", "dismiss-keyguard"]),
)
for description, args in commands:
_run_awake_adb_command(device_id, args, description)
configured = _run_awake_adb_command(
device_id,
["shell", "settings", "get", "global", "stay_on_while_plugged_in"],
"verify USB-scoped stay-awake policy",
)
power_state = _run_awake_adb_command(
device_id,
["shell", "dumpsys", "power"],
"verify active USB stay-awake state",
)
try:
value = int(configured.stdout.strip()) if configured is not None else -1
except (TypeError, ValueError):
value = -1
stay_on_active = (
configured is not None
and configured.returncode == 0
and value == 2
and power_state is not None
and power_state.returncode == 0
and re.search(r"\bmStayOn=true\b", power_state.stdout) is not None
)
if stay_on_active:
logger.info(f"USB-scoped stay-awake policy verified active on {device_id}")
return AWAKE_STRATEGY_USB
logger.warning(
f"USB stay-awake is not active on {device_id} (setting={value}); "
f"using a {AWAKE_HEARTBEAT_INTERVAL_SECONDS:g}-second host heartbeat"
)
primed = _run_awake_adb_command(
device_id,
["shell", "input", "keyevent", "KEYCODE_UNKNOWN"],
"prime the host stay-awake heartbeat",
)
if primed is None or primed.returncode != 0:
return None
return AWAKE_STRATEGY_HEARTBEAT
def _discover_connected_device_ids() -> list[str]:
"""Return the selected or first connected local Android device."""
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
return []
target = os.environ.get("ARTEMIS_DEVICE_ID") or os.environ.get("ADB_DEVICE_SERIAL")
host = os.environ.get("ADB_HOST") or settings.ADB_HOST or "127.0.0.1"
port_text = os.environ.get("ADB_PORT")
try:
port = int(port_text) if port_text else int(settings.ADB_PORT or 5037)
except (TypeError, ValueError):
port = 5037
try:
devices = AdbClient(host=host, port=port).device_list()
serials = [device.serial for device in devices if device.serial]
except Exception as exc:
logger.debug(f"Could not discover a device for the USB awake policy: {exc}")
return []
if target:
return [target] if target in serials else []
# Artemis has a single-device execution policy. Do not modify unrelated
# devices that happen to share the ADB server.
return serials[:1]
class ScreenAwakeService:
"""Apply USB stay-awake or a connection-scoped host heartbeat."""
def __init__(self):
self._lock = threading.RLock()
self._strategies: dict[str, str] = {}
self._heartbeat_stops: dict[str, threading.Event] = {}
self._heartbeat_threads: dict[str, threading.Thread] = {}
self._monitor_stop: threading.Event | None = None
self._monitor_thread: threading.Thread | None = None
def start(self, device_ids: Iterable[str] | None = None) -> dict[str, str | None]:
selected = list(device_ids) if device_ids is not None else _discover_connected_device_ids()
results = {
device_id: self.ensure_device(device_id)
for device_id in dict.fromkeys(selected)
if device_id
}
if _awake_enabled() and os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
self._start_device_monitor()
return results
def ensure_device(self, device_id: str) -> str | None:
"""Configure one device once per process; Android owns unplug behavior."""
if not device_id or not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
return None
with self._lock:
existing = self._strategies.get(device_id)
if existing is not None:
return existing
strategy = _configure_usb_stay_awake(device_id)
if strategy is not None:
self._strategies[device_id] = strategy
if strategy == AWAKE_STRATEGY_HEARTBEAT:
self._start_heartbeat(device_id)
return strategy
def _start_heartbeat(self, device_id: str) -> None:
if device_id in self._heartbeat_threads:
return
stop_event = threading.Event()
def heartbeat() -> None:
while not stop_event.wait(AWAKE_HEARTBEAT_INTERVAL_SECONDS):
_run_awake_adb_command(
device_id,
["shell", "input", "keyevent", "KEYCODE_UNKNOWN"],
"send the host stay-awake heartbeat",
)
thread = threading.Thread(
target=heartbeat,
name=f"artemis-awake-heartbeat-{device_id}",
daemon=True,
)
self._heartbeat_stops[device_id] = stop_event
self._heartbeat_threads[device_id] = thread
thread.start()
def _start_device_monitor(self) -> None:
with self._lock:
if self._monitor_thread is not None and self._monitor_thread.is_alive():
return
stop_event = threading.Event()
def monitor() -> None:
while not stop_event.wait(DEVICE_DISCOVERY_INTERVAL_SECONDS):
connected = set(_discover_connected_device_ids())
if stop_event.is_set():
break
self._reconcile_connected_devices(connected)
thread = threading.Thread(
target=monitor,
name="artemis-awake-device-monitor",
daemon=True,
)
self._monitor_stop = stop_event
self._monitor_thread = thread
thread.start()
def _reconcile_connected_devices(self, connected: set[str]) -> None:
"""Stop unplugged heartbeats and enroll newly attached devices."""
with self._lock:
missing = set(self._strategies) - connected
for device_id in missing:
self._stop_device(device_id)
for device_id in connected:
self.ensure_device(device_id)
def _stop_device(self, device_id: str) -> None:
with self._lock:
stop_event = self._heartbeat_stops.pop(device_id, None)
thread = self._heartbeat_threads.pop(device_id, None)
self._strategies.pop(device_id, None)
if stop_event is not None:
stop_event.set()
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=2.0)
def shutdown(self) -> None:
"""Stop host work; unplug behavior remains owned by Android's USB policy."""
with self._lock:
monitor_stop = self._monitor_stop
monitor_thread = self._monitor_thread
heartbeat_stops = list(self._heartbeat_stops.values())
heartbeat_threads = list(self._heartbeat_threads.values())
if monitor_stop is not None:
monitor_stop.set()
for stop_event in heartbeat_stops:
stop_event.set()
self._monitor_stop = None
self._monitor_thread = None
self._heartbeat_stops.clear()
self._heartbeat_threads.clear()
self._strategies.clear()
if monitor_thread is not None:
monitor_thread.join(timeout=2.0)
for thread in heartbeat_threads:
thread.join(timeout=2.0)
@property
def device_ids(self) -> tuple[str, ...]:
with self._lock:
return tuple(self._strategies)
screen_awake_service = ScreenAwakeService()
def start_awake_service(device_ids: Iterable[str] | None = None) -> dict[str, str | None]:
return screen_awake_service.start(device_ids)
def ensure_device_awake(device_id: str) -> str | None:
return screen_awake_service.ensure_device(device_id)
def shutdown_awake_service() -> None:
screen_awake_service.shutdown()
+361 -34
View File
@@ -12,13 +12,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Cross-process exclusive ownership for a local mobile device."""
"""Cross-process FIFO execution queue for the local Artemis device."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import hashlib
import json
import os
from pathlib import Path
@@ -40,6 +39,8 @@ class DeviceLockOwner:
device_id: str
description: str
acquired_at: str
session_id: str | None = None
ingress: str | None = None
@classmethod
def from_dict(cls, value: dict) -> DeviceLockOwner:
@@ -50,27 +51,57 @@ class DeviceLockOwner:
device_id=str(value["device_id"]),
description=str(value.get("description", "unknown task")),
acquired_at=str(value.get("acquired_at", "unknown time")),
session_id=(
str(value["session_id"])
if value.get("session_id") is not None
else None
),
ingress=(str(value["ingress"]) if value.get("ingress") is not None else None),
)
class DeviceExecutionLock:
"""A PID-aware lock shared by UI, CLI, and MCP task processes.
"""A PID-aware FIFO lease shared by UI, CLI, SDK, and MCP tasks.
Atomic file creation provides the inter-process exclusion. PID creation
time prevents a stale lock from blocking a device after a process exits or
Artemis intentionally supports one active local device. A single global
queue therefore orders every local task regardless of which API or process
submitted it. Submission layers may reserve a queue ticket before spawning
a worker; the worker receives that token through the environment and claims
the same position. Tasks without a submission layer reserve at acquire time.
Atomic file creation provides exclusion. PID creation time prevents stale
owners or queue tickets from blocking execution after a process exits or
after the operating system reuses the same PID.
"""
_MALFORMED_LOCK_GRACE_SECONDS = 5.0
_DEFAULT_POLL_INTERVAL_SECONDS = 0.1
QUEUE_TICKET_ENV = "ARTEMIS_DEVICE_QUEUE_TICKET"
def __init__(self, device_id: str, description: str = "Artemis task"):
def __init__(
self,
device_id: str,
description: str = "Artemis task",
queue_ticket: str | None = None,
):
self.device_id = device_id
self.description = description
self.token = uuid.uuid4().hex
digest = hashlib.sha256(device_id.encode("utf-8", errors="replace")).hexdigest()[:20]
self.path = get_temp_dir("device-locks") / f"device-{digest}.lock"
lock_dir = get_temp_dir("device-locks")
self.path = lock_dir / "artemis-global-device.lock"
self.queue_dir = lock_dir / "artemis-global-device.queue"
self.queue_ticket = queue_ticket or os.environ.pop(self.QUEUE_TICKET_ENV, None)
self.session_id = os.getenv("ARTEMIS_SESSION_ID") or os.getenv(
"ARTEMIS_CLOUD_SESSION_ID"
)
self.ingress = os.getenv("ARTEMIS_TASK_INGRESS") or "sdk"
self._queue_path: Path | None = None
self._acquired = False
@classmethod
def _global_lock_path(cls) -> Path:
return get_temp_dir("device-locks") / "artemis-global-device.lock"
@staticmethod
def _current_process_created_at() -> float:
try:
@@ -120,41 +151,243 @@ class DeviceExecutionLock:
except OSError:
return False
def acquire(self) -> None:
owner_payload = {
@classmethod
def _owner_payload(
cls,
token: str,
device_id: str,
description: str,
*,
session_id: str | None = None,
ingress: str | None = None,
) -> dict:
payload = {
"pid": os.getpid(),
"process_created_at": self._current_process_created_at(),
"token": self.token,
"device_id": self.device_id,
"description": self.description,
"process_created_at": cls._current_process_created_at(),
"token": token,
"device_id": device_id,
"description": description,
"acquired_at": datetime.now(UTC).isoformat(),
}
if session_id:
payload["session_id"] = str(session_id)
if ingress:
payload["ingress"] = str(ingress)
return payload
for _ in range(4):
@classmethod
def reserve(
cls,
description: str = "Artemis task",
device_id: str = "pending",
*,
session_id: str | None = None,
ingress: str | None = None,
) -> str:
"""Reserve a global FIFO position before a worker process is spawned."""
token = uuid.uuid4().hex
queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
queue_dir.mkdir(parents=True, exist_ok=True)
payload = cls._owner_payload(
token,
device_id,
description,
session_id=session_id,
ingress=ingress,
)
for _ in range(8):
path = queue_dir / f"{time.time_ns():020d}-{token}.wait"
try:
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
if self._remove_stale_lock():
continue
owner = self._read_owner(self.path)
owner_text = (
f"PID {owner.pid} ({owner.description}, since {owner.acquired_at})"
if owner is not None
else "another process"
)
raise DeviceBusyError(
f"Device '{self.device_id}' is already controlled by {owner_text}. "
"Wait for that task to finish or stop it before starting another task."
)
continue
else:
try:
os.write(fd, json.dumps(owner_payload, ensure_ascii=False).encode("utf-8"))
os.write(fd, json.dumps(payload, ensure_ascii=False).encode("utf-8"))
finally:
os.close(fd)
self._acquired = True
return
return token
raise DeviceBusyError("Could not reserve a position in the Artemis device queue.")
raise DeviceBusyError(f"Could not acquire the execution lock for '{self.device_id}'.")
@classmethod
def cancel_reservation(cls, token: str | None) -> bool:
"""Remove a pending queue ticket. Active execution leases are untouched."""
if not token:
return False
queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
removed = False
for path in queue_dir.glob(f"*-{token}.wait"):
try:
path.unlink(missing_ok=True)
removed = True
except OSError:
pass
return removed
@classmethod
def transfer_reservation(
cls,
token: str,
pid: int,
*,
description: str = "Artemis task",
device_id: str = "pending",
session_id: str | None = None,
ingress: str | None = None,
) -> bool:
"""Transfer a submission ticket to its worker process.
This makes a ticket stale as soon as a worker that failed before Agent
initialization exits, instead of keeping it alive with the submitting
server process.
"""
queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
matches = list(queue_dir.glob(f"*-{token}.wait"))
path = min(matches, default=None)
if path is None:
return False
try:
import psutil
process_created_at = float(psutil.Process(pid).create_time())
except Exception:
process_created_at = 0.0
payload = {
"pid": pid,
"process_created_at": process_created_at,
"token": token,
"device_id": device_id,
"description": description,
"acquired_at": datetime.now(UTC).isoformat(),
}
if session_id:
payload["session_id"] = str(session_id)
if ingress:
payload["ingress"] = str(ingress)
try:
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
except OSError:
return False
return True
def _find_reserved_ticket(self) -> Path | None:
if not self.queue_ticket:
return None
matches = list(self.queue_dir.glob(f"*-{self.queue_ticket}.wait"))
return min(matches, default=None)
def _claim_or_create_queue_ticket(self) -> Path:
self.queue_dir.mkdir(parents=True, exist_ok=True)
if self.queue_ticket:
path = self._find_reserved_ticket()
if path is None:
raise DeviceBusyError(
"The Artemis queue reservation was cancelled or expired before execution."
)
payload = self._owner_payload(
self.queue_ticket,
self.device_id,
self.description,
session_id=self.session_id,
ingress=self.ingress,
)
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
return path
self.queue_ticket = self.reserve(self.description, self.device_id)
path = self._find_reserved_ticket()
if path is None:
raise DeviceBusyError("The Artemis device queue ticket could not be created.")
return path
def _remove_stale_queue_entries(self) -> int:
removed = 0
if not self.queue_dir.exists():
return removed
for path in self.queue_dir.glob("*.wait"):
if self._queue_path is not None and path == self._queue_path:
continue
owner = self._read_owner(path)
if owner is not None and self._owner_is_alive(owner):
continue
if owner is None:
try:
if time.time() - path.stat().st_mtime < self._MALFORMED_LOCK_GRACE_SECONDS:
continue
except OSError:
continue
try:
path.unlink(missing_ok=True)
removed += 1
except OSError:
pass
return removed
def _try_acquire_owner_lock(self) -> bool:
owner_payload = self._owner_payload(
self.token,
self.device_id,
self.description,
session_id=self.session_id,
ingress=self.ingress,
)
try:
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
self._remove_stale_lock()
return False
try:
os.write(fd, json.dumps(owner_payload, ensure_ascii=False).encode("utf-8"))
finally:
os.close(fd)
self._acquired = True
return True
def acquire(
self,
*,
blocking: bool = True,
timeout: float | None = None,
poll_interval: float = _DEFAULT_POLL_INTERVAL_SECONDS,
cancel_event=None,
) -> None:
"""Wait in FIFO order and acquire exclusive access to the Artemis device."""
self._queue_path = self._claim_or_create_queue_ticket()
started_at = time.monotonic()
try:
while True:
if cancel_event is not None and cancel_event.is_set():
raise DeviceBusyError("Waiting for the Artemis device queue was cancelled.")
self._remove_stale_queue_entries()
queue = sorted(self.queue_dir.glob("*.wait"))
if not self._queue_path.exists():
raise DeviceBusyError("The Artemis queue reservation was cancelled.")
if queue and queue[0] == self._queue_path and self._try_acquire_owner_lock():
self._queue_path.unlink(missing_ok=True)
self._queue_path = None
return
if not blocking:
owner = self._read_owner(self.path)
owner_text = (
f"PID {owner.pid} ({owner.description}, since {owner.acquired_at})"
if owner is not None
else "an earlier queued task"
)
raise DeviceBusyError(
f"Device '{self.device_id}' is already controlled by {owner_text}."
)
if timeout is not None and time.monotonic() - started_at >= timeout:
raise DeviceBusyError(
f"Timed out waiting for the Artemis device queue after {timeout:.1f}s."
)
time.sleep(max(0.01, poll_interval))
except BaseException:
if self._queue_path is not None:
self._queue_path.unlink(missing_ok=True)
self._queue_path = None
raise
def release(self) -> None:
if not self._acquired:
@@ -166,12 +399,106 @@ class DeviceExecutionLock:
finally:
self._acquired = False
@classmethod
def get_active_owner(cls) -> DeviceLockOwner | None:
"""Return the live process that currently owns the global device lease."""
path = cls._global_lock_path()
owner = None
for attempt in range(3):
owner = cls._read_owner(path)
if owner is not None or not path.exists():
break
if attempt < 2:
time.sleep(0.01)
if owner is None:
try:
if path.exists() and time.time() - path.stat().st_mtime >= cls._MALFORMED_LOCK_GRACE_SECONDS:
path.unlink(missing_ok=True)
except OSError:
pass
return None
if cls._owner_is_alive(owner):
return owner
try:
path.unlink(missing_ok=True)
except OSError:
pass
return None
@classmethod
def has_owner_record(cls) -> bool:
"""Return whether an owner record exists, including a record being written."""
return cls._global_lock_path().exists()
@classmethod
def is_active_owner(cls, expected: DeviceLockOwner) -> bool:
"""Revalidate a previously read owner before performing a destructive action."""
current = cls._read_owner(cls._global_lock_path())
return bool(
current
and current.token == expected.token
and current.pid == expected.pid
and abs(current.process_created_at - expected.process_created_at) < 1.0
and cls._owner_is_alive(current)
)
@classmethod
def annotate_active_owner(
cls,
*,
session_id: str,
ingress: str | None = None,
) -> bool:
"""Attach session metadata after tracing starts inside the lease owner."""
path = cls._global_lock_path()
owner = cls._read_owner(path)
if owner is None or owner.pid != os.getpid() or not cls._owner_is_alive(owner):
return False
payload = {
"pid": owner.pid,
"process_created_at": owner.process_created_at,
"token": owner.token,
"device_id": owner.device_id,
"description": owner.description,
"acquired_at": owner.acquired_at,
"session_id": str(session_id),
"ingress": ingress or owner.ingress or "sdk",
}
temp_path = path.with_name(f"{path.name}.{uuid.uuid4().hex}.tmp")
try:
temp_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
os.replace(temp_path, path)
except OSError:
return False
finally:
try:
temp_path.unlink(missing_ok=True)
except OSError:
pass
return True
@classmethod
def cleanup_stale_locks(cls) -> int:
"""Remove lock files whose owning process is no longer alive."""
"""Remove execution leases and queue tickets whose owners are gone."""
removed = 0
lock_dir = get_temp_dir("device-locks")
for path in lock_dir.glob("device-*.lock"):
for path in lock_dir.glob("*.lock"):
owner = cls._read_owner(path)
if owner is not None and cls._owner_is_alive(owner):
continue
if owner is None:
try:
if time.time() - path.stat().st_mtime < cls._MALFORMED_LOCK_GRACE_SECONDS:
continue
except OSError:
continue
try:
path.unlink(missing_ok=True)
removed += 1
except OSError:
pass
queue_dir = lock_dir / "artemis-global-device.queue"
for path in queue_dir.glob("*.wait"):
owner = cls._read_owner(path)
if owner is not None and cls._owner_is_alive(owner):
continue
+26
View File
@@ -65,6 +65,32 @@ class ProcessSupervisor:
"""Recursively terminate a process tree."""
return platform.process.terminate_process_tree(pid, timeout_seconds=timeout_seconds)
@staticmethod
def terminate_tree_verified(
pid: int,
process_created_at: float,
timeout_seconds: float = 3.0,
) -> bool:
"""Terminate a process tree only if PID and creation time still match.
Cross-entry task control discovers the worker through a shared lease.
Verifying creation time prevents a stale lease from terminating an
unrelated process after the operating system reuses its PID.
"""
try:
import psutil
except Exception:
return False
try:
process = psutil.Process(pid)
if process_created_at > 0 and abs(process.create_time() - process_created_at) >= 1.0:
return False
except psutil.NoSuchProcess:
return True
except Exception:
return False
return ProcessSupervisor.terminate_tree(pid, timeout_seconds=timeout_seconds)
@staticmethod
async def stop_process(
proc: asyncio.subprocess.Process | None,
+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
+15 -5
View File
@@ -576,13 +576,14 @@ async def invoke_llm_with_timeout_message[T](
"""Send an LLM call and display a countdown / timeout message if delayed."""
llm_task = asyncio.create_task(llm_call)
waiter_task = asyncio.create_task(asyncio.sleep(timeout_seconds))
try:
done, _ = await asyncio.wait(
{llm_task, waiter_task}, return_when=asyncio.FIRST_COMPLETED
)
done, _ = await asyncio.wait({llm_task, waiter_task}, return_when=asyncio.FIRST_COMPLETED)
if llm_task in done:
return llm_task.result()
if llm_task in done:
waiter_task.cancel()
return llm_task.result()
else:
user_messages_logger.info("Waiting for LLM call response...")
start_time = asyncio.get_event_loop().time()
@@ -602,6 +603,15 @@ async def invoke_llm_with_timeout_message[T](
if elapsed > max(0, hard_timeout - timeout_seconds):
user_messages_logger.error(f"LLM call timed out after {hard_timeout} seconds.")
raise TimeoutError(f"LLM call timed out after {hard_timeout} seconds.")
except BaseException:
if not llm_task.done():
llm_task.cancel()
await asyncio.gather(llm_task, return_exceptions=True)
raise
finally:
if not waiter_task.done():
waiter_task.cancel()
await asyncio.gather(waiter_task, return_exceptions=True)
# Backward compatible factory functions delegating to ModelFactory
+58
View File
@@ -0,0 +1,58 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
"""Best-effort startup progress events for UI clients.
Startup happens before the normal DataEngine event bridge exists. This small
sender lets the worker report those otherwise invisible phases over the same
local IPC protocol without making task startup depend on the UI server.
"""
from __future__ import annotations
import json
import os
import socket
import time
from typing import Any
from artemis.config import read_ipc_port
def publish_startup_progress(stage: str, message: str, **details: Any) -> None:
"""Publish one startup milestone, ignoring unavailable UI/IPC endpoints."""
session_id = os.getenv("ARTEMIS_SESSION_ID") or os.getenv(
"ARTEMIS_CLOUD_SESSION_ID"
)
ipc_port = read_ipc_port()
if not session_id or not ipc_port:
return
event_data = {
"session_id": session_id,
"stage": stage,
"message": message,
"timestamp": time.time(),
**details,
}
encoded = (
json.dumps(
{"event_type": "startup_progress", "data": event_data},
default=str,
)
+ "\n"
).encode("utf-8")
try:
with socket.create_connection(("127.0.0.1", ipc_port), timeout=0.25) as sock:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
sock.sendall(encoded)
except OSError:
# Progress reporting is observability only and must never delay or fail
# the automation task itself.
return