feat: enable remote SDK and resilient device orchestration
This commit is contained in:
1 parent
3cc711df51
commit
ddfa420945
110 files changed
+14450
-12954
No files matched your search
+5
-1
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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))
|
||||
@@ -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:
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
@@ -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
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user