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

+5 -1
View File
@@ -17,7 +17,8 @@
__version__ = "0.2.0"
from artemis.interfaces.sdk.client import ArtemisClient, ConcurrencyMode
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task, TaskResult
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task
from artemis_client import Capabilities, Device, TaskHandle, TaskResult
from artemis.sdk.agent import Agent
from artemis.sdk.builders import Builders
from artemis.agents.base import BaseAgent, AgentRegistry
@@ -27,12 +28,15 @@ from artemis.tools.base import artemis_tool, ToolRegistry
__all__ = [
"__version__",
"ArtemisClient",
"Capabilities",
"ConcurrencyMode",
"Device",
"StreamEvent",
"StreamEventType",
"Agent",
"Builders",
"Task",
"TaskHandle",
"TaskResult",
"BaseAgent",
"AgentRegistry",
@@ -0,0 +1,330 @@
# 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.
"""Process-wide ADB server endpoint discovery and activation."""
from __future__ import annotations
import asyncio
import os
from pathlib import Path
import re
import subprocess
from typing import Any, Callable
from dotenv import set_key
from artemis.config import settings
from artemis.config.paths import ROOT_DIR, get_app_dir
from artemis.runtime.adb_endpoint import (
AdbEndpoint,
AdbSession,
InvalidAdbEndpoint,
current_adb_endpoint,
)
from artemis.toolchain import toolchain
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
AdbServerEndpoint = AdbEndpoint
InvalidAdbServerEndpoint = InvalidAdbEndpoint
class AdbServerConnectionManager:
"""Validate, probe, activate, and persist the selected ADB server endpoint."""
def __init__(
self,
*,
adb_resolver: Callable[[], str | None] | None = None,
env_files: list[Path] | None = None,
) -> None:
self._adb_resolver = adb_resolver or (lambda: toolchain.resolve("adb"))
self._env_files = env_files
self._activation_lock = asyncio.Lock()
@staticmethod
def validate_endpoint(host: str, port: int) -> AdbServerEndpoint:
return AdbEndpoint.create(host, port)
def current_endpoint(self) -> AdbServerEndpoint:
return current_adb_endpoint()
def status(self) -> dict[str, Any]:
return {"endpoint": self.current_endpoint().to_dict()}
def synchronize_environment(self) -> AdbServerEndpoint:
"""Align subprocess and Python ADB clients with the configured endpoint."""
socket_endpoint = self._endpoint_from_socket(os.environ.get("ADB_SERVER_SOCKET"))
has_explicit_host = bool(os.environ.get("ADB_HOST") or os.environ.get("ADB_PORT"))
endpoint = (
socket_endpoint
if socket_endpoint is not None and not has_explicit_host
else self.current_endpoint()
)
self._activate(endpoint)
return endpoint
async def connect(
self,
host: str,
port: int,
*,
persist: bool = True,
) -> dict[str, Any]:
"""Probe an endpoint, then make it the process-wide ADB server on success."""
endpoint = self.validate_endpoint(host, port)
probe_result = await self.probe(host, port)
if not probe_result["success"]:
return probe_result
persisted = False
persistence_error: str | None = None
async with self._activation_lock:
self._activate(endpoint)
if persist:
try:
await asyncio.to_thread(self._persist, endpoint)
persisted = True
except Exception as exc:
persistence_error = str(exc)
logger.warning(f"Unable to persist ADB server endpoint: {exc}")
message = probe_result["message"]
if persist and not persisted:
message = (
f"{message} The connection is active for this session, but Artemis could not "
"save it for the next launch."
)
return {
**probe_result,
"endpoint": endpoint.to_dict(),
"message": message,
"persisted": persisted,
"persistence_error": persistence_error,
}
async def probe(self, host: str, port: int) -> dict[str, Any]:
"""Test an endpoint without changing the process preference."""
endpoint = self.validate_endpoint(host, port)
return await asyncio.to_thread(self._probe_sync, endpoint)
async def use_local_server(self, *, persist: bool = True) -> dict[str, Any]:
"""Restore the standard local ADB server without killing any remote daemon."""
endpoint = AdbEndpoint.local()
persisted = False
persistence_error: str | None = None
async with self._activation_lock:
self._activate(endpoint)
if persist:
try:
await asyncio.to_thread(self._persist, endpoint)
persisted = True
except Exception as exc:
persistence_error = str(exc)
logger.warning(f"Unable to persist local ADB server endpoint: {exc}")
message = "Artemis is now using the local ADB server."
if persist and not persisted:
message += " The setting could not be saved for the next launch."
return {
"success": True,
"endpoint": endpoint.to_dict(),
"persisted": persisted,
"persistence_error": persistence_error,
"message": message,
}
def _probe_sync(self, endpoint: AdbServerEndpoint) -> dict[str, Any]:
adb_path = self._adb_resolver()
if not adb_path:
return {
"success": False,
"error_code": "adb_not_found",
"message": "Android Platform Tools (adb) is not installed or not on PATH.",
"endpoint": endpoint.to_dict(),
"devices": [],
}
session = AdbSession(endpoint, adb_path=adb_path)
command = session.command(["devices", "-l"])
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=8,
env=self._probe_environment(),
)
except subprocess.TimeoutExpired:
return {
"success": False,
"error_code": "timeout",
"message": (
"The ADB server did not respond. Check that the host and port are correct "
"and accessible from this computer."
),
"endpoint": endpoint.to_dict(),
"devices": [],
}
except OSError as exc:
logger.warning(f"Failed to execute ADB endpoint probe: {exc}")
return {
"success": False,
"error_code": "adb_execution_failed",
"message": f"Unable to run adb: {exc}",
"endpoint": endpoint.to_dict(),
"devices": [],
}
stdout = result.stdout or ""
stderr = result.stderr or ""
output = "\n".join(part.strip() for part in (stdout, stderr) if part.strip())
devices = self._parse_devices(stdout)
if result.returncode != 0:
return {
"success": False,
"error_code": "server_unreachable",
"message": self._connection_error_message(output),
"output": output,
"endpoint": endpoint.to_dict(),
"devices": [],
}
ready_devices = [device for device in devices if device["state"] == "device"]
if ready_devices:
message = (
f"Connected to {endpoint.host}:{endpoint.port} and found "
f"{len(ready_devices)} ready device{'s' if len(ready_devices) != 1 else ''}."
)
elif devices:
message = (
"The ADB server is reachable, but its devices require attention. "
"Unlock the phone and approve USB debugging if prompted."
)
else:
message = (
"The ADB server is reachable, but no devices are attached to the remote computer."
)
return {
"success": True,
"message": message,
"output": output,
"endpoint": endpoint.to_dict(),
"devices": devices,
}
@staticmethod
def _parse_devices(output: str) -> list[dict[str, str | None]]:
devices: list[dict[str, str | None]] = []
for raw_line in output.splitlines():
line = raw_line.strip()
if not line or line.lower().startswith("list of devices") or line.startswith("*"):
continue
parts = line.split()
if len(parts) < 2:
continue
metadata = {
key: value.replace("_", " ")
for token in parts[2:]
if ":" in token
for key, value in [token.split(":", 1)]
}
devices.append(
{
"serial": parts[0],
"state": parts[1],
"model": metadata.get("model"),
"product": metadata.get("product"),
}
)
return devices
@staticmethod
def _connection_error_message(output: str) -> str:
lowered = output.lower()
if "cannot connect" in lowered or "failed to connect" in lowered:
return (
"No ADB server is listening at this address. Verify that the host and port are "
"correct and accessible from this computer."
)
if "version" in lowered and "mismatch" in lowered:
return (
"The local and remote ADB versions are incompatible. Use matching Android "
"Platform Tools versions on both computers."
)
return output or "Unable to reach the ADB server endpoint."
@staticmethod
def _probe_environment() -> dict[str, str]:
environment = os.environ.copy()
environment.pop("ADB_SERVER_SOCKET", None)
return environment
@classmethod
def _endpoint_from_socket(cls, value: str | None) -> AdbServerEndpoint | None:
if not value or not value.startswith("tcp:"):
return None
address = value.removeprefix("tcp:")
if address.startswith("["):
match = re.fullmatch(r"\[([^]]+)]:(\d+)", address)
else:
match = re.fullmatch(r"(.+):(\d+)", address)
if match is None:
return None
try:
return cls.validate_endpoint(match.group(1), int(match.group(2)))
except (InvalidAdbServerEndpoint, ValueError):
return None
@staticmethod
def _activate(endpoint: AdbServerEndpoint) -> None:
settings.ADB_HOST = endpoint.host
settings.ADB_PORT = endpoint.port
endpoint.apply_to_environment()
logger.info(
f"Activated {endpoint.mode} ADB server endpoint {endpoint.host}:{endpoint.port}"
)
def _persist(self, endpoint: AdbServerEndpoint) -> None:
for env_file in self._environment_files():
env_file.parent.mkdir(parents=True, exist_ok=True)
if not env_file.exists():
env_file.touch()
set_key(str(env_file), "ADB_HOST", endpoint.host, quote_mode="never")
set_key(str(env_file), "ADB_PORT", str(endpoint.port), quote_mode="never")
def _environment_files(self) -> list[Path]:
if self._env_files is not None:
candidates = self._env_files
else:
candidates = [ROOT_DIR / ".env", get_app_dir() / ".env"]
unique_files: list[Path] = []
seen: set[Path] = set()
for candidate in candidates:
resolved = candidate.resolve()
if resolved not in seen:
seen.add(resolved)
unique_files.append(candidate)
return unique_files
adb_server_connection = AdbServerConnectionManager()
adb_server_connection.synchronize_environment()
+36 -7
View File
@@ -15,11 +15,11 @@
"""System Readiness & Diagnostic Orchestration Engine."""
import asyncio
import shutil
import subprocess
import time
from typing import Any
from artemis.core.diagnostics.adb_server_connection import adb_server_connection
from artemis.core.diagnostics.probes.adb_probe import AdbDeviceProbe
from artemis.core.diagnostics.probes.base import BaseProbe
from artemis.core.diagnostics.probes.credentials_probe import (
@@ -38,8 +38,8 @@ from artemis.core.diagnostics.schema import (
ProbeStatus,
SystemReadinessReport,
)
from artemis.platform import platform
from artemis.toolchain import toolchain
from artemis.platform import platform
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -106,9 +106,7 @@ class ReadinessEngine:
return None
return await probe.probe()
async def run_device_submission_probe(
self, target_serial: str | None = None
) -> ProbeResult:
async def run_device_submission_probe(self, target_serial: str | None = None) -> ProbeResult:
"""Run the bounded device gate used by task submission."""
return await self._adb_probe.probe_submission_readiness(target_serial=target_serial)
@@ -226,7 +224,19 @@ class ReadinessEngine:
"""Execute adb kill-server && adb start-server to recover connectivity, auto-healing corrupted keys if needed."""
from artemis.core.diagnostics.adb_keys import heal_adb_keys, inspect_adb_keys
adb_path = shutil.which("adb") or "adb"
endpoint = adb_server_connection.current_endpoint()
if not endpoint.is_local_default:
return {
"success": True,
"skipped": True,
"message": (
"Remote ADB is active. Artemis refreshed device discovery without stopping "
"the active ADB server endpoint."
),
"endpoint": endpoint.to_dict(),
}
adb_path = toolchain.resolve("adb") or "adb"
def _restart_sync():
# If keys are corrupted, heal them first
@@ -269,6 +279,20 @@ class ReadinessEngine:
async def launch_emulator(self, avd_name: str) -> dict[str, Any]:
"""Launch an Android emulator by AVD name in the background and track its lifecycle."""
if not adb_server_connection.current_endpoint().is_local_default:
return {
"avd_name": avd_name,
"status": "failed",
"pid": None,
"serial": None,
"error": "Switch to local ADB before launching a local emulator.",
"stage_message": "Local ADB is not active",
"progress_percent": 0,
"started_at": None,
"elapsed_seconds": 0,
"logs": [],
"can_retry": True,
}
from artemis.core.diagnostics.emulator_manager import emulator_manager
state = await emulator_manager.launch(avd_name)
@@ -294,11 +318,16 @@ class ReadinessEngine:
async def connect_wireless_adb(self, host: str, port: int = 5555) -> dict[str, Any]:
"""Connect to an Android device over Wi-Fi via adb connect."""
if not adb_server_connection.current_endpoint().is_local_default:
return {
"success": False,
"message": "Switch to local ADB before connecting a Wireless ADB device.",
}
clean_host = host.strip()
if not clean_host:
return {"success": False, "message": "Host IP address cannot be empty"}
target = f"{clean_host}:{port}"
adb_path = shutil.which("adb") or "adb"
adb_path = toolchain.resolve("adb") or "adb"
def _connect_sync():
try:
+7 -2
View File
@@ -351,8 +351,13 @@ class AdbDeviceProbe(BaseProbe):
return states
except TimeoutError:
if proc is not None:
proc.kill()
await proc.communicate()
# The process may have exited between the timeout and the
# kill (frequent under concurrent submissions on Windows).
try:
proc.kill()
await proc.communicate()
except (ProcessLookupError, OSError):
pass
return None
except Exception as exc:
logger.debug(f"Failed submission-time ADB device check: {exc}")
+6
View File
@@ -302,6 +302,12 @@ class DataEngine:
if not self.current_session_id:
return
# Session-level terminal statuses are canonically "completed" /
# "failed" / "cancelled"; "success" is a legacy alias some callers
# still pass and must never reach the sessions table.
if status == "success":
status = "completed"
# Clear pause file if it exists
pause_file = PAUSE_FILE
if pause_file.exists():
+1
View File
@@ -16,6 +16,7 @@
import asyncio
import json
import os
from pathlib import Path
from typing import Annotated
+3 -3
View File
@@ -367,9 +367,9 @@ def run_command(
def on_status(sess_info):
st = sess_info.get("status")
if st == "queued":
console.print(f"[yellow]⏳ Task queued in scheduler, waiting for device...[/yellow]")
console.print("[yellow]⏳ Task queued in scheduler, waiting for device...[/yellow]")
elif st == "running":
console.print(f"[green]▶ Task executing on mobile device...[/green]")
console.print("[green]▶ Task executing on mobile device...[/green]")
try:
final_res = wait_for_daemon_task(
@@ -380,7 +380,7 @@ def run_command(
)
final_st = final_res.get("status")
if final_st in ("completed", "success"):
console.print(f"\n[bold green]✅ Task completed successfully![/bold green]")
console.print("\n[bold green]✅ Task completed successfully![/bold green]")
return
else:
err = final_res.get("error") or final_res.get("explanation") or ""
@@ -17,6 +17,7 @@
from __future__ import annotations
import datetime
import time
from typing import Annotated
import webbrowser
@@ -25,8 +26,8 @@ from rich.panel import Panel
from rich.table import Table
import typer
from artemis.interfaces.cli.commands.ui import ui_command
from artemis.runtime.daemon_client import spawn_daemon
from artemis.interfaces.cli.commands.ui import ensure_showcase_built, ui_command
from artemis.runtime.daemon_client import daemon_log_path, is_daemon_running, spawn_daemon
from artemis.runtime.server_lifecycle import (
find_server_pids,
get_server_status,
@@ -55,8 +56,15 @@ def _format_uptime(seconds: float | None) -> str:
def restart_command(
host: Annotated[
str,
typer.Option("--host", "-H", help="Bind host address for the UI server."),
] = "0.0.0.0",
typer.Option(
"--host",
"-H",
help=(
"Bind host address for the UI server. Defaults to loopback; for remote "
"access prefer a Tailscale/SSH tunnel over a wide bind."
),
),
] = "127.0.0.1",
port: Annotated[
int,
typer.Option("--port", "-p", help="Port to run the unified UI server on."),
@@ -86,14 +94,23 @@ def restart_command(
daemon: Annotated[
bool,
typer.Option(
"--daemon",
"-d",
help="Run the new server as a detached background process instead of foreground.",
"--daemon/--foreground",
"-d/-F",
help=(
"Run the new server as a detached background daemon (default) or attached "
"to the current terminal. The daemon is launched via 'python -m' so the "
"console-script executable is never pinned by a long-lived server "
"(which would block 'uv sync' reinstalls on Windows)."
),
),
] = False,
] = True,
) -> None:
"""Restart the Artemis Web UI server, terminating any existing instance on the port."""
console.print()
if reload and daemon:
# uvicorn auto-reload requires an attached terminal session.
console.print(" [dim]ℹ --reload requested: switching to foreground mode.[/dim]")
daemon = False
console.print(
Panel(
f"[bold cyan]🔄 Artemis Server Restart[/bold cyan]\n"
@@ -111,7 +128,7 @@ def restart_command(
else:
console.print(f" [dim]ℹ No active Artemis server found on port {port}.[/dim]")
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=4.0)
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=12.0)
if stopped_pids:
console.print(f" [green]✓ {msg}[/green]\n")
elif not success:
@@ -122,23 +139,54 @@ def restart_command(
# 2. Launch fresh server instance
local_url = f"http://localhost:{port}"
if daemon:
console.print(f" [cyan]🚀 Starting Artemis in detached background daemon mode...[/cyan]")
ensure_showcase_built(console)
console.print(" [cyan]🚀 Starting Artemis in detached background daemon mode...[/cyan]")
proc = spawn_daemon(host=host, port=port)
if proc:
if not proc:
console.print(" [red]❌ Failed to spawn background daemon.[/red]\n")
raise typer.Exit(code=1)
# Wait until the daemon answers HTTP before declaring success (cold
# start includes DB/trace initialization and can take tens of seconds).
ready = False
deadline = time.monotonic() + 60.0
while time.monotonic() < deadline:
if is_daemon_running(host="127.0.0.1", port=port, timeout=0.5):
ready = True
break
if proc.poll() is not None:
# Parent exited: either it crashed, or it handed the socket off
# to a child worker. Give the worker a short grace period.
grace = time.monotonic() + 5.0
while time.monotonic() < grace:
if is_daemon_running(host="127.0.0.1", port=port, timeout=0.5):
ready = True
break
time.sleep(0.3)
break
time.sleep(0.3)
log_path = daemon_log_path(port)
if ready:
console.print(
f" [bold green]✓ Artemis server running in background (PID: {proc.pid})[/bold green]\n"
f" 🌐 [cyan]{local_url}[/cyan]\n"
f" 🛠️ [cyan]{local_url}/admin[/cyan]\n"
f" 📄 [dim]Logs: {log_path}[/dim]\n"
f" [dim]💡 Stop with [bold]artemis stop[/bold]; run in-terminal with [bold]artemis restart --foreground[/bold].[/dim]\n"
)
if open_browser:
webbrowser.open(local_url)
else:
console.print(" [red]❌ Failed to spawn background daemon.[/red]\n")
console.print(
f" [red]❌ Background daemon did not become ready on port {port}.[/red]\n"
f" 📄 [dim]Check logs: {log_path}[/dim]\n"
)
raise typer.Exit(code=1)
return
# Default foreground mode
console.print(f" [cyan]🚀 Launching unified Artemis Showcase UI in current terminal...[/cyan]\n")
# Foreground mode (--foreground / --reload)
console.print(" [cyan]🚀 Launching unified Artemis Showcase UI in current terminal...[/cyan]\n")
ui_command(host=host, port=port, open_browser=open_browser, reload=reload)
@@ -165,7 +213,7 @@ def stop_command(
else:
console.print(f" [dim]Scanning port {port}...[/dim]")
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=4.0)
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=12.0)
if stopped_pids:
console.print(
Panel(
+59 -19
View File
@@ -29,6 +29,33 @@ from rich.panel import Panel
import typer
def running_via_console_script_shim() -> bool:
"""Return True when this CLI was launched through a console-script ``.exe`` shim.
On Windows, ``uv``/pip install entry points as trampoline executables
(``Scripts/artemis.exe``) that stay resident for the lifetime of the CLI
process and hold a lock on their own file. A long-running foreground server
started through the shim therefore blocks ``uv sync`` from reinstalling the
package ("os error 32") until the server exits. Launching via
``python -m artemis`` avoids the shim entirely.
"""
if sys.platform != "win32":
return False
argv0 = Path(sys.argv[0] or "")
return argv0.suffix.lower() == ".exe" and argv0.name.lower() != "python.exe"
def warn_if_console_script_shim(console: Console) -> None:
"""Print a hint when a foreground server run will pin the console-script exe."""
if running_via_console_script_shim():
console.print(
" [dim]⚠ Running in foreground via the [bold]artemis.exe[/bold] launcher: package "
"syncs (uv sync / uv run) cannot replace it while this server is running.\n"
" Prefer [bold]artemis restart[/bold] (background daemon) or launch with "
"[bold]uv run python -m artemis ui[/bold] to avoid this.[/dim]\n"
)
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
@@ -57,6 +84,25 @@ def _showcase_build_required(showcase_dir: Path) -> bool:
return any(path.exists() and path.stat().st_mtime > build_time for path in input_paths)
def ensure_showcase_built(console: Console) -> None:
"""Rebuild the Angular Showcase UI when its sources are newer than the dist build."""
from artemis.config.paths import ROOT_DIR
showcase_dir = ROOT_DIR / "apps" / "showcase_ui"
if not _showcase_build_required(showcase_dir):
return
npm_executable = _resolve_npm_executable()
if not npm_executable:
return
console.print(" [yellow]🎨 Showcase UI sources changed. Compiling Angular Showcase UI...[/yellow]")
try:
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")
def _poll_and_open_browser(url: str, stop_event: threading.Event, timeout: float = 15.0) -> None:
"""Poll the UI server until it responds, then launch the user's default browser."""
start = time.time()
@@ -75,8 +121,15 @@ def _poll_and_open_browser(url: str, stop_event: threading.Event, timeout: float
def ui_command(
host: Annotated[
str,
typer.Option("--host", "-h", help="Bind host address for the UI server."),
] = "0.0.0.0",
typer.Option(
"--host",
"-h",
help=(
"Bind host address for the UI server. Defaults to loopback; for remote "
"access prefer a Tailscale/SSH tunnel over a wide bind."
),
),
] = "127.0.0.1",
port: Annotated[
int,
typer.Option("--port", "-p", help="Port to run the unified UI server on."),
@@ -116,7 +169,7 @@ def ui_command(
if restart:
console.print(f" [yellow]🔄 Restart requested. Recycling port {port}...[/yellow]")
stop_server(port=port, timeout=4.0)
stop_server(port=port, timeout=12.0)
elif is_port_in_use(port):
pids = find_server_pids(port)
pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else ""
@@ -132,7 +185,7 @@ def ui_command(
choice = "1"
if choice == "2":
console.print(f"\n [yellow]🔄 Terminating previous Artemis instance on port {port}...[/yellow]")
stop_server(port=port, timeout=4.0)
stop_server(port=port, timeout=12.0)
elif choice == "3":
return
else:
@@ -178,21 +231,8 @@ def ui_command(
except Exception:
pass
showcase_dir = ROOT_DIR / "apps" / "showcase_ui"
if _showcase_build_required(showcase_dir):
npm_executable = _resolve_npm_executable()
if npm_executable:
console.print(
" [yellow]🎨 Showcase UI sources changed. Compiling Angular Showcase UI...[/yellow]"
)
try:
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")
ensure_showcase_built(console)
warn_if_console_script_shim(console)
stop_event = threading.Event()
if open_browser:
+5 -1
View File
@@ -15,13 +15,17 @@
"""Python SDK for ARTEMIS."""
from artemis.interfaces.sdk.client import ArtemisClient, ConcurrencyMode
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task, TaskResult
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task
from artemis_client import Capabilities, Device, TaskHandle, TaskResult
__all__ = [
"ArtemisClient",
"Capabilities",
"ConcurrencyMode",
"Device",
"StreamEvent",
"StreamEventType",
"Task",
"TaskHandle",
"TaskResult",
]
+8 -433
View File
@@ -5,440 +5,15 @@
# 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.
import asyncio
import os
import uuid
from typing import Any, Literal
from collections.abc import AsyncGenerator, Callable
from artemis.engine.pipeline import Pipeline
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task, TaskResult
from artemis.runtime import (
ConcurrencyMode,
DeviceBusyError,
DeviceExecutionLock,
DeviceStatus,
device_pool,
ensure_daemon_running,
submit_task_to_daemon,
wait_for_daemon_task,
)
from artemis.utils.logger import get_logger
"""Compatibility import for the standalone remote Artemis client.
logger = get_logger(__name__)
The implementation lives in the zero-dependency ``artemis-client`` package.
Keeping this module lets existing full-runtime installations migrate without
maintaining a second client execution path.
"""
from artemis.runtime import ConcurrencyMode
from artemis_client import ArtemisClient
class ArtemisClient:
"""The official high-level developer entrypoint for interacting with ARTEMIS."""
def __init__(
self,
device_id: str | None = None,
device_serial: str | None = None,
default_profile: Literal["flash", "pro"] = "pro",
concurrency_mode: Literal["global", "per_device"] | str = "per_device",
max_concurrency: int | None = None,
standalone: bool = False,
):
self.standalone = standalone or os.environ.get("ARTEMIS_STANDALONE", "").lower() in ("1", "true", "yes")
if not self.standalone:
try:
ensure_daemon_running(timeout=1.5)
except Exception:
pass
target_dev = device_serial or device_id
if target_dev is None or target_dev == "default-device":
self._device_id = device_pool.select_device() or "default-device"
else:
self._device_id = target_dev
self.default_profile = default_profile
self._concurrency_mode = str(concurrency_mode).strip().lower()
self._max_concurrency = max_concurrency
@property
def device_id(self) -> str:
return self._device_id
@device_id.setter
def device_id(self, val: str) -> None:
self._device_id = val
@property
def device_serial(self) -> str:
"""Alias for device_id."""
return self._device_id
@device_serial.setter
def device_serial(self, val: str) -> None:
self._device_id = val
def set_device(self, device_serial: str) -> "ArtemisClient":
"""Set target device serial and return client for chaining."""
self._device_id = device_serial
return self
@property
def concurrency_mode(self) -> str:
"""Concurrency mode: 'global' (1 across all devices) or 'per_device' (1 per device)."""
return self._concurrency_mode
@concurrency_mode.setter
def concurrency_mode(self, mode: Literal["global", "per_device"] | str) -> None:
self._concurrency_mode = str(mode).strip().lower()
@property
def max_concurrency(self) -> int | None:
return self._max_concurrency
@max_concurrency.setter
def max_concurrency(self, val: int | None) -> None:
self._max_concurrency = val
def set_concurrency_mode(self, mode: Literal["global", "per_device"] | str) -> "ArtemisClient":
"""Set concurrency mode ('global' or 'per_device') and return client for chaining."""
self.concurrency_mode = mode
return self
def list_devices(self) -> list[DeviceStatus]:
"""List all connected Android devices with their lock state and availability."""
return device_pool.list_devices()
async def list_devices_async(self) -> list[DeviceStatus]:
"""Asynchronously list all connected Android devices with their lock state."""
return await device_pool.list_devices_async()
def get_idle_devices(self) -> list[DeviceStatus]:
"""Return connected devices currently ready and idle (not holding a task lock)."""
return device_pool.get_idle_devices()
async def run(
self,
goal: str,
profile: Literal["flash", "pro"] | None = None,
device_id: str | None = None,
device_serial: str | None = None,
concurrency_mode: Literal["global", "per_device"] | str | None = None,
max_concurrency: int | None = None,
blocking: bool = True,
lock_timeout: float | None = None,
max_turns: int = 30,
**kwargs: Any,
) -> TaskResult:
"""Executes a single autonomous task on a target device and returns the final TaskResult."""
target_profile = profile or self.default_profile
target_device = device_serial or device_id or self.device_id
if target_device == "default-device":
target_device = device_pool.select_device() or "default-device"
mode = concurrency_mode or self.concurrency_mode
concurrency_limit = max_concurrency if max_concurrency is not None else self.max_concurrency
logger.info(
f"ArtemisClient running task '{goal}' on device '{target_device}' "
f"with profile '{target_profile}' (concurrency_mode='{mode}')..."
)
task_session_id = str(uuid.uuid4())
prev_ingress = os.environ.get("ARTEMIS_TASK_INGRESS")
prev_sess_id = os.environ.get("ARTEMIS_SESSION_ID")
lock = DeviceExecutionLock(
device_id=target_device,
description=f"{goal[:120]}",
concurrency_mode=mode,
max_concurrency=concurrency_limit,
session_id=task_session_id,
ingress="sdk",
)
try:
os.environ["ARTEMIS_TASK_INGRESS"] = "sdk"
os.environ["ARTEMIS_SESSION_ID"] = task_session_id
await asyncio.to_thread(
lock.acquire,
blocking=blocking,
timeout=lock_timeout,
)
# Check if targeting a live connected device
devices = device_pool.list_devices()
connected_serials = {d.serial for d in devices}
is_live_device = target_device in connected_serials and os.environ.get("ARTEMIS_MOCK_DRIVER") != "1"
if is_live_device:
# Route through unified Artemis Daemon scheduler unless standalone explicitly configured
if not self.standalone and os.environ.get("ARTEMIS_STANDALONE") != "1":
try:
is_running, base_url = ensure_daemon_running(timeout=8.0, wait_ready=True)
if is_running and base_url:
dev_serial = target_device if target_device != "default-device" else None
resp = submit_task_to_daemon(
goal=goal,
profile=target_profile,
device_serial=dev_serial,
expected_output=kwargs.get("expected_output"),
enable_outputter=kwargs.get("enable_outputter"),
locked_app_package=kwargs.get("locked_package") or kwargs.get("locked_app_package"),
app_path=kwargs.get("app_path"),
session_id=task_session_id,
ingress="sdk",
base_url=base_url,
)
if resp and resp.get("tasks"):
final_res = wait_for_daemon_task(
task_session_id,
base_url=base_url,
timeout=float(kwargs.get("timeout", 1800.0)),
)
final_st = final_res.get("status", "completed")
err = final_res.get("error")
return TaskResult(
trace_id=task_session_id,
status="completed" if final_st in ("completed", "success") else final_st,
turns=final_res.get("turns", 1),
error=err,
device_id=target_device,
)
except Exception as exc:
logger.debug(f"SDK daemon routing notice: {exc}")
from artemis.sdk.agent import Agent
from artemis.sdk.builders import Builders
from artemis.context import DevicePlatform
cfg_builder = (
Builders.AgentConfig
.for_device(DevicePlatform.ANDROID, target_device)
.with_concurrency_mode(mode)
)
if concurrency_limit is not None:
cfg_builder.with_max_concurrency(concurrency_limit)
agent = Agent(config=cfg_builder.build())
agent._session_id = task_session_id
await agent.init()
try:
task = agent.new_task(goal=goal)
if target_profile:
task.using_profile(target_profile)
if kwargs.get("locked_package"):
task.with_locked_app_package(kwargs["locked_package"])
output = await agent.run_task(request=task.build())
return TaskResult(
trace_id=task_session_id,
status="completed" if output is not None else "success",
turns=getattr(agent, "_turns_completed", 1),
output=output,
device_id=target_device,
)
finally:
await agent.clean()
state = await Pipeline.execute(
goal=goal,
profile=target_profile,
device_id=target_device,
**kwargs,
)
return TaskResult(
trace_id=state.trace_id,
status=state.status.value,
turns=state.current_turn,
error=state.error_message,
device_id=target_device,
)
finally:
if prev_sess_id is not None:
os.environ["ARTEMIS_SESSION_ID"] = prev_sess_id
else:
os.environ.pop("ARTEMIS_SESSION_ID", None)
if prev_ingress is not None:
os.environ["ARTEMIS_TASK_INGRESS"] = prev_ingress
else:
os.environ.pop("ARTEMIS_TASK_INGRESS", None)
if lock._acquired:
await asyncio.to_thread(lock.release)
async def stream_run(
self,
goal: str,
profile: Literal["flash", "pro"] | None = None,
device_id: str | None = None,
device_serial: str | None = None,
concurrency_mode: Literal["global", "per_device"] | str | None = None,
max_concurrency: int | None = None,
blocking: bool = True,
lock_timeout: float | None = None,
max_turns: int = 30,
callbacks: list[Callable[[StreamEvent], Any]] | None = None,
**kwargs: Any,
) -> AsyncGenerator[StreamEvent, None]:
"""Executes an autonomous task while yielding real-time StreamEvents asynchronously."""
target_profile = profile or self.default_profile
target_device = device_serial or device_id or self.device_id
if target_device == "default-device":
target_device = device_pool.select_device() or "default-device"
mode = concurrency_mode or self.concurrency_mode
concurrency_limit = max_concurrency if max_concurrency is not None else self.max_concurrency
logger.info(
f"ArtemisClient streaming task '{goal}' on device '{target_device}' "
f"with profile '{target_profile}' (concurrency_mode='{mode}')..."
)
task_session_id = str(uuid.uuid4())
prev_ingress = os.environ.get("ARTEMIS_TASK_INGRESS")
prev_sess_id = os.environ.get("ARTEMIS_SESSION_ID")
lock = DeviceExecutionLock(
device_id=target_device,
description=f"ArtemisClient streaming task: {goal[:120]}",
concurrency_mode=mode,
max_concurrency=concurrency_limit,
session_id=task_session_id,
ingress="sdk",
)
try:
os.environ["ARTEMIS_TASK_INGRESS"] = "sdk"
os.environ["ARTEMIS_SESSION_ID"] = task_session_id
await asyncio.to_thread(
lock.acquire,
blocking=blocking,
timeout=lock_timeout,
)
# 1. Emit initial starting event
start_event = StreamEvent(
event_type=StreamEventType.STATUS,
step_number=0,
payload={
"status": "starting",
"goal": goal,
"profile": target_profile,
"device_id": target_device,
"concurrency_mode": mode,
},
)
if callbacks:
for cb in callbacks:
try:
cb(start_event)
except Exception as e:
logger.debug(f"Callback error: {e}")
yield start_event
# 2. Execute task pipeline
try:
state = await Pipeline.execute(
goal=goal,
profile=target_profile,
device_id=target_device,
**kwargs,
)
# 3. Emit intermediate step events
for step in state.steps:
ev = StreamEvent(
event_type=StreamEventType.STEP_END,
step_number=step.step_number,
payload={
"thought": step.thought,
"action": step.action_name,
"params": step.action_params,
"result": step.result,
"duration": step.duration_seconds,
},
)
if callbacks:
for cb in callbacks:
try:
cb(ev)
except Exception as e:
logger.debug(f"Callback error: {e}")
yield ev
# 4. Emit completion event
final_ev = StreamEvent(
event_type=StreamEventType.STATUS,
step_number=state.current_turn,
payload={
"status": state.status.value,
"turns": state.current_turn,
"error": state.error_message,
"device_id": target_device,
},
)
if callbacks:
for cb in callbacks:
try:
cb(final_ev)
except Exception as e:
logger.debug(f"Callback error: {e}")
yield final_ev
except Exception as e:
err_ev = StreamEvent(
event_type=StreamEventType.ERROR,
step_number=0,
payload={"error": str(e), "device_id": target_device},
)
if callbacks:
for cb in callbacks:
try:
cb(err_ev)
except Exception:
pass
yield err_ev
finally:
if prev_sess_id is not None:
os.environ["ARTEMIS_SESSION_ID"] = prev_sess_id
else:
os.environ.pop("ARTEMIS_SESSION_ID", None)
if prev_ingress is not None:
os.environ["ARTEMIS_TASK_INGRESS"] = prev_ingress
else:
os.environ.pop("ARTEMIS_TASK_INGRESS", None)
if lock._acquired:
await asyncio.to_thread(lock.release)
async def run_task(
self,
task: Task,
profile: Literal["flash", "pro"] | None = None,
device_id: str | None = None,
device_serial: str | None = None,
concurrency_mode: Literal["global", "per_device"] | str | None = None,
**kwargs: Any,
) -> TaskResult:
"""Executes a Task instance using ArtemisClient."""
target_device = (
device_serial
or device_id
or task.device_serial
or task.device_id
or self.device_id
)
target_profile = profile or task.profile or self.default_profile
target_mode = concurrency_mode or task.concurrency_mode or self.concurrency_mode
return await self.run(
goal=task.goal,
profile=target_profile,
device_id=target_device,
concurrency_mode=target_mode,
max_turns=task.max_turns,
locked_package=task.locked_package,
**kwargs,
)
__all__ = ["ArtemisClient", "ConcurrencyMode"]
+179
View File
@@ -0,0 +1,179 @@
# 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.
"""Fake chat model for infrastructure testing (ARTEMIS_FAKE_LLM=1).
Completes any task on the first LLM invocation so that queueing, entry
points, device locking, and status propagation can be exercised without
real model calls:
- Flash (report_task_status bound): immediately reports status=completed.
- Pro planner (save_note bound): saves a task plan whose only subgoal is
already checked, so convergence_gate ends the graph after planning.
- Anything else: returns a plain "FAKE_LLM_OK" text message.
ARTEMIS_FAKE_LLM_DELAY_S adds an artificial per-call delay so tasks stay
alive long enough to observe queue/concurrency behavior.
"""
import asyncio
import json
import time
from typing import Any, Iterator, AsyncIterator
import uuid
from langchain_core.callbacks import (
AsyncCallbackManagerForLLMRun,
CallbackManagerForLLMRun,
)
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
FAKE_DONE_TEXT = "FAKE_LLM: task auto-completed for infrastructure testing."
def _tool_name(tool: Any) -> str | None:
name = getattr(tool, "name", None)
if isinstance(name, str):
return name
if isinstance(tool, dict):
if isinstance(tool.get("name"), str):
return tool["name"]
fn = tool.get("function")
if isinstance(fn, dict) and isinstance(fn.get("name"), str):
return fn["name"]
return None
class FakeChatModel(BaseChatModel):
"""Deterministic fake model that finishes every task on the first call."""
bound_tool_names: list[str] = []
delay_s: float = 0.0
@property
def _llm_type(self) -> str:
return "artemis-fake"
def bind_tools(self, tools: Any, **kwargs: Any) -> "FakeChatModel":
names = [n for n in (_tool_name(t) for t in tools) if n]
return FakeChatModel(bound_tool_names=names, delay_s=self.delay_s)
def with_structured_output(self, schema: Any, **kwargs: Any) -> "FakeChatModel":
return self
def _decide(self, messages: list[BaseMessage]) -> AIMessage:
call_id = f"fake-{uuid.uuid4().hex[:12]}"
usage = {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}
if "report_task_status" in self.bound_tool_names:
return AIMessage(
content="Task verified complete on first observation.",
tool_calls=[
{
"name": "report_task_status",
"args": {"status": "completed", "explanation": FAKE_DONE_TEXT},
"id": call_id,
}
],
usage_metadata=usage,
)
if "save_note" in self.bound_tool_names:
already_planned = any(
tc.get("name") == "save_note"
for m in messages
for tc in (getattr(m, "tool_calls", None) or [])
)
if already_planned:
return AIMessage(content="Plan saved. " + FAKE_DONE_TEXT, usage_metadata=usage)
return AIMessage(
content="Saving completed plan.",
tool_calls=[
{
"name": "save_note",
"args": {
"key": "task_plan",
"content": "- [x] " + FAKE_DONE_TEXT,
},
"id": call_id,
}
],
usage_metadata=usage,
)
return AIMessage(content=FAKE_DONE_TEXT, usage_metadata=usage)
def _to_chunk(self, msg: AIMessage) -> ChatGenerationChunk:
tool_call_chunks = [
{
"name": tc["name"],
"args": json.dumps(tc["args"]),
"id": tc["id"],
"index": i,
"type": "tool_call_chunk",
}
for i, tc in enumerate(msg.tool_calls or [])
]
chunk = AIMessageChunk(
content=msg.content,
tool_call_chunks=tool_call_chunks,
usage_metadata=msg.usage_metadata,
)
return ChatGenerationChunk(message=chunk)
def _generate(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: CallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> ChatResult:
if self.delay_s > 0:
time.sleep(self.delay_s)
return ChatResult(generations=[ChatGeneration(message=self._decide(messages))])
async def _agenerate(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: AsyncCallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> ChatResult:
if self.delay_s > 0:
await asyncio.sleep(self.delay_s)
return ChatResult(generations=[ChatGeneration(message=self._decide(messages))])
def _stream(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: CallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> Iterator[ChatGenerationChunk]:
if self.delay_s > 0:
time.sleep(self.delay_s)
yield self._to_chunk(self._decide(messages))
async def _astream(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: AsyncCallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> AsyncIterator[ChatGenerationChunk]:
if self.delay_s > 0:
await asyncio.sleep(self.delay_s)
yield self._to_chunk(self._decide(messages))
+10
View File
@@ -188,6 +188,16 @@ class ModelFactory:
@classmethod
def create_model(cls, endpoint: ModelEndpoint) -> BaseChatModel:
if os.environ.get("ARTEMIS_FAKE_LLM") == "1":
from artemis.llm.fake_model import FakeChatModel
delay = float(os.environ.get("ARTEMIS_FAKE_LLM_DELAY_S", "0") or 0)
logger.warning(
f"ARTEMIS_FAKE_LLM=1 — returning FakeChatModel (delay={delay}s) "
f"instead of {endpoint.provider}/{endpoint.model_name}"
)
return FakeChatModel(delay_s=delay)
provider = ModelProvider.from_string(endpoint.provider)
if provider == ModelProvider.GOOGLE:
+2 -2
View File
@@ -310,7 +310,7 @@ def build_action_server(actuator: Actuator, name: str = "artemis_actions") -> Fa
coro = asyncio.wait_for(coro, timeout=timeout_ms / 1000.0)
b64 = await coro
payload: dict[str, Any] = {"ok": True, "image_b64": b64}
except (TimeoutError, asyncio.TimeoutError):
except TimeoutError:
payload = {"ok": False, "error": f"screenshot timed out after {timeout_ms}ms"}
except Exception as e:
payload = {"ok": False, "error": str(e)}
@@ -331,7 +331,7 @@ def build_action_server(actuator: Actuator, name: str = "artemis_actions") -> Fa
coro = asyncio.wait_for(coro, timeout=timeout_ms / 1000.0)
elements = await coro
payload: dict[str, Any] = {"ok": True, "elements": elements}
except (TimeoutError, asyncio.TimeoutError):
except TimeoutError:
payload = {"ok": False, "error": f"ui hierarchy fetch timed out after {timeout_ms}ms"}
except Exception as e:
payload = {"ok": False, "error": str(e)}
+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 (
+6 -2
View File
@@ -234,6 +234,10 @@ 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."""
if os.environ.get("ARTEMIS_FAKE_LLM") == "1":
logger.info("ARTEMIS_FAKE_LLM=1 — skipping real LLM connection pre-warming.")
publish_startup_progress("model_ready", "Model connection is ready (fake LLM)", session_id=self._session_id)
return
publish_startup_progress("model_warmup", "Warming the model connection", session_id=self._session_id)
logger.info("Starting background pre-warming of Gemini API connection pools...")
try:
@@ -646,7 +650,7 @@ class Agent:
logger.info(f"✅ Automation '{task_name}' is success ✅")
await task.finalize(content=output, state=last_state_snapshot)
if context.data_engine:
context.data_engine.end_session("success")
context.data_engine.end_session("completed")
else:
err = (
f"[{task_name}] FlashRunner failed:"
@@ -714,7 +718,7 @@ class Agent:
logger.info(f"✅ Automation '{task_name}' is success ✅")
await task.finalize(content=output, state=last_state_snapshot)
if context.data_engine:
context.data_engine.end_session("success")
context.data_engine.end_session("completed")
return output
finally: