diff --git a/apps/device-host-agent/host_agent/web/app.py b/apps/device-host-agent/host_agent/web/app.py index 38aa360..2749936 100644 --- a/apps/device-host-agent/host_agent/web/app.py +++ b/apps/device-host-agent/host_agent/web/app.py @@ -108,6 +108,17 @@ def _login_page( return HTMLResponse(_chrome("Login", body, session=None)) +def _device_display_status(device: Any, *, busy_device_id: str | None) -> str: + """Connected-but-idle devices report "busy" at the DeviceManager layer + (an active Appium/WDA session is required to reuse it), which is not the + same as a task currently running on that device. Only the device actually + bound to the current assignment should read as "busy" here. + """ + if device.status == "busy" and device.id != busy_device_id: + return "connected" + return device.status + + def _dashboard_body( *, identity: HostIdentityState | None, @@ -118,6 +129,7 @@ def _dashboard_body( heartbeat = snapshot.get("last_heartbeat") assignment = snapshot.get("current_assignment") policy = snapshot.get("host_policy") + busy_device_id = assignment["device_id"] if assignment else None heartbeat_text = ( f"{'ok' if heartbeat['ok'] else 'failed'} at {heartbeat['at']} " f"({heartbeat['device_count']} devices)" @@ -143,7 +155,8 @@ def _dashboard_body( ) device_rows = "".join( f"{escape(device.id)}{escape(device.name or '')}" - f"{escape(device.driver_type)}{escape(device.status)}" + f"{escape(device.driver_type)}" + f"{escape(_device_display_status(device, busy_device_id=busy_device_id))}" for device in devices ) return f""" @@ -403,12 +416,14 @@ def create_console_app( session: SessionState = Depends(require_session), ) -> JSONResponse: snapshot = status_tracker.snapshot() + current_assignment = snapshot.get("current_assignment") + busy_device_id = current_assignment["device_id"] if current_assignment else None devices = [ { "id": device.id, "name": device.name, "driver_type": device.driver_type, - "status": device.status, + "status": _device_display_status(device, busy_device_id=busy_device_id), } for device in manager.list_devices() ] diff --git a/apps/device-host-agent/tests/test_web_app.py b/apps/device-host-agent/tests/test_web_app.py index ae0af3d..36d3796 100644 --- a/apps/device-host-agent/tests/test_web_app.py +++ b/apps/device-host-agent/tests/test_web_app.py @@ -1,9 +1,11 @@ from __future__ import annotations import re +from datetime import UTC, datetime from fastapi.testclient import TestClient +from cloud.internal_api.models import AssignmentModel from device.manager import DeviceManager from host_agent.config import HostAgentConfig from host_agent.history import ConsoleHistoryStore @@ -53,6 +55,7 @@ def _build_client(tmp_path, *, create_account: bool = True) -> tuple[TestClient, "local_account_store": local_account_store, "history_store": history_store, "session_manager": session_manager, + "status_tracker": status_tracker, } return client, context @@ -119,6 +122,52 @@ def test_login_with_correct_password_sets_cookie_and_dashboard_succeeds( assert "control.example" in response.text +def test_connected_device_without_running_task_shows_connected_not_busy( + tmp_path, +) -> None: + client, context = _build_client(tmp_path) + _login(client) + context["manager"].register_device( + "device-a", + lambda: object(), # type: ignore[arg-type,return-value] + status="busy", + ) + + dashboard_response = client.get("/") + api_response = client.get("/api/status") + + assert "connected" in dashboard_response.text + assert "busy" not in dashboard_response.text + assert api_response.json()["devices"][0]["status"] == "connected" + + +def test_device_running_current_assignment_still_shows_busy(tmp_path) -> None: + client, context = _build_client(tmp_path) + _login(client) + context["manager"].register_device( + "device-a", + lambda: object(), # type: ignore[arg-type,return-value] + status="busy", + ) + context["status_tracker"].mark_assignment_started( + AssignmentModel( + task_id="task-a", + attempt=1, + lease_id="lease-a", + lease_expires_at=datetime(2026, 7, 12, tzinfo=UTC), + host_id="host-a", + device_id="device-a", + goal="open settings", + ) + ) + + dashboard_response = client.get("/") + api_response = client.get("/api/status") + + assert "busy" in dashboard_response.text + assert api_response.json()["devices"][0]["status"] == "busy" + + def test_mutating_post_without_csrf_token_is_rejected_and_makes_no_change( tmp_path, ) -> None: