feat: add multi-device concurrency, server lifecycle CLI, and unified execution stream

- Implement DevicePool and DeviceExecutionLock concurrency modes (per-device FIFO vs global serialization)
- Extend ArtemisClient SDK and CLI with explicit device targeting and concurrency strategy configuration
- Add server lifecycle management (artemis restart, stop, status) with cross-platform PID discovery
- Update MCP server tools to support multi-device dispatch, trace tagging, and per-device execution
- Enhance Showcase UI with step-by-step visual replay, playback speed controls, and device serial badges
- Add comprehensive unit test coverage and edge-case verification scripts
This commit is contained in:
somew1nd committed 2026-08-26 15:53:14 -07:00
1 parent a36a66ce6d
commit c2c90a6378
84 files changed
+5612 -645

No files matched your search

+55 -1
View File
@@ -39,6 +39,8 @@ logger = get_logger(__name__)
async def execute_task(
goal: str,
device_serial: str | None = None,
session_id: str | None = None,
locked_app_package: str | None = None,
test_name: str | None = None,
traces_output_path_str: str | None = None,
@@ -76,6 +78,14 @@ async def execute_task(
explorer_pro_mode: Override Explorer version mode for Pro execution profile.
"""
publish_startup_progress("configuration", "Loading the run configuration")
effective_sid = (
session_id
or os.getenv("ARTEMIS_SESSION_ID")
or os.getenv("ARTEMIS_CLOUD_SESSION_ID")
)
if effective_sid:
os.environ["ARTEMIS_SESSION_ID"] = str(effective_sid)
llm_config = initialize_llm_config()
agent_profile = AgentProfile(name="default", llm_config=llm_config)
config = Builders.AgentConfig.with_default_profile(profile=agent_profile)
@@ -115,13 +125,25 @@ async def execute_task(
if settings.ADB_HOST:
config.with_adb_server(host=settings.ADB_HOST, port=settings.ADB_PORT)
target_serial = device_serial or settings.ADB_DEVICE_SERIAL or os.environ.get("ADB_DEVICE_SERIAL")
if not target_serial:
try:
from artemis.runtime import device_pool
target_serial = device_pool.select_device()
except Exception:
target_serial = None
if target_serial:
from artemis.context import DevicePlatform
config.for_device(DevicePlatform.ANDROID, target_serial)
if graph_config_callbacks:
config.with_graph_config_callbacks(graph_config_callbacks)
agent: Agent | None = None
try:
publish_startup_progress("device_check", "Checking the Android device")
agent = Agent(config=config.build())
agent = Agent(config=config.build(), session_id=effective_sid)
await agent.init(
retry_count=int(os.getenv("ARTEMIS_HEALTH_RETRIES", 5)),
retry_wait_seconds=int(os.getenv("ARTEMIS_HEALTH_DELAY", 2)),
@@ -273,6 +295,28 @@ def run_command(
help="Explorer version mode when running under Pro profile (Operator/Validator).",
),
] = None,
device_serial: Annotated[
str | None,
typer.Option(
"--device-serial",
"-s",
help="Target specific Android device by serial number (e.g. emulator-5554).",
),
] = None,
session_id: Annotated[
str | None,
typer.Option(
"--session-id",
help="Canonical session UUID for trace and stream telemetry.",
),
] = None,
standalone: Annotated[
bool,
typer.Option(
"--standalone",
help="Run in standalone embedded mode without auto-spawning the Artemis Daemon.",
),
] = False,
) -> None:
"""Run an autonomous UI automation task on the connected Android device."""
if with_video_recording_tools:
@@ -280,6 +324,14 @@ def run_command(
console = Console()
# Daemon auto-spawn: By default, ensure Daemon is running in background unless standalone mode requested
if not standalone and os.environ.get("ARTEMIS_STANDALONE") != "1":
try:
from artemis.runtime import ensure_daemon_running
ensure_daemon_running(timeout=1.5)
except Exception as exc:
logger.debug(f"Daemon probe/auto-spawn skipped: {exc}")
adb_client = None
try:
if which("adb"):
@@ -306,6 +358,8 @@ def run_command(
asyncio.run(
execute_task(
goal=goal,
device_serial=device_serial,
session_id=session_id,
locked_app_package=locked_app_package,
test_name=test_name,
traces_output_path_str=traces_path,
@@ -0,0 +1,255 @@
# 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.
"""CLI commands for Artemis server lifecycle management: restart, stop, and status."""
from __future__ import annotations
import datetime
from typing import Annotated
import webbrowser
from rich.console import Console
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.runtime.server_lifecycle import (
find_server_pids,
get_server_status,
stop_server,
)
console = Console()
def _format_uptime(seconds: float | None) -> str:
"""Format seconds into human-friendly duration (e.g. 1h 23m 45s)."""
if seconds is None:
return "Unknown"
sec = int(seconds)
hours, remainder = divmod(sec, 3600)
minutes, remainder_sec = divmod(remainder, 60)
parts = []
if hours > 0:
parts.append(f"{hours}h")
if minutes > 0 or hours > 0:
parts.append(f"{minutes}m")
parts.append(f"{remainder_sec}s")
return " ".join(parts)
def restart_command(
host: Annotated[
str,
typer.Option("--host", "-H", help="Bind host address for the UI server."),
] = "0.0.0.0",
port: Annotated[
int,
typer.Option("--port", "-p", help="Port to run the unified UI server on."),
] = 8000,
open_browser: Annotated[
bool,
typer.Option(
"--open/--no-open",
help="Automatically open the Showcase UI in default web browser.",
),
] = True,
force: Annotated[
bool,
typer.Option(
"--force",
"-f",
help="Force kill previous server immediately without waiting for graceful exit.",
),
] = False,
reload: Annotated[
bool,
typer.Option(
"--reload/--no-reload",
help="Enable uvicorn live auto-reload (for development).",
),
] = False,
daemon: Annotated[
bool,
typer.Option(
"--daemon",
"-d",
help="Run the new server as a detached background process instead of foreground.",
),
] = False,
) -> None:
"""Restart the Artemis Web UI server, terminating any existing instance on the port."""
console.print()
console.print(
Panel(
f"[bold cyan]🔄 Artemis Server Restart[/bold cyan]\n"
f"[dim]Inspecting port {port} and recycling any running Artemis instances...[/dim]",
border_style="cyan",
expand=False,
)
)
# 1. Stop existing server
existing_pids = find_server_pids(port)
if existing_pids:
pid_str = ", ".join(map(str, existing_pids))
console.print(f" [yellow]🛑 Terminating running Artemis instance (PID: {pid_str})...[/yellow]")
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)
if stopped_pids:
console.print(f" [green]✓ {msg}[/green]\n")
elif not success:
console.print(f" [red]⚠ {msg}[/red]\n")
else:
console.print(f" [green]✓ Port {port} is clear and ready.[/green]\n")
# 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]")
proc = spawn_daemon(host=host, port=port)
if proc:
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"
)
if open_browser:
webbrowser.open(local_url)
else:
console.print(" [red]❌ Failed to spawn background daemon.[/red]\n")
raise typer.Exit(code=1)
return
# Default foreground mode
console.print(f" [cyan]🚀 Launching unified Artemis Showcase UI in current terminal...[/cyan]\n")
ui_command(host=host, port=port, open_browser=open_browser, reload=reload)
def stop_command(
port: Annotated[
int,
typer.Option("--port", "-p", help="Port of the Artemis server to stop."),
] = 8000,
force: Annotated[
bool,
typer.Option(
"--force",
"-f",
help="Force kill immediately without waiting for graceful shutdown.",
),
] = False,
) -> None:
"""Stop any running Artemis Web UI / server instance on the given port."""
console.print()
existing_pids = find_server_pids(port)
if existing_pids:
pid_str = ", ".join(map(str, existing_pids))
console.print(f" [yellow]🛑 Stopping Artemis server on port {port} (PID: {pid_str})...[/yellow]")
else:
console.print(f" [dim]Scanning port {port}...[/dim]")
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=4.0)
if stopped_pids:
console.print(
Panel(
f"[bold green]✓ Artemis server stopped successfully.[/bold green]\n\n"
f"[dim]{msg}[/dim]",
title="🛑 Server Stopped",
border_style="green",
expand=False,
)
)
elif not success:
console.print(
Panel(
f"[bold red]⚠ Failed to fully stop server on port {port}[/bold red]\n\n{msg}",
title="⚠ Warning",
border_style="red",
expand=False,
)
)
raise typer.Exit(code=1)
else:
console.print(
Panel(
f"[dim]No active Artemis server found on port {port}. Port is free.[/dim]",
title="ℹ Status",
border_style="dim",
expand=False,
)
)
def status_command(
port: Annotated[
int,
typer.Option("--port", "-p", help="Port to query for Artemis server status."),
] = 8000,
) -> None:
"""Check whether the Artemis server is currently running and display runtime metadata."""
console.print()
status = get_server_status(port=port)
if status["running"]:
pids_str = ", ".join(map(str, status["pids"])) if status["pids"] else str(status["active_pid"] or "Unknown")
uptime_str = _format_uptime(status["uptime_seconds"])
metadata = status.get("metadata") or {}
started_at_str = "Unknown"
if metadata.get("started_at"):
try:
dt = datetime.datetime.fromtimestamp(metadata["started_at"])
started_at_str = dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
pass
table = Table(show_header=False, box=None, padding=(0, 2))
table.add_row("[bold cyan]Status[/bold cyan]", "[bold green]● ONLINE / RUNNING[/bold green]")
table.add_row("[bold cyan]PID(s)[/bold cyan]", f"[yellow]{pids_str}[/yellow]")
table.add_row("[bold cyan]Port[/bold cyan]", f"[white]{port}[/white]")
table.add_row("[bold cyan]Uptime[/bold cyan]", f"[white]{uptime_str}[/white]")
table.add_row("[bold cyan]Started At[/bold cyan]", f"[dim]{started_at_str}[/dim]")
table.add_row("[bold cyan]Showcase UI[/bold cyan]", f"[cyan]{status['url']}[/cyan]")
table.add_row("[bold cyan]Admin Debug[/bold cyan]", f"[magenta]{status['admin_url']}[/magenta]")
if metadata.get("cwd"):
table.add_row("[bold cyan]Workspace[/bold cyan]", f"[dim]{metadata['cwd']}[/dim]")
console.print(
Panel(
table,
title="🚀 Artemis Server Status",
border_style="green",
expand=False,
)
)
console.print(" [dim]💡 To restart: [bold]artemis restart[/bold] | To stop: [bold]artemis stop[/bold][/dim]\n")
else:
console.print(
Panel(
f"[dim]○ OFFLINE / STOPPED[/dim]\n\n"
f"Port [cyan]{port}[/cyan] is currently free.\n"
f"[dim]No active Artemis server process detected.[/dim]",
title="ℹ Artemis Server Status",
border_style="dim",
expand=False,
)
)
console.print(" [dim]💡 To start: [bold]artemis ui[/bold] or [bold]./start.sh[/bold][/dim]\n")
+48
View File
@@ -95,11 +95,59 @@ def ui_command(
help="Enable uvicorn live auto-reload (for development).",
),
] = False,
restart: Annotated[
bool,
typer.Option(
"--restart",
"-r",
help="Restart server by terminating any existing instance on the port first.",
),
] = False,
) -> None:
"""Launch the unified Artemis Showcase UI & Admin Console in one click."""
console = Console()
local_url = f"http://localhost:{port}"
from artemis.runtime.server_lifecycle import (
find_server_pids,
is_port_in_use,
stop_server,
)
if restart:
console.print(f" [yellow]🔄 Restart requested. Recycling port {port}...[/yellow]")
stop_server(port=port, timeout=4.0)
elif is_port_in_use(port):
pids = find_server_pids(port)
pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else ""
if sys.stdin.isatty():
console.print(f"\n [yellow]⚠ Port {port} is already in use by an active Artemis server{pid_str}.[/yellow]")
console.print(" [dim]Choose an action:[/dim]")
console.print(" [bold]1[/bold] Open browser (default)")
console.print(" [bold]2[/bold] Restart server (stop existing and start here)")
console.print(" [bold]3[/bold] Cancel")
try:
choice = input(" Select [1-3, default 1]: ").strip()
except (KeyboardInterrupt, EOFError):
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)
elif choice == "3":
return
else:
if open_browser:
webbrowser.open(local_url)
return
else:
console.print(
f"\n [yellow]⚠ Port {port} is already in use by Artemis{pid_str}. "
f"Use '[bold]artemis restart[/bold]' to restart.[/yellow]\n"
)
if open_browser:
webbrowser.open(local_url)
return
console.print()
msg = (
f"[bold cyan]✨ Artemis Autonomous Mobile Agent UI[/bold cyan]\n\n"