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

+30 -2
View File
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import time
from uuid import UUID
from fastapi import APIRouter, HTTPException
@@ -49,12 +50,39 @@ async def list_sessions():
unresolved_profiles = []
result = []
try:
from artemis.runtime import DeviceExecutionLock
active_owners = DeviceExecutionLock.get_active_owners()
active_owner_sids = {
str(owner.session_id)
for owner in active_owners.values()
if owner.session_id
}
except Exception:
active_owner_sids = set()
for row_dict in rows:
s_id = str(row_dict.get("session_id"))
recording = latest_recordings.get(s_id)
d_info_raw = row_dict.get("device_info")
device_id = None
if d_info_raw:
try:
d_info = json.loads(d_info_raw) if isinstance(d_info_raw, str) else d_info_raw
if isinstance(d_info, dict):
device_id = d_info.get("device_id") or d_info.get("device_serial")
except Exception:
pass
if not device_id and recording:
device_id = recording.get("device_id")
row_dict["device_id"] = device_id
row_dict["device_serial"] = device_id
if row_dict.get("status") == "running":
is_active = s_id in state.active_connections or (
state.is_running and s_id == str(state.active_session_id)
is_active = (
s_id in active_owner_sids
or s_id in state.active_connections
or (state.is_running and s_id == str(state.active_session_id))
)
worker_is_alive = session_repo.process_is_alive(row_dict.get("pid"))
if not is_active and not worker_is_alive:
+93
View File
@@ -360,3 +360,96 @@ async def get_model_config_and_env():
"env_filename": ".env",
"env_vars": env_vars,
}
@router.get("/server-status")
async def get_server_runtime_status():
"""Retrieve runtime status, PID, port, and uptime of the Artemis server."""
import os
from artemis.runtime.server_lifecycle import get_server_status
try:
from apps.admin_console.core.state import state
except ImportError:
from admin_console.core.state import state
port = getattr(state, "port", 8000)
status = get_server_status(port=port)
status["current_pid"] = os.getpid()
return status
@router.post("/restart")
async def restart_server_endpoint():
"""Request a graceful restart of the Artemis server from thin clients/UI."""
import asyncio
import os
import sys
import threading
try:
from apps.admin_console.core.state import state
except ImportError:
from admin_console.core.state import state
port = getattr(state, "port", 8000)
current_pid = os.getpid()
def _restart_worker():
import time
time.sleep(0.6)
if sys.platform != "win32":
try:
os.execv(sys.executable, [sys.executable] + sys.argv)
except Exception:
import subprocess
subprocess.Popen([sys.executable] + sys.argv)
os._exit(0)
else:
import subprocess
subprocess.Popen([sys.executable] + sys.argv)
os._exit(0)
threading.Thread(target=_restart_worker, daemon=True).start()
return {
"status": "restarting",
"message": "Artemis server is restarting. Client reconnection should occur in 2-3 seconds.",
"previous_pid": current_pid,
"port": port,
}
@router.post("/shutdown")
async def shutdown_server_endpoint():
"""Request a graceful shutdown of the Artemis server."""
import os
import signal
import threading
try:
from apps.admin_console.core.state import state
except ImportError:
from admin_console.core.state import state
current_pid = os.getpid()
def _shutdown_worker():
import time
time.sleep(0.5)
try:
os.kill(current_pid, signal.SIGTERM)
except Exception:
os._exit(0)
threading.Thread(target=_shutdown_worker, daemon=True).start()
return {
"status": "shutting_down",
"message": "Artemis server is shutting down.",
"pid": current_pid,
}
+46 -3
View File
@@ -18,7 +18,7 @@ import json
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from artemis.core.diagnostics import readiness_engine
from artemis.runtime import DeviceExecutionLock
from artemis.runtime import DeviceExecutionLock, device_pool
try:
from admin_console.core.state import state
@@ -99,9 +99,17 @@ async def run_task(request: RunRequest):
enable_outputter=request.enable_outputter,
locked_app_package=request.locked_app_package,
app_path=request.app_path,
device_serial=request.device_serial,
)
@router.get("/api/devices")
async def list_devices():
"""List all connected Android devices with their busy / idle status."""
devices = await device_pool.list_devices_async()
return {"devices": [d.to_dict() for d in devices]}
@router.post("/api/stop")
async def stop_task(all: bool = False):
stopped = task_queue_service.stop_tasks(clear_all=all)
@@ -138,10 +146,24 @@ async def get_status():
None,
)
active_owners = DeviceExecutionLock.get_active_owners()
active_tasks = [
{
"device_id": owner.device_id,
"session_id": owner.session_id,
"goal": owner.description,
"pid": owner.pid,
"ingress": owner.ingress,
"acquired_at": owner.acquired_at,
}
for owner in active_owners.values()
]
running_sid = (
(global_owner.session_id if global_owner else None)
or state.active_session_id
or (running_task.get("session_id") if running_task else None)
or (active_tasks[0]["session_id"] if active_tasks and active_tasks[0].get("session_id") else None)
or (session_repo.get_running_session_id() if is_running else None)
)
owner_connection = (
@@ -152,6 +174,7 @@ async def get_status():
or (global_owner.description if global_owner else None)
or state.current_goal
or (running_task.get("goal") if running_task else None)
or (active_tasks[0]["goal"] if active_tasks else None)
)
active_profile = owner_connection.get("profile") or state.current_profile or (
@@ -168,7 +191,23 @@ async def get_status():
)
model_info = model_service.get_active_model_info(active_profile)
queue_data = state.queue_tasks
# Unified Global Queue: merge web tasks and external SDK/CLI device queue tickets
global_queued = DeviceExecutionLock.get_queued_tasks()
seen_ids = set()
queue_data: list[dict[str, Any]] = []
for item in state.queue_tasks:
sid = item.get("session_id")
if sid:
seen_ids.add(str(sid))
queue_data.append(item)
for g_item in global_queued:
sid = str(g_item.get("session_id"))
if sid not in seen_ids:
seen_ids.add(sid)
queue_data.append(g_item)
if is_running:
is_paused = state.is_paused
@@ -187,6 +226,7 @@ async def get_status():
"background_tasks": bg_tasks,
"queue": queue_data,
"model_info": model_info,
"active_tasks": active_tasks,
}
if latest_session_id and str(latest_session_id) in state.active_connections:
@@ -203,6 +243,8 @@ async def get_status():
"background_tasks": bg_tasks,
"queue": queue_data,
"model_info": conn_model_info,
"active_tasks": active_tasks,
"ipc_port": state.ipc_port,
}
return {
@@ -210,7 +252,8 @@ async def get_status():
"session_id": latest_session_id,
"background_tasks": bg_tasks,
"queue": queue_data,
"model_info": model_info,
"active_tasks": active_tasks,
"ipc_port": state.ipc_port,
}