fix(host-agent): distinguish connected devices from busy-with-task in console
Tests / Test failed: 2, passed: 693
Tests / Test failed: 2, passed: 693
DeviceManager marks a device "busy" as soon as an Appium/WDA session is connected, which is unrelated to whether a task is currently executing on it. The Host Agent's local console displayed this raw status, making connected-but-idle devices look permanently busy. Cross-reference the device id against AgentStatusTracker's current_assignment (already tracked via mark_assignment_started/finished) to show "connected" unless a task is actually running on that device.
This commit is contained in:
@@ -108,6 +108,17 @@ def _login_page(
|
|||||||
return HTMLResponse(_chrome("Login", body, session=None))
|
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(
|
def _dashboard_body(
|
||||||
*,
|
*,
|
||||||
identity: HostIdentityState | None,
|
identity: HostIdentityState | None,
|
||||||
@@ -118,6 +129,7 @@ def _dashboard_body(
|
|||||||
heartbeat = snapshot.get("last_heartbeat")
|
heartbeat = snapshot.get("last_heartbeat")
|
||||||
assignment = snapshot.get("current_assignment")
|
assignment = snapshot.get("current_assignment")
|
||||||
policy = snapshot.get("host_policy")
|
policy = snapshot.get("host_policy")
|
||||||
|
busy_device_id = assignment["device_id"] if assignment else None
|
||||||
heartbeat_text = (
|
heartbeat_text = (
|
||||||
f"{'ok' if heartbeat['ok'] else 'failed'} at {heartbeat['at']} "
|
f"{'ok' if heartbeat['ok'] else 'failed'} at {heartbeat['at']} "
|
||||||
f"({heartbeat['device_count']} devices)"
|
f"({heartbeat['device_count']} devices)"
|
||||||
@@ -143,7 +155,8 @@ def _dashboard_body(
|
|||||||
)
|
)
|
||||||
device_rows = "".join(
|
device_rows = "".join(
|
||||||
f"<tr><td>{escape(device.id)}</td><td>{escape(device.name or '')}</td>"
|
f"<tr><td>{escape(device.id)}</td><td>{escape(device.name or '')}</td>"
|
||||||
f"<td>{escape(device.driver_type)}</td><td>{escape(device.status)}</td></tr>"
|
f"<td>{escape(device.driver_type)}</td>"
|
||||||
|
f"<td>{escape(_device_display_status(device, busy_device_id=busy_device_id))}</td></tr>"
|
||||||
for device in devices
|
for device in devices
|
||||||
)
|
)
|
||||||
return f"""
|
return f"""
|
||||||
@@ -403,12 +416,14 @@ def create_console_app(
|
|||||||
session: SessionState = Depends(require_session),
|
session: SessionState = Depends(require_session),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
snapshot = status_tracker.snapshot()
|
snapshot = status_tracker.snapshot()
|
||||||
|
current_assignment = snapshot.get("current_assignment")
|
||||||
|
busy_device_id = current_assignment["device_id"] if current_assignment else None
|
||||||
devices = [
|
devices = [
|
||||||
{
|
{
|
||||||
"id": device.id,
|
"id": device.id,
|
||||||
"name": device.name,
|
"name": device.name,
|
||||||
"driver_type": device.driver_type,
|
"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()
|
for device in manager.list_devices()
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from cloud.internal_api.models import AssignmentModel
|
||||||
from device.manager import DeviceManager
|
from device.manager import DeviceManager
|
||||||
from host_agent.config import HostAgentConfig
|
from host_agent.config import HostAgentConfig
|
||||||
from host_agent.history import ConsoleHistoryStore
|
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,
|
"local_account_store": local_account_store,
|
||||||
"history_store": history_store,
|
"history_store": history_store,
|
||||||
"session_manager": session_manager,
|
"session_manager": session_manager,
|
||||||
|
"status_tracker": status_tracker,
|
||||||
}
|
}
|
||||||
return client, context
|
return client, context
|
||||||
|
|
||||||
@@ -119,6 +122,52 @@ def test_login_with_correct_password_sets_cookie_and_dashboard_succeeds(
|
|||||||
assert "control.example" in response.text
|
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 "<td>busy</td>" 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 "<td>busy</td>" 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(
|
def test_mutating_post_without_csrf_token_is_rejected_and_makes_no_change(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user