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:
1 parent
a36a66ce6d
commit
c2c90a6378
84 files changed
+5612
-645
No files matched your search
@@ -30,6 +30,8 @@ class ServerState:
|
||||
self.ipc_server: asyncio.Server | None = None
|
||||
self.ipc_serve_task: asyncio.Task | None = None
|
||||
self.ipc_port: int | None = None
|
||||
self.port: int = 8000
|
||||
self.host: str = "0.0.0.0"
|
||||
self.is_shutting_down: bool = False
|
||||
|
||||
self.current_process: asyncio.subprocess.Process | None = None
|
||||
|
||||
@@ -1148,13 +1148,15 @@
|
||||
const time = new Date(session.start_time * 1000).toLocaleString();
|
||||
const titleText = session.initial_goal || 'Unnamed Session';
|
||||
const sidDisplay = session.session_id || '';
|
||||
const deviceSerial = session.device_serial || session.device_id || '';
|
||||
const devicePrefix = deviceSerial ? `${escapeHtml(deviceSerial)} · ` : '';
|
||||
|
||||
div.innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<h3 style="font-size: 0.95rem; margin-bottom: 3px; margin-top: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${titleText}">${titleText}</h3>
|
||||
<div style="font-size: 0.75rem; font-family: monospace; opacity: 0.85; margin-bottom: 3px; word-break: break-all; color: ${session.session_id === currentSessionId ? '#edf2f7' : '#4a5568'};">ID: ${sidDisplay}</div>
|
||||
<small style="color: ${session.session_id === currentSessionId ? '#e2e8f0' : '#718096'}; font-size: 0.75rem;">${time}</small>
|
||||
<small style="color: ${session.session_id === currentSessionId ? '#e2e8f0' : '#718096'}; font-size: 0.75rem;">${devicePrefix}${time}</small>
|
||||
</div>
|
||||
<button onclick="deleteSession(event, '${session.session_id}')" style="background: none; border: none; color: ${session.session_id === currentSessionId ? '#fff' : '#e53e3e'}; cursor: pointer; font-size: 1.1rem; padding: 0 0 0 8px; line-height: 1; flex-shrink: 0;" title="Delete Session">🗑️</button>
|
||||
</div>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ class RunRequest(BaseModel):
|
||||
enable_outputter: bool | None = None
|
||||
locked_app_package: str | None = None
|
||||
app_path: str | None = None
|
||||
device_serial: str | None = None
|
||||
|
||||
|
||||
|
||||
class ReplayRequest(BaseModel):
|
||||
|
||||
@@ -49,8 +49,10 @@ import uvicorn
|
||||
|
||||
from artemis.runtime import (
|
||||
DeviceExecutionLock,
|
||||
clear_server_info,
|
||||
shutdown_awake_service,
|
||||
start_awake_service,
|
||||
write_server_info,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -108,6 +110,7 @@ async def on_startup():
|
||||
"""Startup lifecycle hooks."""
|
||||
state.is_shutting_down = False
|
||||
state.shutdown_event.clear()
|
||||
write_server_info(port=getattr(state, "port", 8000), host=getattr(state, "host", "0.0.0.0"))
|
||||
await asyncio.to_thread(start_awake_service)
|
||||
cleaned_device_locks = DeviceExecutionLock.cleanup_stale_locks()
|
||||
if cleaned_device_locks:
|
||||
@@ -160,6 +163,7 @@ async def on_shutdown():
|
||||
await ipc_service.stop_server()
|
||||
state.ipc_subscribers.clear()
|
||||
await asyncio.to_thread(shutdown_awake_service)
|
||||
clear_server_info()
|
||||
|
||||
|
||||
# Mount modular routers
|
||||
@@ -382,23 +386,30 @@ class ArtemisUvicornServer(uvicorn.Server):
|
||||
|
||||
def run_ui_server(host: str, port: int, reload: bool = False) -> None:
|
||||
"""Run the UI server with bounded, signal-aware graceful shutdown."""
|
||||
if reload:
|
||||
uvicorn.run(
|
||||
state.host = host
|
||||
state.port = port
|
||||
write_server_info(port=port, host=host)
|
||||
|
||||
try:
|
||||
if reload:
|
||||
uvicorn.run(
|
||||
"apps.admin_console.server:app",
|
||||
host=host,
|
||||
port=port,
|
||||
reload=True,
|
||||
timeout_graceful_shutdown=5,
|
||||
)
|
||||
return
|
||||
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
host=host,
|
||||
port=port,
|
||||
reload=True,
|
||||
timeout_graceful_shutdown=5,
|
||||
)
|
||||
return
|
||||
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
host=host,
|
||||
port=port,
|
||||
timeout_graceful_shutdown=5,
|
||||
)
|
||||
ArtemisUvicornServer(config).run()
|
||||
ArtemisUvicornServer(config).run()
|
||||
finally:
|
||||
clear_server_info()
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -334,12 +334,12 @@ PRESET_TASK_CATALOG: list[TaskPreset] = [
|
||||
required_packages=["com.google.android.apps.photos"],
|
||||
priority=82,
|
||||
),
|
||||
# 9. WeChat (微信)
|
||||
# 9. WeChat
|
||||
TaskPreset(
|
||||
id="wechat_browse",
|
||||
title="Check WeChat Messages",
|
||||
description="Open WeChat and view top recent chat conversations",
|
||||
goal="Open WeChat (微信) and view the top recent chat messages.",
|
||||
goal="Open WeChat and view the top recent chat messages.",
|
||||
profile="flash",
|
||||
category="flash",
|
||||
tag="WeChat",
|
||||
@@ -351,7 +351,7 @@ PRESET_TASK_CATALOG: list[TaskPreset] = [
|
||||
id="pro_wechat_to_calendar",
|
||||
title="WeChat Notice to Calendar",
|
||||
description="Extract meeting notice from WeChat chat and add to Calendar",
|
||||
goal="Open WeChat (微信), locate the latest meeting announcement or event message in the top chat, extract the time and topic, then open Calendar and schedule an event.",
|
||||
goal="Open WeChat, locate the latest meeting announcement or event message in the top chat, extract the time and topic, then open Calendar and schedule an event.",
|
||||
profile="pro",
|
||||
category="cross_app",
|
||||
tag="WeChat + Calendar",
|
||||
@@ -367,12 +367,12 @@ PRESET_TASK_CATALOG: list[TaskPreset] = [
|
||||
required_packages=["com.tencent.mm"],
|
||||
priority=93,
|
||||
),
|
||||
# 10. Xiaohongshu (小红书)
|
||||
# 10. Xiaohongshu
|
||||
TaskPreset(
|
||||
id="xhs_coffee_guide",
|
||||
title="RED Cafe Guide Search",
|
||||
description="Search trending specialty cafe reviews on Xiaohongshu",
|
||||
goal="Open Xiaohongshu (小红书), search for top-rated specialty coffee shops, and view the top post.",
|
||||
goal="Open Xiaohongshu, search for top-rated specialty coffee shops, and view the top post.",
|
||||
profile="flash",
|
||||
category="flash",
|
||||
tag="Xiaohongshu",
|
||||
@@ -384,12 +384,12 @@ PRESET_TASK_CATALOG: list[TaskPreset] = [
|
||||
required_packages=["com.xingin.xhs"],
|
||||
priority=87,
|
||||
),
|
||||
# 11. Meituan / Dianping (美团 / 大众点评)
|
||||
# 11. Meituan / Dianping
|
||||
TaskPreset(
|
||||
id="meituan_ramen_search",
|
||||
title="Meituan Food Search",
|
||||
description="Search top-rated Ramen nearby on Meituan or Dianping",
|
||||
goal="Open Meituan (美团) or Dianping (大众点评), search for top-rated Ramen nearby, and view top restaurant rating.",
|
||||
goal="Open Meituan or Dianping, search for top-rated Ramen nearby, and view top restaurant rating.",
|
||||
profile="flash",
|
||||
category="flash",
|
||||
tag="Meituan",
|
||||
@@ -401,12 +401,12 @@ PRESET_TASK_CATALOG: list[TaskPreset] = [
|
||||
required_packages=["com.sankuai.meituan", "com.dianping.v1"],
|
||||
priority=86,
|
||||
),
|
||||
# 12. Bilibili (哔哩哔哩)
|
||||
# 12. Bilibili
|
||||
TaskPreset(
|
||||
id="bilibili_stream",
|
||||
title="Bilibili Tech Video",
|
||||
description="Search and play an AI Agent tutorial video on Bilibili",
|
||||
goal='Open Bilibili (哔哩哔哩), search for "AI Agent 架构实战", and play the top matching video.',
|
||||
goal='Open Bilibili, search for "AI Agent Architecture", and play the top matching video.',
|
||||
profile="flash",
|
||||
category="flash",
|
||||
tag="Bilibili",
|
||||
|
||||
@@ -241,6 +241,11 @@ class TaskQueueService:
|
||||
async def queue_worker(cls):
|
||||
"""Persistent, self-healing background worker loop processing tasks in FIFO order."""
|
||||
print("[QueueWorker] Background worker loop initialized and running.")
|
||||
try:
|
||||
DeviceExecutionLock.cleanup_stale_locks()
|
||||
except Exception as exc:
|
||||
print(f"[QueueWorker] Initial stale lock cleanup notice: {exc}")
|
||||
|
||||
while True:
|
||||
task_item = None
|
||||
sess_id = None
|
||||
@@ -289,6 +294,7 @@ class TaskQueueService:
|
||||
"session_id": sess_id,
|
||||
"initial_goal": goal,
|
||||
"profile": profile,
|
||||
"device_serial": task_item.get("device_serial"),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -322,6 +328,8 @@ class TaskQueueService:
|
||||
"--test-name",
|
||||
test_name,
|
||||
]
|
||||
if sess_id:
|
||||
cmd.extend(["--session-id", str(sess_id)])
|
||||
if expected_output:
|
||||
cmd.extend(["--output-description", str(expected_output)])
|
||||
if enable_outputter is not None:
|
||||
@@ -330,9 +338,13 @@ class TaskQueueService:
|
||||
cmd.extend(["--locked-app", str(locked_app)])
|
||||
if app_path:
|
||||
cmd.extend(["--app-path", str(app_path)])
|
||||
device_serial = task_item.get("device_serial")
|
||||
if device_serial:
|
||||
cmd.extend(["--device-serial", str(device_serial)])
|
||||
env["ADB_DEVICE_SERIAL"] = str(device_serial)
|
||||
|
||||
print(
|
||||
f"[QueueWorker] Starting task [{sess_id}]: '{goal}' (profile: {profile}, outputter: {bool(expected_output or enable_outputter)})"
|
||||
f"[QueueWorker] Starting task [{sess_id}]: '{goal}' (profile: {profile}, device: {device_serial or 'auto'}, outputter: {bool(expected_output or enable_outputter)})"
|
||||
)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
@@ -349,6 +361,7 @@ class TaskQueueService:
|
||||
str(queue_ticket),
|
||||
proc.pid,
|
||||
description=f"frontend task: {goal[:120]}",
|
||||
device_id=device_serial or "pending",
|
||||
session_id=str(sess_id) if sess_id else None,
|
||||
ingress="frontend",
|
||||
)
|
||||
@@ -453,6 +466,7 @@ class TaskQueueService:
|
||||
enable_outputter: bool | None = None,
|
||||
locked_app_package: str | None = None,
|
||||
app_path: str | None = None,
|
||||
device_serial: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Enqueues one or more goals and wakes up the background worker."""
|
||||
cls.ensure_worker_running()
|
||||
@@ -461,8 +475,18 @@ class TaskQueueService:
|
||||
now = time.time()
|
||||
for i, goal in enumerate(goals):
|
||||
sess_id = str(uuid.uuid4())
|
||||
assigned_serial = device_serial
|
||||
if not assigned_serial:
|
||||
try:
|
||||
from artemis.runtime import device_pool
|
||||
|
||||
assigned_serial = device_pool.select_device()
|
||||
except Exception:
|
||||
assigned_serial = None
|
||||
|
||||
queue_ticket = DeviceExecutionLock.reserve(
|
||||
description=f"frontend task: {goal[:120]}",
|
||||
device_id=assigned_serial or "pending",
|
||||
session_id=sess_id,
|
||||
ingress="frontend",
|
||||
)
|
||||
@@ -474,6 +498,7 @@ class TaskQueueService:
|
||||
"enable_outputter": enable_outputter,
|
||||
"locked_app_package": locked_app_package,
|
||||
"app_path": app_path,
|
||||
"device_serial": assigned_serial,
|
||||
"status": "pending",
|
||||
"queue_ticket": queue_ticket,
|
||||
"created_at": now + i * 0.001,
|
||||
|
||||
Reference in new issue
Block a user