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

+132 -1
View File
@@ -15,6 +15,8 @@
"""Unit tests for Artemis MCP Tools."""
import inspect
import json
import os
import shutil
import tempfile
from types import SimpleNamespace
@@ -49,6 +51,7 @@ def test_tool_signatures():
assert "locked_app_package" in sig_run.parameters
assert "app_path" in sig_run.parameters
assert "expected_output_desc" in sig_run.parameters
assert "device_serial" in sig_run.parameters
# mobile_manage_task signature check
sig_manage = inspect.signature(mobile_manage_task)
@@ -59,6 +62,7 @@ def test_tool_signatures():
# mobile_get_device_state signature check
sig_device = inspect.signature(mobile_get_device_state)
assert "view_type" in sig_device.parameters
assert "device_serial" in sig_device.parameters
# mobile_inspect_trace signature check
sig_inspect = inspect.signature(mobile_inspect_trace)
@@ -97,10 +101,54 @@ def test_mobile_run_task_reserves_and_passes_global_queue_ticket(temp_trace_env)
ingress="mcp",
)
assert result["trace_id"]
assert result["device_serial"] == "auto-select"
assert popen.call_args.kwargs["env"]["ARTEMIS_DEVICE_QUEUE_TICKET"] == "queue-ticket-1"
assert popen.call_args.kwargs["env"]["ARTEMIS_TASK_INGRESS"] == "mcp"
status = trace_store.read_status(result["trace_id"])
assert status["queue_ticket"] == "queue-ticket-1"
assert status["device_serial"] is None
def test_mobile_run_task_with_device_serial(temp_trace_env):
process = MagicMock(pid=54321)
with (
patch(
"mcp_server.tools.task_runner.DeviceExecutionLock.reserve",
return_value="queue-ticket-dev",
) as reserve,
patch("mcp_server.tools.task_runner.DeviceExecutionLock.transfer_reservation") as transfer,
patch("mcp_server.tools.task_runner.subprocess.Popen", return_value=process) as popen,
):
result = mobile_run_task(
task_desc="Open Settings on target phone",
conversation_id="conv-2",
model="Flash",
device_serial="pixel-11-pro-001",
)
reserve.assert_called_once_with(
description="MCP task: Open Settings on target phone",
session_id=result["trace_id"],
ingress="mcp",
device_id="pixel-11-pro-001",
)
transfer.assert_called_once_with(
"queue-ticket-dev",
54321,
description="MCP task: Open Settings on target phone",
session_id=result["trace_id"],
ingress="mcp",
device_id="pixel-11-pro-001",
)
assert result["device_serial"] == "pixel-11-pro-001"
cmd = popen.call_args.args[0]
assert "--device-serial" in cmd
assert cmd[cmd.index("--device-serial") + 1] == "pixel-11-pro-001"
assert popen.call_args.kwargs["env"]["ADB_DEVICE_SERIAL"] == "pixel-11-pro-001"
assert popen.call_args.kwargs["env"]["ARTEMIS_DEVICE_ID"] == "pixel-11-pro-001"
status = trace_store.read_status(result["trace_id"])
assert status["device_serial"] == "pixel-11-pro-001"
def test_mobile_manage_task_unknown_trace(temp_trace_env):
@@ -111,13 +159,14 @@ def test_mobile_manage_task_unknown_trace(temp_trace_env):
def test_mobile_manage_task_status_and_stop(temp_trace_env):
trace_id = str(uuid.uuid4())
trace_store.init_trace(trace_id, "Test task", "Flash", "conv-1")
trace_store.init_trace(trace_id, "Test task", "Flash", "conv-1", device_serial="device-xyz")
# Status check
status_res = mobile_manage_task(action="status", trace_id=trace_id)
assert status_res["trace_id"] == trace_id
assert status_res["status"] == "running"
assert status_res["model"] == "Flash"
assert status_res["device_serial"] == "device-xyz"
# Stop without PID
stop_res = mobile_manage_task(action="stop", trace_id=trace_id)
@@ -125,6 +174,31 @@ def test_mobile_manage_task_status_and_stop(temp_trace_env):
assert "PID" in stop_res["message"] or "Cannot stop" in stop_res["message"]
def test_mobile_manage_task_status_backfills_device_serial_from_db(temp_trace_env):
import sqlite3
import json
import os
trace_id = str(uuid.uuid4())
trace_store.init_trace(trace_id, "Test task without serial", "Flash", "conv-1")
# Create dummy data_engine.db with session record
db_path = os.path.join(temp_trace_env, "data_engine.db")
conn = sqlite3.connect(db_path)
conn.execute(
"CREATE TABLE sessions (session_id TEXT PRIMARY KEY, device_info TEXT, pid INTEGER, start_time REAL)"
)
conn.execute(
"INSERT INTO sessions VALUES (?, ?, ?, ?)",
(trace_id, json.dumps({"device_id": "auto-chosen-serial-777"}), 12345, 1000.0),
)
conn.commit()
conn.close()
status_res = mobile_manage_task(action="status", trace_id=trace_id)
assert status_res["device_serial"] == "auto-chosen-serial-777"
def test_mobile_manage_task_inject_instruction(temp_trace_env):
trace_id = str(uuid.uuid4())
trace_store.init_trace(trace_id, "Test task", "Pro", "conv-1")
@@ -181,6 +255,63 @@ async def test_mobile_inspect_trace_invalid_action():
assert "not supported" in res["message"]
@pytest.mark.asyncio
async def test_mobile_get_device_state_passes_device_serial():
"""Verify that device_serial is forwarded to _get_controller."""
controller = MagicMock()
controller.ctx.device.device_width = 1080
controller.ctx.device.device_height = 2400
controller.get_screen_data = AsyncMock(
return_value=SimpleNamespace(
base64="ZHVtbXk=",
elements=[],
width=1080,
height=2400,
)
)
with (
patch("mcp_server.tools.device_state._get_controller", return_value=controller) as get_ctrl_mock,
patch("mcp_server.tools.device_state.is_ocr_configured", return_value=False),
):
await mobile_get_device_state(view_type="hierarchy", device_serial="device-serial-abc")
get_ctrl_mock.assert_called_once_with(device_serial="device-serial-abc")
@pytest.mark.asyncio
async def test_mobile_inspect_trace_includes_device_serial(temp_trace_env):
import sqlite3
trace_id = str(uuid.uuid4())
trace_store.init_trace(trace_id, "Inspect test", "Flash", "conv-1", device_serial="pixel-target-999")
# Create dummy database and table
db_path = os.path.join(temp_trace_env, "data_engine.db")
conn = sqlite3.connect(db_path)
conn.execute("CREATE TABLE sessions (session_id TEXT PRIMARY KEY, start_time REAL, device_info TEXT)")
conn.execute("CREATE TABLE steps (step_id TEXT PRIMARY KEY, session_id TEXT, step_number INTEGER, timestamp REAL, pre_image_name TEXT, post_image_name TEXT, summary TEXT, action_taken TEXT, operator_raw_thinking TEXT, last_execution_result TEXT, extra_metadata TEXT)")
conn.execute("CREATE TABLE traces (trace_id TEXT PRIMARY KEY, step_id TEXT, session_id TEXT, type TEXT, name TEXT, status TEXT, timestamp REAL, duration REAL, payload TEXT)")
conn.execute("INSERT INTO sessions VALUES (?, ?, ?)", (trace_id, 1000.0, None))
conn.commit()
conn.close()
res_summary = await mobile_inspect_trace(action="view_summary", trace_id=trace_id)
assert "**Device Serial:** `pixel-target-999`" in res_summary
def test_trace_store_device_serial(temp_trace_env):
trace_id = "test-store-serial"
data = trace_store.init_trace(trace_id, "desc", "Flash", "conv", device_serial="init-serial")
assert data["device_serial"] == "init-serial"
data_updated = trace_store.update_trace_device_serial(trace_id, "updated-serial")
assert data_updated["device_serial"] == "updated-serial"
read_back = trace_store.read_status(trace_id)
assert read_back["device_serial"] == "updated-serial"
@pytest.mark.asyncio
async def test_mcp_server_auto_registers_tools():
"""Verify that importing mcp_server.base auto-registers all mobile tools without manual tools import."""