refactor(device): retire remaining single-device state
This commit is contained in:
commit
584bb2d09c
12 files changed
+173
-86
No files matched your search
@@ -111,7 +111,7 @@ async def select_active_device(request: SelectDeviceRequest):
|
|||||||
if not serial:
|
if not serial:
|
||||||
raise HTTPException(status_code=400, detail="Device serial cannot be empty.")
|
raise HTTPException(status_code=400, detail="Device serial cannot be empty.")
|
||||||
|
|
||||||
readiness_engine.set_active_device_serial(serial)
|
readiness_engine.set_probe_target_serial(serial)
|
||||||
# Return updated readiness
|
# Return updated readiness
|
||||||
report = await readiness_engine.run_all(force_refresh=True)
|
report = await readiness_engine.run_all(force_refresh=True)
|
||||||
return {
|
return {
|
||||||
@@ -192,7 +192,7 @@ async def connect_adb_server(payload: ConnectAdbServerRequest, request: Request)
|
|||||||
|
|
||||||
response: dict[str, object] = {"connection_result": connection_result}
|
response: dict[str, object] = {"connection_result": connection_result}
|
||||||
if connection_result["success"]:
|
if connection_result["success"]:
|
||||||
readiness_engine.set_active_device_serial(None)
|
readiness_engine.set_probe_target_serial(None)
|
||||||
readiness_engine.invalidate_cache()
|
readiness_engine.invalidate_cache()
|
||||||
response["report"] = await readiness_engine.run_all(force_refresh=True)
|
response["report"] = await readiness_engine.run_all(force_refresh=True)
|
||||||
return response
|
return response
|
||||||
@@ -214,7 +214,7 @@ async def use_local_adb_server(request: Request, persist: bool = True):
|
|||||||
"""Restore the standard local ADB server without touching a remote daemon."""
|
"""Restore the standard local ADB server without touching a remote daemon."""
|
||||||
_require_local_admin_request(request)
|
_require_local_admin_request(request)
|
||||||
connection_result = await adb_server_connection.use_local_server(persist=persist)
|
connection_result = await adb_server_connection.use_local_server(persist=persist)
|
||||||
readiness_engine.set_active_device_serial(None)
|
readiness_engine.set_probe_target_serial(None)
|
||||||
readiness_engine.invalidate_cache()
|
readiness_engine.invalidate_cache()
|
||||||
updated_report = await readiness_engine.run_all(force_refresh=True)
|
updated_report = await readiness_engine.run_all(force_refresh=True)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -140,7 +140,10 @@ async def run_task(request: RunRequest):
|
|||||||
# polling intervals cannot start through a stale Ready state. Use the
|
# polling intervals cannot start through a stale Ready state. Use the
|
||||||
# bounded submission probe: the full diagnostics path also scans packages,
|
# bounded submission probe: the full diagnostics path also scans packages,
|
||||||
# emulator installations, Android version, and screen size.
|
# emulator installations, Android version, and screen size.
|
||||||
target_serial = request.device_serial or readiness_engine.get_active_device_serial()
|
# With no explicit serial the probe itself resolves a live target (it
|
||||||
|
# prefers the diagnostics target preference, then any unlocked ready
|
||||||
|
# device); the verified serial is bound below.
|
||||||
|
target_serial = request.device_serial
|
||||||
device_probe = await readiness_engine.run_device_submission_probe(
|
device_probe = await readiness_engine.run_device_submission_probe(
|
||||||
target_serial=target_serial
|
target_serial=target_serial
|
||||||
)
|
)
|
||||||
@@ -164,10 +167,6 @@ async def run_task(request: RunRequest):
|
|||||||
# invalid, enqueue_tasks rejects the submission with a clear error.
|
# invalid, enqueue_tasks rejects the submission with a clear error.
|
||||||
if verified_serial and not request.device_serial:
|
if verified_serial and not request.device_serial:
|
||||||
target_serial = verified_serial
|
target_serial = verified_serial
|
||||||
# The probe verified a live device either way: keep the global active
|
|
||||||
# serial in sync even when the caller pinned the target explicitly.
|
|
||||||
if verified_serial and readiness_engine.get_active_device_serial() != verified_serial:
|
|
||||||
readiness_engine.set_active_device_serial(verified_serial)
|
|
||||||
|
|
||||||
return await task_queue_service.enqueue_tasks(
|
return await task_queue_service.enqueue_tasks(
|
||||||
incoming_goals,
|
incoming_goals,
|
||||||
|
|||||||
@@ -772,13 +772,6 @@ class TaskQueueService:
|
|||||||
for i, goal in enumerate(goals):
|
for i, goal in enumerate(goals):
|
||||||
sess_id = (session_id if (session_id and len(goals) == 1) else str(uuid.uuid4()))
|
sess_id = (session_id if (session_id and len(goals) == 1) else str(uuid.uuid4()))
|
||||||
assigned_serial = device_serial
|
assigned_serial = device_serial
|
||||||
if not assigned_serial:
|
|
||||||
try:
|
|
||||||
from artemis.core.diagnostics import readiness_engine
|
|
||||||
|
|
||||||
assigned_serial = readiness_engine.get_active_device_serial()
|
|
||||||
except Exception:
|
|
||||||
assigned_serial = None
|
|
||||||
if not assigned_serial:
|
if not assigned_serial:
|
||||||
try:
|
try:
|
||||||
from artemis.runtime import device_pool
|
from artemis.runtime import device_pool
|
||||||
@@ -956,21 +949,18 @@ class TaskQueueService:
|
|||||||
owner = dev_owner
|
owner = dev_owner
|
||||||
break
|
break
|
||||||
if owner is None:
|
if owner is None:
|
||||||
|
# Covers scoped/legacy lock records that get_active_owners cannot
|
||||||
|
# key by session or device. A stale owner for a different session
|
||||||
|
# is never adopted: with no live owner, a session-targeted stop
|
||||||
|
# falls through to the queue/session-repository fallbacks below.
|
||||||
fallback_owner = DeviceExecutionLock.get_active_owner(target_device)
|
fallback_owner = DeviceExecutionLock.get_active_owner(target_device)
|
||||||
if fallback_owner:
|
if fallback_owner and (
|
||||||
if not target_sid or (
|
not target_sid
|
||||||
fallback_owner.session_id and str(fallback_owner.session_id) == target_sid
|
or (fallback_owner.session_id and str(fallback_owner.session_id) == target_sid)
|
||||||
):
|
):
|
||||||
owner = fallback_owner
|
owner = fallback_owner
|
||||||
elif not active_owners:
|
|
||||||
# In unit tests, get_active_owners is unmocked (empty), while get_active_owner is mocked
|
|
||||||
owner = fallback_owner
|
|
||||||
|
|
||||||
owner_record_exists = (
|
owner_record_exists = DeviceExecutionLock.has_owner_record(target_device)
|
||||||
DeviceExecutionLock.has_owner_record(target_device)
|
|
||||||
if hasattr(DeviceExecutionLock, "has_owner_record")
|
|
||||||
else False
|
|
||||||
)
|
|
||||||
owner_pid = owner.pid if owner else None
|
owner_pid = owner.pid if owner else None
|
||||||
|
|
||||||
# Resolve the locally-managed run for this target (concurrent scheduling
|
# Resolve the locally-managed run for this target (concurrent scheduling
|
||||||
@@ -991,8 +981,16 @@ class TaskQueueService:
|
|||||||
(None, None),
|
(None, None),
|
||||||
)
|
)
|
||||||
elif len(state.active_runs) == 1:
|
elif len(state.active_runs) == 1:
|
||||||
|
# Untargeted stop (legacy single-device gesture): with exactly one
|
||||||
|
# live run the target is unambiguous.
|
||||||
local_run_key, local_run = next(iter(state.active_runs.items()))
|
local_run_key, local_run = next(iter(state.active_runs.items()))
|
||||||
local_proc = (local_run or {}).get("process") or state.current_process
|
local_proc = (local_run or {}).get("process")
|
||||||
|
if local_proc is None and not target_sid and not target_device:
|
||||||
|
# Untargeted legacy fallback only: the last-started process may
|
||||||
|
# stand in when no per-run entry resolved. An explicitly targeted
|
||||||
|
# stop that matched no run must never grab current_process -- it
|
||||||
|
# mirrors the newest run, which can be an unrelated concurrent one.
|
||||||
|
local_proc = state.current_process
|
||||||
if local_run is None and local_proc is not None:
|
if local_run is None and local_proc is not None:
|
||||||
# Legacy path: current_process without a resolved run entry. Find
|
# Legacy path: current_process without a resolved run entry. Find
|
||||||
# the run that owns this process so a manual stop can be attributed
|
# the run that owns this process so a manual stop can be attributed
|
||||||
|
|||||||
@@ -404,9 +404,12 @@ class EmulatorManager:
|
|||||||
try:
|
try:
|
||||||
from artemis.core.diagnostics.engine import readiness_engine
|
from artemis.core.diagnostics.engine import readiness_engine
|
||||||
|
|
||||||
readiness_engine.set_active_device_serial(detected_serial)
|
# Focus the readiness report on the freshly booted
|
||||||
|
# emulator; task routing still resolves its own
|
||||||
|
# target from the request or the device pool.
|
||||||
|
readiness_engine.set_probe_target_serial(detected_serial)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Failed to auto-select device serial: {e}")
|
logger.debug(f"Failed to focus probes on the new emulator: {e}")
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
# Gradual progress visual indicator
|
# Gradual progress visual indicator
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ class ReadinessEngine:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._probes: dict[str, BaseProbe] = {}
|
self._probes: dict[str, BaseProbe] = {}
|
||||||
self._active_device_serial: str | None = None
|
|
||||||
self._python_probe = PythonRuntimeProbe()
|
self._python_probe = PythonRuntimeProbe()
|
||||||
self._config_probe = SystemConfigProbe()
|
self._config_probe = SystemConfigProbe()
|
||||||
self._toolchain_probe = ToolchainProbe()
|
self._toolchain_probe = ToolchainProbe()
|
||||||
@@ -81,24 +80,19 @@ class ReadinessEngine:
|
|||||||
"""Remove a diagnostic probe."""
|
"""Remove a diagnostic probe."""
|
||||||
self._probes.pop(probe_id, None)
|
self._probes.pop(probe_id, None)
|
||||||
|
|
||||||
def set_active_device_serial(self, serial: str | None) -> None:
|
def set_probe_target_serial(self, serial: str | None) -> None:
|
||||||
"""Set user-selected active target device serial."""
|
"""Set the diagnostics probes' preferred device serial.
|
||||||
self._active_device_serial = serial
|
|
||||||
|
This is a probe/report preference only (which device the readiness
|
||||||
|
report highlights and verifies first). Task routing never reads it
|
||||||
|
back: execution targets come from explicit request serials or the
|
||||||
|
device pool. Pass None to clear the preference, e.g. after switching
|
||||||
|
ADB server endpoints.
|
||||||
|
"""
|
||||||
self.invalidate_cache()
|
self.invalidate_cache()
|
||||||
if hasattr(self._adb_probe, "set_target_serial"):
|
if hasattr(self._adb_probe, "set_target_serial"):
|
||||||
self._adb_probe.set_target_serial(serial)
|
self._adb_probe.set_target_serial(serial)
|
||||||
|
|
||||||
def get_active_device_serial(self) -> str | None:
|
|
||||||
"""Get currently selected active device serial."""
|
|
||||||
if self._report_cache and self._report_cache.active_device:
|
|
||||||
if self._report_cache.active_device.is_locked is False:
|
|
||||||
return self._report_cache.active_device.serial
|
|
||||||
if self._active_device_serial:
|
|
||||||
return self._active_device_serial
|
|
||||||
if self._report_cache and self._report_cache.active_device:
|
|
||||||
return self._report_cache.active_device.serial
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def run_probe(self, probe_id: str) -> ProbeResult | None:
|
async def run_probe(self, probe_id: str) -> ProbeResult | None:
|
||||||
"""Execute a single specific probe by ID."""
|
"""Execute a single specific probe by ID."""
|
||||||
probe = self._probes.get(probe_id)
|
probe = self._probes.get(probe_id)
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ def _configure_usb_stay_awake(device_id: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _discover_connected_device_ids() -> list[str]:
|
def _discover_connected_device_ids() -> list[str]:
|
||||||
"""Return the selected or all connected local Android devices."""
|
"""Return the explicitly targeted device, or the connected devices Artemis manages."""
|
||||||
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
|
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -171,9 +171,18 @@ def _discover_connected_device_ids() -> list[str]:
|
|||||||
|
|
||||||
if target:
|
if target:
|
||||||
return [target] if target in serials else []
|
return [target] if target in serials else []
|
||||||
# Artemis has a single-device execution policy. Do not modify unrelated
|
# Keep awake only the devices Artemis actually manages: serials claimed in
|
||||||
# devices that happen to share the ADB server.
|
# the device pool by an active execution lock or a live queue reservation.
|
||||||
return serials[:1]
|
# Other devices sharing the same ADB server belong to unrelated tools or
|
||||||
|
# users, and their power policy must not be touched.
|
||||||
|
try:
|
||||||
|
from artemis.runtime.device_pool import device_pool
|
||||||
|
|
||||||
|
claimed = device_pool.get_claimed_serials()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"Could not query claimed devices for the awake policy: {exc}")
|
||||||
|
return []
|
||||||
|
return [serial for serial in serials if serial in claimed]
|
||||||
|
|
||||||
|
|
||||||
class ScreenAwakeService:
|
class ScreenAwakeService:
|
||||||
|
|||||||
@@ -398,6 +398,30 @@ class DevicePool:
|
|||||||
"""Return ready devices that are not currently holding an execution lock."""
|
"""Return ready devices that are not currently holding an execution lock."""
|
||||||
return [d for d in self.list_devices() if d.state == "device" and not d.is_busy]
|
return [d for d in self.list_devices() if d.state == "device" and not d.is_busy]
|
||||||
|
|
||||||
|
def get_claimed_serials(self) -> set[str]:
|
||||||
|
"""Return device serials Artemis currently claims on the ADB server.
|
||||||
|
|
||||||
|
A device is claimed while any Artemis process holds its execution lock
|
||||||
|
or has a live pending queue reservation targeting it. Placeholder
|
||||||
|
reservations not yet bound to a concrete device ("pending"/"any")
|
||||||
|
claim nothing. Read-only: computed purely from cross-process lock and
|
||||||
|
queue metadata, without any adb traffic.
|
||||||
|
"""
|
||||||
|
claimed: set[str] = set()
|
||||||
|
try:
|
||||||
|
for owner in DeviceExecutionLock.get_active_owners().values():
|
||||||
|
if owner.device_id:
|
||||||
|
claimed.add(str(owner.device_id))
|
||||||
|
for item in DeviceExecutionLock.get_queued_tasks():
|
||||||
|
serial = item.get("device_serial")
|
||||||
|
if serial:
|
||||||
|
claimed.add(str(serial))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"Could not compute claimed device serials: {exc}")
|
||||||
|
claimed.discard("pending")
|
||||||
|
claimed.discard("any")
|
||||||
|
return claimed
|
||||||
|
|
||||||
def select_device(self, preferred_serial: str | None = None) -> str | None:
|
def select_device(self, preferred_serial: str | None = None) -> str | None:
|
||||||
"""Select a target device serial for task execution.
|
"""Select a target device serial for task execution.
|
||||||
|
|
||||||
@@ -419,16 +443,6 @@ class DevicePool:
|
|||||||
# If not detected by ADB, still return it so ADB can attempt connection
|
# If not detected by ADB, still return it so ADB can attempt connection
|
||||||
return preferred_serial
|
return preferred_serial
|
||||||
|
|
||||||
# Prefer active ready device from readiness engine if available
|
|
||||||
try:
|
|
||||||
from artemis.core.diagnostics import readiness_engine
|
|
||||||
|
|
||||||
active_serial = readiness_engine.get_active_device_serial()
|
|
||||||
if active_serial and any(d.serial == active_serial and d.state == "device" for d in all_devs):
|
|
||||||
return active_serial
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Pick first idle device
|
# Pick first idle device
|
||||||
for dev in ready_devs:
|
for dev in ready_devs:
|
||||||
if not dev.is_busy:
|
if not dev.is_busy:
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ async def test_run_task_enqueues_when_device_is_unlocked(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_task_switches_to_unlocked_device_and_syncs_engine(monkeypatch):
|
async def test_run_task_binds_probe_verified_device_when_no_serial_requested(monkeypatch):
|
||||||
"""When default device is locked, run_task should enqueue on verified fallback and update engine."""
|
"""Without an explicit serial, run_task enqueues on the probe-verified live device."""
|
||||||
unlocked_probe = ProbeResult(
|
unlocked_probe = ProbeResult(
|
||||||
id="android_adb",
|
id="android_adb",
|
||||||
category=ProbeCategory.DEVICE,
|
category=ProbeCategory.DEVICE,
|
||||||
@@ -100,9 +100,6 @@ async def test_run_task_switches_to_unlocked_device_and_syncs_engine(monkeypatch
|
|||||||
tasks.readiness_engine, "run_device_submission_probe", run_probe
|
tasks.readiness_engine, "run_device_submission_probe", run_probe
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue_tasks)
|
monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue_tasks)
|
||||||
monkeypatch.setattr(tasks.readiness_engine, "get_active_device_serial", lambda: "device-locked-1")
|
|
||||||
set_active_mock = AsyncMock()
|
|
||||||
monkeypatch.setattr(tasks.readiness_engine, "set_active_device_serial", set_active_mock)
|
|
||||||
|
|
||||||
result = await tasks.run_task(RunRequest(goal="Run on any ready device"))
|
result = await tasks.run_task(RunRequest(goal="Run on any ready device"))
|
||||||
|
|
||||||
@@ -111,7 +108,6 @@ async def test_run_task_switches_to_unlocked_device_and_syncs_engine(monkeypatch
|
|||||||
# Check that device_serial passed to enqueue_tasks is the verified unlocked device
|
# Check that device_serial passed to enqueue_tasks is the verified unlocked device
|
||||||
_, kwargs = enqueue_tasks.call_args
|
_, kwargs = enqueue_tasks.call_args
|
||||||
assert kwargs.get("device_serial") == "emulator-5556"
|
assert kwargs.get("device_serial") == "emulator-5556"
|
||||||
set_active_mock.assert_called_once_with("emulator-5556")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -222,10 +222,6 @@ async def test_queue_worker_execution_lifecycle():
|
|||||||
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
|
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
|
||||||
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
|
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
|
||||||
patch("apps.admin_console.services.task_queue_service.media_service"),
|
patch("apps.admin_console.services.task_queue_service.media_service"),
|
||||||
patch(
|
|
||||||
"artemis.core.diagnostics.readiness_engine.get_active_device_serial",
|
|
||||||
return_value=None,
|
|
||||||
),
|
|
||||||
patch(
|
patch(
|
||||||
"artemis.runtime.device_pool.device_pool.select_device",
|
"artemis.runtime.device_pool.device_pool.select_device",
|
||||||
return_value="emulator-5554",
|
return_value="emulator-5554",
|
||||||
@@ -272,10 +268,6 @@ async def test_queue_worker_cmd_construction():
|
|||||||
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
|
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
|
||||||
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
|
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
|
||||||
patch("apps.admin_console.services.task_queue_service.media_service"),
|
patch("apps.admin_console.services.task_queue_service.media_service"),
|
||||||
patch(
|
|
||||||
"artemis.core.diagnostics.readiness_engine.get_active_device_serial",
|
|
||||||
return_value=None,
|
|
||||||
),
|
|
||||||
patch(
|
patch(
|
||||||
"artemis.runtime.device_pool.device_pool.select_device",
|
"artemis.runtime.device_pool.device_pool.select_device",
|
||||||
return_value="emulator-5554",
|
return_value="emulator-5554",
|
||||||
@@ -489,6 +481,48 @@ def test_stop_tasks_does_not_kill_stale_reused_pid():
|
|||||||
terminate_verified.assert_not_called()
|
terminate_verified.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_tasks_session_target_never_adopts_mismatched_fallback_owner():
|
||||||
|
"""No live owners is a legal state: a session-targeted stop must not
|
||||||
|
terminate a fallback owner that belongs to a different session."""
|
||||||
|
other_owner = DeviceLockOwner(
|
||||||
|
pid=24680,
|
||||||
|
process_created_at=1234.5,
|
||||||
|
token="other-owner-token",
|
||||||
|
device_id="emulator-5554",
|
||||||
|
description="Task for another session",
|
||||||
|
acquired_at="2026-08-24T00:00:00+00:00",
|
||||||
|
session_id="other-session",
|
||||||
|
ingress="cli",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owners",
|
||||||
|
return_value={},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owner",
|
||||||
|
return_value=other_owner,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.is_active_owner",
|
||||||
|
return_value=True,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"apps.admin_console.services.task_queue_service.process_supervisor.terminate_tree_verified"
|
||||||
|
) as terminate_verified,
|
||||||
|
patch(
|
||||||
|
"apps.admin_console.services.task_queue_service.session_repo.update_session_status"
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"apps.admin_console.services.task_queue_service.session_repo.get_session_by_id",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
task_queue_service.stop_tasks(clear_all=False, session_id="wanted-session")
|
||||||
|
|
||||||
|
terminate_verified.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_status_reports_external_global_owner_without_ipc_connection():
|
async def test_status_reports_external_global_owner_without_ipc_connection():
|
||||||
external_owner = DeviceLockOwner(
|
external_owner = DeviceLockOwner(
|
||||||
@@ -548,10 +582,6 @@ async def test_cancel_task_triggers_next_pending_task():
|
|||||||
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
|
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
|
||||||
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
|
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
|
||||||
patch("apps.admin_console.services.task_queue_service.media_service"),
|
patch("apps.admin_console.services.task_queue_service.media_service"),
|
||||||
patch(
|
|
||||||
"artemis.core.diagnostics.readiness_engine.get_active_device_serial",
|
|
||||||
return_value=None,
|
|
||||||
),
|
|
||||||
patch(
|
patch(
|
||||||
"artemis.runtime.device_pool.device_pool.select_device",
|
"artemis.runtime.device_pool.device_pool.select_device",
|
||||||
return_value="emulator-5554",
|
return_value="emulator-5554",
|
||||||
@@ -603,10 +633,6 @@ async def test_immediate_cancel_ignores_stale_ipc_and_runs_next_task():
|
|||||||
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
|
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
|
||||||
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
|
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
|
||||||
patch("apps.admin_console.services.task_queue_service.media_service"),
|
patch("apps.admin_console.services.task_queue_service.media_service"),
|
||||||
patch(
|
|
||||||
"artemis.core.diagnostics.readiness_engine.get_active_device_serial",
|
|
||||||
return_value=None,
|
|
||||||
),
|
|
||||||
patch(
|
patch(
|
||||||
"artemis.runtime.device_pool.device_pool.select_device",
|
"artemis.runtime.device_pool.device_pool.select_device",
|
||||||
return_value="emulator-5554",
|
return_value="emulator-5554",
|
||||||
|
|||||||
@@ -392,11 +392,13 @@ async def test_toolchain_probe_structure():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_active_device_selection():
|
async def test_probe_target_serial_forwards_to_adb_probe():
|
||||||
"""Verify selecting a device updates engine state."""
|
"""Verify the probe target preference reaches the ADB probe and can be cleared."""
|
||||||
engine = ReadinessEngine()
|
engine = ReadinessEngine()
|
||||||
engine.set_active_device_serial("test-emulator-1234")
|
engine.set_probe_target_serial("test-emulator-1234")
|
||||||
assert engine.get_active_device_serial() == "test-emulator-1234"
|
assert engine._adb_probe._target_serial == "test-emulator-1234"
|
||||||
|
engine.set_probe_target_serial(None)
|
||||||
|
assert engine._adb_probe._target_serial is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -156,11 +156,30 @@ def test_disconnect_reconciliation_stops_heartbeat_and_allows_reenrollment(
|
|||||||
assert configure.call_count == 2
|
assert configure.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@patch("artemis.runtime.device_pool.device_pool.get_claimed_serials")
|
||||||
@patch("artemis.runtime.awake_service.AdbClient")
|
@patch("artemis.runtime.awake_service.AdbClient")
|
||||||
def test_discovery_honors_single_device_policy(adb_client):
|
def test_discovery_keeps_only_pool_claimed_devices(adb_client, claimed):
|
||||||
|
"""The pool manages two devices while the ADB server lists three: only the
|
||||||
|
two claimed devices are kept awake; the unrelated one is left untouched."""
|
||||||
|
adb_client.return_value.device_list.return_value = [
|
||||||
|
MagicMock(serial="device-1"),
|
||||||
|
MagicMock(serial="device-2"),
|
||||||
|
MagicMock(serial="device-3"),
|
||||||
|
]
|
||||||
|
claimed.return_value = {"device-1", "device-2"}
|
||||||
|
|
||||||
|
assert _discover_connected_device_ids() == ["device-1", "device-2"]
|
||||||
|
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"artemis.runtime.device_pool.device_pool.get_claimed_serials",
|
||||||
|
return_value=set(),
|
||||||
|
)
|
||||||
|
@patch("artemis.runtime.awake_service.AdbClient")
|
||||||
|
def test_discovery_returns_nothing_when_pool_claims_no_device(adb_client, _claimed):
|
||||||
adb_client.return_value.device_list.return_value = [
|
adb_client.return_value.device_list.return_value = [
|
||||||
MagicMock(serial="device-1"),
|
MagicMock(serial="device-1"),
|
||||||
MagicMock(serial="device-2"),
|
MagicMock(serial="device-2"),
|
||||||
]
|
]
|
||||||
|
|
||||||
assert _discover_connected_device_ids() == ["device-1"]
|
assert _discover_connected_device_ids() == []
|
||||||
@@ -91,6 +91,33 @@ def test_device_pool_list_devices_and_busy_status(monkeypatch):
|
|||||||
lock.release()
|
lock.release()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_claimed_serials_reflects_locks_and_reservations():
|
||||||
|
pool = DevicePool()
|
||||||
|
assert pool.get_claimed_serials() == set()
|
||||||
|
|
||||||
|
lock = DeviceExecutionLock("emulator-5554", "claimed by test")
|
||||||
|
lock.acquire()
|
||||||
|
bound_ticket = DeviceExecutionLock.reserve(
|
||||||
|
description="queued task",
|
||||||
|
device_id="pixel-8-pro",
|
||||||
|
session_id="queued-session",
|
||||||
|
)
|
||||||
|
unbound_ticket = DeviceExecutionLock.reserve(
|
||||||
|
description="unbound task",
|
||||||
|
device_id="pending",
|
||||||
|
session_id="pending-session",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# Locked and reserved serials are claimed; the unbound placeholder is not.
|
||||||
|
assert pool.get_claimed_serials() == {"emulator-5554", "pixel-8-pro"}
|
||||||
|
finally:
|
||||||
|
DeviceExecutionLock.cancel_reservation(bound_ticket)
|
||||||
|
DeviceExecutionLock.cancel_reservation(unbound_ticket)
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
assert pool.get_claimed_serials() == set()
|
||||||
|
|
||||||
|
|
||||||
RAW_DEVICE = ("emulator-5554", "device", "Emulator", "sdk")
|
RAW_DEVICE = ("emulator-5554", "device", "Emulator", "sdk")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user