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

+94
View File
@@ -227,3 +227,97 @@ def test_active_owner_is_discoverable_and_can_be_annotated(monkeypatch):
assert annotated.ingress == "cli"
finally:
owner_lock.release()
def test_multi_device_locks_can_run_concurrently():
lock_a = DeviceExecutionLock("emulator-5554", "task on device A")
lock_b = DeviceExecutionLock("pixel-9-test", "task on device B")
lock_a.acquire()
try:
# Device B should be acquired without blocking since it's a different device
lock_b.acquire(blocking=False)
try:
active_owners = DeviceExecutionLock.get_active_owners()
assert "emulator-5554" in active_owners
assert "pixel-9-test" in active_owners
# A second task on device A should be blocked
lock_a_second = DeviceExecutionLock("emulator-5554", "second task on device A")
with pytest.raises(DeviceBusyError, match="task on device A"):
lock_a_second.acquire(blocking=False)
finally:
lock_b.release()
finally:
lock_a.release()
def test_global_concurrency_serial_mode_blocks_other_devices(monkeypatch):
monkeypatch.setenv("ARTEMIS_MAX_CONCURRENT_TASKS", "1")
lock_a = DeviceExecutionLock("device_alpha", "task alpha")
lock_b = DeviceExecutionLock("device_beta", "task beta")
lock_a.acquire()
try:
with pytest.raises(DeviceBusyError, match="concurrency limit"):
lock_b.acquire(blocking=False)
finally:
lock_a.release()
# Once lock A is released, lock B can acquire
lock_b.acquire(blocking=False)
lock_b.release()
def test_owner_is_alive_guards_against_recycled_pid():
from unittest.mock import MagicMock, patch
from artemis.runtime.device_lock import DeviceLockOwner
owner = DeviceLockOwner(
pid=99999,
process_created_at=0.0,
token="token-1",
device_id="dev-1",
description="test",
acquired_at="now",
)
# 1. PID is 0 or negative -> immediately dead
invalid_owner = DeviceLockOwner(
pid=0,
process_created_at=0.0,
token="token-1",
device_id="dev-1",
description="test",
acquired_at="now",
)
assert DeviceExecutionLock._owner_is_alive(invalid_owner) is False
# 2. Recycled PID belongs to non-Artemis system process -> treated as dead
mock_proc = MagicMock()
mock_proc.is_running.return_value = True
mock_proc.name.return_value = "systemd"
mock_proc.cmdline.return_value = ["/sbin/init"]
with patch("psutil.Process", return_value=mock_proc):
assert DeviceExecutionLock._owner_is_alive(owner) is False
# 3. PID belongs to a Python / Artemis process -> alive
mock_proc.name.return_value = "python3"
mock_proc.cmdline.return_value = ["python3", "-m", "artemis.main"]
with patch("psutil.Process", return_value=mock_proc):
assert DeviceExecutionLock._owner_is_alive(owner) is True
def test_safe_unlink_and_replace_retry(tmp_path):
f = tmp_path / "test_file.txt"
f.write_text("hello")
assert DeviceExecutionLock._safe_unlink(f) is True
assert not f.exists()
src = tmp_path / "src.txt"
dst = tmp_path / "dst.txt"
src.write_text("world")
assert DeviceExecutionLock._safe_replace(src, dst) is True
assert dst.read_text() == "world"