feat: enable remote SDK and resilient device orchestration

This commit is contained in:
somew1nd committed 2026-08-30 00:41:07 -07:00
1 parent 3cc711df51
commit ddfa420945
110 files changed
+14450 -12954

No files matched your search

+14
View File
@@ -19,6 +19,14 @@ from artemis.runtime.device_lock import (
DeviceBusyError,
DeviceExecutionLock,
)
from artemis.runtime.adb_endpoint import (
ADB_ENDPOINT_ID_ENV,
AdbEndpoint,
AdbSession,
AdbTarget,
InvalidAdbEndpoint,
current_adb_endpoint,
)
from artemis.runtime.device_pool import DevicePool, DeviceStatus, device_pool
from artemis.runtime.daemon_client import (
ensure_daemon_running,
@@ -49,12 +57,18 @@ from artemis.runtime.server_lifecycle import (
from artemis.runtime.supervisor import ProcessSupervisor, process_supervisor
__all__ = [
"ADB_ENDPOINT_ID_ENV",
"AdbEndpoint",
"AdbSession",
"AdbTarget",
"ConcurrencyMode",
"DeviceBusyError",
"DeviceExecutionLock",
"DevicePool",
"DeviceStatus",
"InvalidAdbEndpoint",
"clear_server_info",
"current_adb_endpoint",
"device_pool",
"ensure_daemon_running",
"ensure_device_awake",
+168
View File
@@ -0,0 +1,168 @@
# 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.
"""Immutable ADB endpoint and task target primitives.
The selected endpoint is a user preference. An :class:`AdbTarget` is an
execution snapshot. Keeping those concepts separate prevents a queued or
running task from silently moving to another ADB server when the preference
changes in the Admin Console.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
import os
import re
import subprocess
from typing import Any, Mapping, MutableMapping, Sequence
from artemis.config import settings
from artemis.config.constants import DEFAULT_ADB_HOST, DEFAULT_ADB_PORT
from artemis.toolchain import toolchain
ADB_ENDPOINT_ID_ENV = "ARTEMIS_ADB_ENDPOINT_ID"
_LOCAL_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
_SAFE_HOST_PATTERN = re.compile(r"^[A-Za-z0-9._:\-\[\]]+$")
class InvalidAdbEndpoint(ValueError):
"""Raised when an ADB server endpoint is malformed."""
@dataclass(frozen=True, slots=True)
class AdbEndpoint:
"""Network address of one ADB server."""
host: str
port: int
@classmethod
def create(cls, host: str, port: int) -> AdbEndpoint:
clean_host = str(host).strip()
if clean_host.startswith("[") and clean_host.endswith("]"):
clean_host = clean_host[1:-1]
if not clean_host:
raise InvalidAdbEndpoint("ADB server host cannot be empty.")
if len(clean_host) > 253 or not _SAFE_HOST_PATTERN.fullmatch(clean_host):
raise InvalidAdbEndpoint(
"Enter a valid IP address or host name without a URL scheme or path."
)
if not 1 <= int(port) <= 65535:
raise InvalidAdbEndpoint("ADB server port must be between 1 and 65535.")
return cls(host=clean_host, port=int(port))
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> AdbEndpoint:
return cls.create(str(value.get("host", "")), int(value.get("port", 0)))
@classmethod
def local(cls) -> AdbEndpoint:
return cls(DEFAULT_ADB_HOST, DEFAULT_ADB_PORT)
@property
def socket(self) -> str:
host = self.host
if ":" in host and not host.startswith("["):
host = f"[{host}]"
return f"tcp:{host}:{self.port}"
@property
def identity(self) -> str:
"""Stable identity used for task snapshots and device-lock scoping."""
return self.socket.lower()
@property
def is_local_default(self) -> bool:
return self.host.lower() in _LOCAL_HOSTS and self.port == DEFAULT_ADB_PORT
@property
def mode(self) -> str:
return "local" if self.is_local_default else "remote"
def to_dict(self) -> dict[str, Any]:
return {
**asdict(self),
"socket": self.socket,
"identity": self.identity,
"mode": self.mode,
"is_local_default": self.is_local_default,
}
def apply_to_environment(
self,
environment: MutableMapping[str, str] | None = None,
) -> MutableMapping[str, str]:
target = environment if environment is not None else os.environ
target["ADB_HOST"] = self.host
target["ADB_PORT"] = str(self.port)
target["ADB_SERVER_SOCKET"] = self.socket
target[ADB_ENDPOINT_ID_ENV] = self.identity
return target
@dataclass(frozen=True, slots=True)
class AdbTarget:
"""A device serial bound to the ADB endpoint that discovered it."""
endpoint: AdbEndpoint
serial: str | None = None
@property
def lock_scope(self) -> str:
return self.endpoint.identity
@property
def lock_key(self) -> str:
return f"{self.lock_scope}/{self.serial or 'pending'}"
def to_dict(self) -> dict[str, Any]:
return {"endpoint": self.endpoint.to_dict(), "serial": self.serial}
class AdbSession:
"""Execute ADB commands against one explicit, immutable endpoint."""
def __init__(self, endpoint: AdbEndpoint, adb_path: str | None = None) -> None:
self.endpoint = endpoint
self.adb_path = adb_path or toolchain.resolve("adb")
def command(self, arguments: Sequence[str]) -> list[str]:
if not self.adb_path:
raise FileNotFoundError("Android Platform Tools (adb) could not be found.")
return [
self.adb_path,
"-H",
self.endpoint.host,
"-P",
str(self.endpoint.port),
*arguments,
]
def environment(self, base: Mapping[str, str] | None = None) -> dict[str, str]:
environment = dict(base if base is not None else os.environ)
self.endpoint.apply_to_environment(environment)
return environment
def run(self, arguments: Sequence[str], **kwargs: Any) -> subprocess.CompletedProcess:
kwargs.setdefault("env", self.environment())
return subprocess.run(self.command(arguments), **kwargs)
def current_adb_endpoint() -> AdbEndpoint:
"""Return the process preference as an immutable endpoint snapshot."""
host = settings.ADB_HOST or os.environ.get("ADB_HOST") or DEFAULT_ADB_HOST
port = settings.ADB_PORT or os.environ.get("ADB_PORT") or DEFAULT_ADB_PORT
return AdbEndpoint.create(str(host), int(port))
+33 -3
View File
@@ -62,11 +62,24 @@ def is_daemon_running(
return False
def daemon_log_path(port: int = DEFAULT_DAEMON_PORT) -> Path:
"""Return the log file path used by a background-spawned Daemon on the given port."""
from artemis.config.paths import get_app_dir
return get_app_dir() / "logs" / f"daemon-{port}.log"
def spawn_daemon(
host: str = DEFAULT_DAEMON_HOST,
port: int = DEFAULT_DAEMON_PORT,
) -> subprocess.Popen | None:
"""Silently spawn the Artemis Daemon server in the background as a detached process."""
"""Silently spawn the Artemis Daemon server in the background as a detached process.
The server is always launched via ``sys.executable -m`` (never through the
``artemis``/``artemis-admin`` console-script shims) so the long-lived process
chain never holds a lock on ``Scripts/artemis.exe`` — on Windows, a resident
shim blocks ``uv sync``/reinstalls with "os error 32" until it exits.
"""
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
env["PYTHONUTF8"] = "1"
@@ -81,11 +94,21 @@ def spawn_daemon(
str(port),
]
# Persist daemon output for post-mortem debugging (daemon mode is the
# default for `artemis restart`, so silent DEVNULL would hide crashes).
log_handle: Any = subprocess.DEVNULL
try:
log_file = daemon_log_path(port)
log_file.parent.mkdir(parents=True, exist_ok=True)
log_handle = open(log_file, "ab")
except Exception:
log_handle = subprocess.DEVNULL
kwargs: dict[str, Any] = {
"cwd": str(ROOT_DIR),
"env": env,
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"stdout": log_handle,
"stderr": subprocess.STDOUT if log_handle is not subprocess.DEVNULL else subprocess.DEVNULL,
"stdin": subprocess.DEVNULL,
}
@@ -105,6 +128,13 @@ def spawn_daemon(
except Exception as exc:
logger.warning(f"Could not auto-spawn Artemis Daemon: {exc}")
return None
finally:
# The child inherited the handle; the parent's copy is no longer needed.
if log_handle is not subprocess.DEVNULL:
try:
log_handle.close()
except Exception:
pass
def ensure_daemon_running(
+107 -17
View File
@@ -50,6 +50,7 @@ class DeviceLockOwner:
acquired_at: str
session_id: str | None = None
ingress: str | None = None
lock_scope: str | None = None
@classmethod
def from_dict(cls, value: dict) -> DeviceLockOwner:
@@ -66,6 +67,11 @@ class DeviceLockOwner:
else None
),
ingress=(str(value["ingress"]) if value.get("ingress") is not None else None),
lock_scope=(
str(value["lock_scope"])
if value.get("lock_scope") is not None
else None
),
)
@@ -84,6 +90,7 @@ class DeviceExecutionLock:
_MALFORMED_LOCK_GRACE_SECONDS = 5.0
_DEFAULT_POLL_INTERVAL_SECONDS = 0.1
QUEUE_TICKET_ENV = "ARTEMIS_DEVICE_QUEUE_TICKET"
LOCK_SCOPE_ENV = "ARTEMIS_ADB_ENDPOINT_ID"
@staticmethod
def _normalize_device_id(device_id: str | None) -> str:
@@ -96,6 +103,16 @@ class DeviceExecutionLock:
clean = re.sub(r"[^a-zA-Z0-9_-]", "_", raw)
return clean or "default"
@classmethod
def _normalize_lock_id(
cls,
device_id: str | None,
lock_scope: str | None = None,
) -> str:
clean_device = cls._normalize_device_id(device_id)
clean_scope = cls._normalize_device_id(lock_scope) if lock_scope else None
return f"{clean_scope}__{clean_device}" if clean_scope else clean_device
def __init__(
self,
device_id: str = "default",
@@ -105,9 +122,11 @@ class DeviceExecutionLock:
concurrency_mode: str | None = None,
session_id: str | None = None,
ingress: str | None = None,
lock_scope: str | None = None,
):
self.device_id = device_id or "default"
self.clean_device_id = self._normalize_device_id(self.device_id)
self.lock_scope = lock_scope or os.getenv(self.LOCK_SCOPE_ENV) or None
self.clean_device_id = self._normalize_lock_id(self.device_id, self.lock_scope)
self.description = description
self.max_concurrency = max_concurrency
self.concurrency_mode = (
@@ -130,8 +149,15 @@ class DeviceExecutionLock:
self._acquired = False
@classmethod
def _device_lock_path(cls, device_id: str | None) -> Path:
clean_id = cls._normalize_device_id(device_id)
def _device_lock_path(
cls,
device_id: str | None,
lock_scope: str | None = None,
) -> Path:
clean_id = cls._normalize_lock_id(
device_id,
lock_scope or os.getenv(cls.LOCK_SCOPE_ENV) or None,
)
return get_temp_dir("device-locks") / f"artemis-device-{clean_id}.lock"
@classmethod
@@ -224,6 +250,7 @@ class DeviceExecutionLock:
*,
session_id: str | None = None,
ingress: str | None = None,
lock_scope: str | None = None,
) -> dict:
payload = {
"pid": os.getpid(),
@@ -237,6 +264,8 @@ class DeviceExecutionLock:
payload["session_id"] = str(session_id)
if ingress:
payload["ingress"] = str(ingress)
if lock_scope:
payload["lock_scope"] = str(lock_scope)
return payload
@classmethod
@@ -247,6 +276,7 @@ class DeviceExecutionLock:
*,
session_id: str | None = None,
ingress: str | None = None,
lock_scope: str | None = None,
) -> str:
"""Reserve a FIFO position in the global queue before a worker process is spawned."""
token = uuid.uuid4().hex
@@ -258,6 +288,7 @@ class DeviceExecutionLock:
description,
session_id=session_id,
ingress=ingress,
lock_scope=lock_scope or os.getenv(cls.LOCK_SCOPE_ENV) or None,
)
for _ in range(8):
path = queue_dir / f"{time.time_ns():020d}-{token}.wait"
@@ -299,6 +330,7 @@ class DeviceExecutionLock:
device_id: str = "pending",
session_id: str | None = None,
ingress: str | None = None,
lock_scope: str | None = None,
) -> bool:
"""Transfer a submission ticket to its worker process."""
queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
@@ -326,6 +358,9 @@ class DeviceExecutionLock:
payload["session_id"] = str(session_id)
if ingress:
payload["ingress"] = str(ingress)
effective_scope = lock_scope or os.getenv(cls.LOCK_SCOPE_ENV) or None
if effective_scope:
payload["lock_scope"] = str(effective_scope)
try:
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
except OSError:
@@ -352,6 +387,7 @@ class DeviceExecutionLock:
self.description,
session_id=self.session_id,
ingress=self.ingress,
lock_scope=self.lock_scope,
)
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
return path
@@ -361,6 +397,7 @@ class DeviceExecutionLock:
self.device_id,
session_id=self.session_id,
ingress=self.ingress,
lock_scope=self.lock_scope,
)
path = self._find_reserved_ticket()
if path is None:
@@ -397,6 +434,7 @@ class DeviceExecutionLock:
self.description,
session_id=self.session_id,
ingress=self.ingress,
lock_scope=self.lock_scope,
)
try:
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
@@ -474,7 +512,11 @@ class DeviceExecutionLock:
o
for o in active_owners.values()
if not (
(o.pid == os.getpid() and ((self.session_id and o.session_id == self.session_id) or o.token == self.token))
o.pid == os.getpid()
and (
(self.session_id and o.session_id == self.session_id)
or o.token == self.token
)
)
]
if len(active_other_owners) >= effective_concurrency:
@@ -513,7 +555,18 @@ class DeviceExecutionLock:
if ticket_owner is None:
continue
ticket_dev = self._normalize_device_id(ticket_owner.device_id)
if ticket_dev == target_dev or ticket_dev in ("pending", "any"):
ticket_lock_id = self._normalize_lock_id(
ticket_owner.device_id,
ticket_owner.lock_scope,
)
same_scope = (
not ticket_owner.lock_scope
or not self.lock_scope
or ticket_owner.lock_scope == self.lock_scope
)
if ticket_lock_id == target_dev or (
ticket_dev in ("pending", "any") and same_scope
):
device_queue.append(wait_path)
is_eligible = bool(device_queue and device_queue[0] == self._queue_path)
@@ -585,14 +638,13 @@ class DeviceExecutionLock:
lock_dir = get_temp_dir("device-locks")
if not lock_dir.exists():
return {}
active_map: dict[str, DeviceLockOwner] = {}
seen_tokens = set()
owners: list[DeviceLockOwner] = []
seen_tokens: set[str] = set()
for lock_path in sorted(lock_dir.glob("artemis-device-*.lock")):
owner = cls._get_active_owner_for_path(lock_path)
if owner is not None and owner.token not in seen_tokens:
dev_key = cls._normalize_device_id(owner.device_id)
active_map[dev_key] = owner
owners.append(owner)
seen_tokens.add(owner.token)
# Also check legacy global lock if still present
@@ -600,22 +652,45 @@ class DeviceExecutionLock:
if global_lock.exists():
owner = cls._get_active_owner_for_path(global_lock)
if owner is not None and owner.token not in seen_tokens:
dev_key = cls._normalize_device_id(owner.device_id)
active_map[dev_key] = owner
owners.append(owner)
seen_tokens.add(owner.token)
active_map: dict[str, DeviceLockOwner] = {}
owners_by_device: dict[str, list[DeviceLockOwner]] = {}
for owner in owners:
key = cls._normalize_device_id(owner.device_id)
owners_by_device.setdefault(key, []).append(owner)
for device_key, device_owners in owners_by_device.items():
if len(device_owners) == 1:
active_map[device_key] = device_owners[0]
continue
for owner in device_owners:
scoped_key = cls._normalize_lock_id(owner.device_id, owner.lock_scope)
active_map[scoped_key] = owner
return active_map
@classmethod
def get_active_owner(cls, device_id: str | None = None) -> DeviceLockOwner | None:
def get_active_owner(
cls,
device_id: str | None = None,
lock_scope: str | None = None,
) -> DeviceLockOwner | None:
"""Return the live process owning the lock for a specific device, or any device if None."""
lock_dir = get_temp_dir("device-locks")
if device_id is not None:
clean_id = cls._normalize_device_id(device_id)
effective_scope = lock_scope or os.getenv(cls.LOCK_SCOPE_ENV) or None
clean_id = cls._normalize_lock_id(device_id, effective_scope)
dev_path = lock_dir / f"artemis-device-{clean_id}.lock"
owner = cls._get_active_owner_for_path(dev_path)
if owner is not None:
return owner
if effective_scope:
legacy_path = lock_dir / (
f"artemis-device-{cls._normalize_device_id(device_id)}.lock"
)
owner = cls._get_active_owner_for_path(legacy_path)
if owner is not None:
return owner
# Check legacy global lock as fallback
global_path = lock_dir / "artemis-global-device.lock"
if global_path.exists():
@@ -636,7 +711,10 @@ class DeviceExecutionLock:
if not queue_dir.exists():
return []
clean_target = cls._normalize_device_id(device_id) if device_id else None
target_scope = os.getenv(cls.LOCK_SCOPE_ENV) or None
clean_target = (
cls._normalize_lock_id(device_id, target_scope) if device_id else None
)
active_tokens = {o.token for o in cls.get_active_owners().values()}
queued: list[dict[str, Any]] = []
@@ -652,7 +730,15 @@ class DeviceExecutionLock:
continue
target_dev = cls._normalize_device_id(owner.device_id)
if clean_target is not None and target_dev not in (clean_target, "pending", "any"):
target_lock_id = cls._normalize_lock_id(owner.device_id, owner.lock_scope)
same_scope = (
not target_scope
or not owner.lock_scope
or owner.lock_scope == target_scope
)
if clean_target is not None and target_lock_id != clean_target and not (
target_dev in ("pending", "any") and same_scope
):
continue
try:
@@ -665,6 +751,7 @@ class DeviceExecutionLock:
"goal": owner.description,
"device_id": owner.device_id,
"device_serial": owner.device_id,
"adb_endpoint_id": owner.lock_scope,
"pid": owner.pid,
"token": owner.token,
"ingress": owner.ingress or "unknown",
@@ -681,7 +768,10 @@ class DeviceExecutionLock:
if not lock_dir.exists():
return False
if device_id is not None:
clean_id = cls._normalize_device_id(device_id)
clean_id = cls._normalize_lock_id(
device_id,
os.getenv(cls.LOCK_SCOPE_ENV) or None,
)
return (
lock_dir / f"artemis-device-{clean_id}.lock"
).exists() or (lock_dir / "artemis-global-device.lock").exists()
@@ -694,7 +784,7 @@ class DeviceExecutionLock:
"""Revalidate a previously read owner before performing a destructive action."""
target_dev = device_id or expected.device_id
lock_dir = get_temp_dir("device-locks")
clean_id = cls._normalize_device_id(target_dev)
clean_id = cls._normalize_lock_id(target_dev, expected.lock_scope)
path = lock_dir / f"artemis-device-{clean_id}.lock"
if not path.exists():
path = lock_dir / "artemis-global-device.lock"
+233 -38
View File
@@ -18,8 +18,11 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass
import os
import shutil
import subprocess
import threading
import time
from typing import Any
from artemis.runtime.device_lock import DeviceExecutionLock
@@ -62,8 +65,28 @@ class DeviceStatus:
class DevicePool:
"""Manages discovery and assignment across all connected Android devices."""
# A snapshot younger than this is returned without touching adb, so a burst
# of concurrent enumerations (UI polling, readiness probes, submissions)
# collapses into a single `adb devices` call.
CACHE_TTL = 1.0
# When enumeration fails outright (adb server restarting, subprocess error),
# the last successful snapshot keeps answering for this long. Real
# disconnects are not failures -- adb reports them as a successful listing
# without the device -- so serving stale here only bridges server-level blips.
STALE_ON_ERROR_TTL = 10.0
# Until one enumeration has succeeded, `adb devices` may need to spawn the
# adb server itself, which routinely exceeds the hot-path budget.
HOT_QUERY_TIMEOUT = 2.0
COLD_QUERY_TIMEOUT = 8.0
def __init__(self, adb_path: str | None = None):
self._adb_path = adb_path
self._cache_lock = threading.Lock()
self._cached_raw: list[tuple[str, str, str | None, str | None]] | None = None
self._cached_at = 0.0
self._warmed = False
self._sync_query_gate = threading.Lock()
self._async_inflight: tuple[asyncio.AbstractEventLoop, asyncio.Task] | None = None
def _resolve_adb(self) -> str | None:
if self._adb_path:
@@ -73,32 +96,41 @@ class DevicePool:
except Exception:
return shutil.which("adb")
def _query_adb_devices_sync(self, timeout: float = 2.0) -> list[tuple[str, str, str | None, str | None]]:
"""Run `adb devices -l` synchronously to parse attached devices."""
def _current_query_timeout(self) -> float:
return self.HOT_QUERY_TIMEOUT if self._warmed else self.COLD_QUERY_TIMEOUT
def _query_adb_devices_sync(
self, timeout: float | None = None
) -> list[tuple[str, str, str | None, str | None]] | None:
"""Run `adb devices -l` synchronously. Returns None when the query
itself failed, as opposed to an empty list of attached devices."""
adb = self._resolve_adb()
if not adb:
return []
return None
try:
res = subprocess.run(
[adb, "devices", "-l"],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=timeout,
timeout=timeout if timeout is not None else self._current_query_timeout(),
check=False,
)
if res.returncode != 0:
return []
return None
return self._parse_device_lines(res.stdout.splitlines())
except Exception as exc:
logger.debug(f"Error querying adb devices: {exc}")
return []
return None
async def _query_adb_devices_async(self, timeout: float = 2.0) -> list[tuple[str, str, str | None, str | None]]:
"""Run `adb devices -l` asynchronously to parse attached devices."""
async def _query_adb_devices_async(
self, timeout: float | None = None
) -> list[tuple[str, str, str | None, str | None]] | None:
"""Run `adb devices -l` asynchronously. Returns None when the query
itself failed, as opposed to an empty list of attached devices."""
adb = self._resolve_adb()
if not adb:
return []
return None
proc = None
try:
proc = await asyncio.create_subprocess_exec(
@@ -108,9 +140,12 @@ class DevicePool:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
stdout, _ = await asyncio.wait_for(
proc.communicate(),
timeout=timeout if timeout is not None else self._current_query_timeout(),
)
if proc.returncode != 0:
return []
return None
return self._parse_device_lines(stdout.decode(errors="replace").splitlines())
except Exception as exc:
logger.debug(f"Error querying adb devices asynchronously: {exc}")
@@ -119,7 +154,7 @@ class DevicePool:
proc.kill()
except Exception:
pass
return []
return None
@staticmethod
def _parse_device_lines(lines: list[str]) -> list[tuple[str, str, str | None, str | None]]:
@@ -143,16 +178,134 @@ class DevicePool:
results.append((serial, state, model, product))
return results
def list_devices(self) -> list[DeviceStatus]:
"""Synchronously list all connected devices along with their active lock state."""
raw_devices = self._query_adb_devices_sync()
def _cached_snapshot(
self, *, allow_stale: bool
) -> list[tuple[str, str, str | None, str | None]] | None:
with self._cache_lock:
if self._cached_raw is None:
return None
age = time.monotonic() - self._cached_at
if age <= self.CACHE_TTL or (allow_stale and age <= self.STALE_ON_ERROR_TTL):
return list(self._cached_raw)
return None
def _store_snapshot(self, raw: list[tuple[str, str, str | None, str | None]]) -> None:
with self._cache_lock:
self._cached_raw = list(raw)
self._cached_at = time.monotonic()
self._warmed = True
def _enumerate_sync(self) -> list[tuple[str, str, str | None, str | None]] | None:
"""Return the raw enumeration, or None when the query failed and no
usable snapshot exists -- never an ambiguous empty list on failure."""
cached = self._cached_snapshot(allow_stale=False)
if cached is not None:
return cached
# The gate serializes concurrent enumerations: followers block until the
# leader finishes, then hit the snapshot it just stored.
with self._sync_query_gate:
cached = self._cached_snapshot(allow_stale=False)
if cached is not None:
return cached
raw = self._query_adb_devices_sync()
if raw is None:
return self._cached_snapshot(allow_stale=True)
self._store_snapshot(raw)
return raw
async def _enumerate_async(self) -> list[tuple[str, str, str | None, str | None]] | None:
cached = self._cached_snapshot(allow_stale=False)
if cached is not None:
return cached
loop = asyncio.get_running_loop()
inflight = self._async_inflight
if inflight is not None and inflight[0] is loop and not inflight[1].done():
# Shield followers so one cancelled waiter cannot abort the shared query.
return await asyncio.shield(inflight[1])
task = loop.create_task(self._enumerate_async_uncached())
self._async_inflight = (loop, task)
try:
return await task
finally:
if self._async_inflight is not None and self._async_inflight[1] is task:
self._async_inflight = None
async def _enumerate_async_uncached(self) -> list[tuple[str, str, str | None, str | None]] | None:
raw = await self._query_adb_devices_async()
if raw is None:
return self._cached_snapshot(allow_stale=True)
self._store_snapshot(raw)
return raw
async def _start_adb_server(self, timeout: float) -> None:
"""Best-effort bounded `adb start-server` so later queries hit a warm daemon."""
adb = self._resolve_adb()
if not adb:
return
proc = None
try:
proc = await asyncio.create_subprocess_exec(
adb,
"start-server",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
await asyncio.wait_for(proc.communicate(), timeout=timeout)
except Exception as exc:
logger.debug(f"adb start-server warm-up did not complete: {exc}")
if proc is not None:
try:
proc.kill()
except Exception:
pass
async def warm_up_async(
self,
*,
server_timeout: float = 10.0,
settle_timeout: float = 3.0,
poll_interval: float = 0.5,
) -> bool:
"""Start the adb server and complete one successful enumeration.
Meant to run once before an entrypoint starts accepting task
submissions, so the first requests never race an adb cold start.
Devices reconnect asynchronously after the server comes up, so the
settle window keeps polling until at least one device reaches the
ready state (or the window closes). Returns True once any enumeration
succeeded -- zero attached devices is still a warm pool.
"""
if self._resolve_adb() is None:
return False
await self._start_adb_server(timeout=server_timeout)
deadline = time.monotonic() + max(settle_timeout, 0.0)
succeeded = False
while True:
raw = await self._query_adb_devices_async()
if raw is not None:
succeeded = True
self._store_snapshot(raw)
if any(state == "device" for _, state, _, _ in raw):
return True
if time.monotonic() >= deadline:
return succeeded
await asyncio.sleep(poll_interval)
def _build_statuses(
self, raw_devices: list[tuple[str, str, str | None, str | None]]
) -> list[DeviceStatus]:
active_owners = DeviceExecutionLock.get_active_owners()
devices: list[DeviceStatus] = []
for serial, state, model, product in raw_devices:
is_emu = serial.startswith("emulator-") or "127.0.0.1" in serial or "localhost" in serial
clean_id = DeviceExecutionLock._normalize_device_id(serial)
owner = active_owners.get(clean_id)
clean_id = DeviceExecutionLock._normalize_lock_id(
serial,
os.getenv(DeviceExecutionLock.LOCK_SCOPE_ENV) or None,
)
owner = active_owners.get(clean_id) or active_owners.get(
DeviceExecutionLock._normalize_device_id(serial)
)
status = DeviceStatus(
serial=serial,
@@ -169,31 +322,73 @@ class DevicePool:
devices.append(status)
return devices
def list_devices(self) -> list[DeviceStatus]:
"""Synchronously list all connected devices along with their active lock state.
The raw adb enumeration may be served from a short-lived snapshot;
lock ownership is always computed fresh. A failed enumeration collapses
to an empty list -- use try_list_devices when the caller must tell the
two apart.
"""
return self._build_statuses(self._enumerate_sync() or [])
async def list_devices_async(self) -> list[DeviceStatus]:
"""Asynchronously list all connected devices along with their active lock state."""
raw_devices = await self._query_adb_devices_async()
active_owners = DeviceExecutionLock.get_active_owners()
"""Asynchronously list all connected devices along with their active lock state.
devices: list[DeviceStatus] = []
for serial, state, model, product in raw_devices:
is_emu = serial.startswith("emulator-") or "127.0.0.1" in serial or "localhost" in serial
clean_id = DeviceExecutionLock._normalize_device_id(serial)
owner = active_owners.get(clean_id)
The raw adb enumeration may be served from a short-lived snapshot;
lock ownership is always computed fresh. A failed enumeration collapses
to an empty list -- use try_list_devices_async when the caller must tell
the two apart.
"""
return self._build_statuses(await self._enumerate_async() or [])
status = DeviceStatus(
serial=serial,
state=state,
model=model,
product=product,
is_emulator=is_emu,
is_busy=owner is not None,
active_pid=owner.pid if owner else None,
active_task_desc=owner.description if owner else None,
active_session_id=owner.session_id if owner else None,
acquired_at=owner.acquired_at if owner else None,
def try_list_devices(self) -> list[DeviceStatus] | None:
"""Like list_devices, but returns None when enumeration failed and no
usable snapshot exists, so callers can distinguish "could not ask adb"
from "adb answered: no devices attached"."""
raw = self._enumerate_sync()
return None if raw is None else self._build_statuses(raw)
async def try_list_devices_async(self) -> list[DeviceStatus] | None:
"""Async variant of try_list_devices."""
raw = await self._enumerate_async()
return None if raw is None else self._build_statuses(raw)
@staticmethod
def _explicit_serial_error(
requested_serial: str, devices: list[DeviceStatus] | None
) -> str | None:
# Fail open on an indeterminate or empty enumeration (adb missing, server
# blip, devices still handshaking): the submission proceeds and fails
# downstream with a clear no-device error instead of a false hard reject.
if not devices:
return None
norm = DeviceExecutionLock._normalize_device_id
state_by_serial = {norm(d.serial): d.state for d in devices}
dev_state = state_by_serial.get(norm(requested_serial))
if dev_state == "device":
return None
if dev_state is None:
return (
f"Device '{requested_serial}' is not connected. "
f"Attached devices: {sorted(state_by_serial)}."
)
devices.append(status)
return devices
return f"Device '{requested_serial}' is attached but not ready (state: '{dev_state}')."
def validate_explicit_serial(self, requested_serial: str) -> str | None:
"""Return a rejection message for an explicitly requested serial, or None.
Only a successful, non-empty enumeration may reject: it is the single
authority for the strict device-binding check shared by the admission
paths (admin console router/queue and the MCP task runner).
"""
return self._explicit_serial_error(requested_serial, self.try_list_devices())
async def validate_explicit_serial_async(self, requested_serial: str) -> str | None:
"""Async variant of validate_explicit_serial."""
return self._explicit_serial_error(
requested_serial, await self.try_list_devices_async()
)
def get_ready_devices(self) -> list[DeviceStatus]:
"""Return devices in authorized 'device' state."""
+139 -12
View File
@@ -30,6 +30,8 @@ import subprocess
import sys
import time
from typing import Any
import urllib.error
import urllib.request
from artemis.config.paths import ROOT_DIR, get_server_info_file
from artemis.runtime.device_lock import DeviceExecutionLock
@@ -51,8 +53,9 @@ def is_port_in_use(port: int, host: str = "127.0.0.1", timeout: float = 0.5) ->
def write_server_info(
port: int,
host: str = "0.0.0.0",
host: str = "127.0.0.1",
pid: int | None = None,
lifecycle_token: str | None = None,
) -> Path:
"""Persist current Artemis server runtime metadata to well-known path."""
info_file = get_server_info_file()
@@ -67,6 +70,8 @@ def write_server_info(
"sys_executable": sys.executable,
"cmdline": sys.argv,
}
if lifecycle_token:
data["lifecycle_token"] = lifecycle_token
info_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
logger.debug(f"Saved Artemis server info (PID: {data['pid']}, port: {port}) to {info_file}")
except Exception as e:
@@ -91,17 +96,105 @@ def read_server_info() -> dict[str, Any] | None:
return None
def clear_server_info() -> None:
"""Remove the persisted server metadata file if it exists."""
def clear_server_info(
*,
port: int | None = None,
lifecycle_token: str | None = None,
) -> None:
"""Remove persisted metadata, optionally only for the matching server.
Scoped removal keeps ``artemis stop --port <unused>`` and a late shutdown
finalizer from deleting metadata written by a different server instance.
"""
info_file = get_server_info_file()
try:
if info_file.exists():
if port is not None or lifecycle_token is not None:
info = read_server_info()
if not info:
return
if port is not None and info.get("port") != port:
return
if lifecycle_token is not None and info.get("lifecycle_token") != lifecycle_token:
return
info_file.unlink()
logger.debug(f"Cleared server info file at {info_file}")
except Exception as e:
logger.debug(f"Error clearing server info file {info_file}: {e}")
def request_graceful_shutdown(port: int, timeout: float = 1.5) -> bool:
"""Ask a locally managed Artemis server to run its shutdown lifecycle.
The per-process token in the server metadata prevents this control request
from being accepted remotely or accidentally sent to an unrelated service
using the same port. A missing/stale token simply makes the caller fall back
to process termination.
"""
info = read_server_info()
if not info or info.get("port") != port:
return False
token = info.get("lifecycle_token")
if not isinstance(token, str) or not token:
return False
request = urllib.request.Request(
f"http://127.0.0.1:{port}/api/system/shutdown",
method="POST",
headers={
"User-Agent": "Artemis-Lifecycle-Client/1.0",
"X-Artemis-Lifecycle-Token": token,
},
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.status in (200, 202)
except (OSError, urllib.error.URLError, urllib.error.HTTPError):
return False
except Exception:
return False
def _reconcile_orphaned_sessions() -> int:
"""Immediately mark sessions whose worker PIDs died during forced stop."""
try:
from apps.admin_console.database.repositories.session_repository import session_repo
return session_repo.reconcile_orphaned_sessions()
except Exception as exc:
logger.debug(f"Could not reconcile orphaned sessions after server stop: {exc}")
return 0
def _any_pid_alive(pids: list[int]) -> bool:
"""Return whether any target process is still running (zombies excluded)."""
if not pids:
return False
try:
import psutil
for pid in pids:
try:
process = psutil.Process(pid)
if process.is_running() and process.status() != psutil.STATUS_ZOMBIE:
return True
except psutil.NoSuchProcess:
continue
except psutil.AccessDenied:
return True
return False
except Exception:
return any(_pid_exists_portably(pid) for pid in pids)
def _pid_exists_portably(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except OSError:
return False
def find_server_pids(port: int = 8000) -> list[int]:
"""Find all process IDs associated with the Artemis server listening on `port`.
@@ -243,15 +336,15 @@ def get_server_status(port: int = 8000) -> dict[str, Any]:
def stop_server(
port: int = 8000,
timeout: float = 4.0,
timeout: float = 12.0,
force: bool = False,
) -> tuple[bool, str, list[int]]:
"""Gracefully terminate any running Artemis server on `port`.
Args:
port: TCP port to inspect and terminate.
timeout: Maximum seconds to wait for graceful process exit.
force: If True, immediately send SIGKILL without waiting.
timeout: Maximum seconds to wait for the server shutdown lifecycle.
force: If True, skip the shutdown API and terminate the process tree.
Returns:
tuple (success, message, affected_pids)
@@ -259,7 +352,7 @@ def stop_server(
pids = find_server_pids(port)
if not pids and not is_port_in_use(port):
clear_server_info()
clear_server_info(port=port)
DeviceExecutionLock.cleanup_stale_locks()
return True, f"No active server detected on port {port}.", []
@@ -271,15 +364,46 @@ def stop_server(
stopped_pids: list[int] = []
current_pid = os.getpid()
# A normal stop is an application lifecycle request, not an OS signal.
# This matters on Windows where taskkill /F and os.kill(..., SIGTERM) both
# bypass FastAPI's shutdown hook. Give Uvicorn enough time to cancel task
# workers, persist terminal session state, and release IPC/device resources.
graceful_requested = False
if not force:
graceful_requested = request_graceful_shutdown(
port,
timeout=min(2.0, max(0.25, timeout)),
)
if graceful_requested:
deadline = time.time() + max(0.25, timeout)
while time.time() < deadline:
if not is_port_in_use(port) and not _any_pid_alive(pids):
break
time.sleep(0.1)
if not is_port_in_use(port) and not _any_pid_alive(pids):
cleaned_locks = DeviceExecutionLock.cleanup_stale_locks()
clear_server_info(port=port)
pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else ""
msg = f"Artemis server stopped gracefully{pid_str}. Port {port} is released."
if cleaned_locks:
msg += f" Cleaned up {cleaned_locks} device lock(s)."
return True, msg, pids
logger.warning(
f"Artemis server on port {port} did not exit within {timeout}s; "
"falling back to process-tree termination."
)
for pid in pids:
if pid == current_pid:
# Don't terminate self during stop_server call
continue
try:
if force:
ProcessSupervisor.terminate_tree(pid, timeout_seconds=0.5)
else:
ProcessSupervisor.terminate_tree(pid, timeout_seconds=timeout)
ProcessSupervisor.terminate_tree(
pid,
timeout_seconds=0.5 if force or graceful_requested else timeout,
)
stopped_pids.append(pid)
except Exception as e:
logger.warning(f"Failed to terminate process {pid}: {e}")
@@ -304,7 +428,8 @@ def stop_server(
# Cleanup stale device execution locks left by terminated server
cleaned_locks = DeviceExecutionLock.cleanup_stale_locks()
clear_server_info()
reconciled_sessions = _reconcile_orphaned_sessions()
clear_server_info(port=port)
port_free = not is_port_in_use(port)
if port_free:
@@ -312,6 +437,8 @@ def stop_server(
msg = f"Artemis server stopped{pid_str}. Port {port} is released."
if cleaned_locks:
msg += f" Cleaned up {cleaned_locks} device lock(s)."
if reconciled_sessions:
msg += f" Reconciled {reconciled_sessions} interrupted session(s)."
return True, msg, stopped_pids
else:
return (