129 lines
4.8 KiB
Python
129 lines
4.8 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import TYPE_CHECKING
|
|
|
|
from cloud.internal_api.models import DeviceSnapshotModel, HeartbeatResponse
|
|
from core.errors import DeviceRuntimeError
|
|
from device.manager import DeviceManager
|
|
from host_agent.client import HostAgentClient
|
|
from host_agent.config import HostAgentConfig
|
|
from host_agent.policy_cache import HostPolicyCacheStore
|
|
from host_agent.status import AgentStatusTracker
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Awaitable, Callable
|
|
|
|
from host_agent.mcp_lock import McpBusyTracker
|
|
|
|
|
|
def build_device_snapshot(manager: DeviceManager) -> list[DeviceSnapshotModel]:
|
|
return [
|
|
DeviceSnapshotModel(
|
|
device_id=device.id,
|
|
driver_type=device.driver_type,
|
|
status="idle" if device.status == "busy" else device.status,
|
|
capability_tags=list(device.capability_tags),
|
|
)
|
|
for device in sorted(manager.list_devices(), key=lambda item: item.id)
|
|
]
|
|
|
|
|
|
class HeartbeatSynchronizer:
|
|
def __init__(
|
|
self,
|
|
manager: DeviceManager,
|
|
client: HostAgentClient,
|
|
config: HostAgentConfig,
|
|
*,
|
|
address: str | None = None,
|
|
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
status_tracker: AgentStatusTracker | None = None,
|
|
on_sync: Callable[[int], None] | None = None,
|
|
policy_cache: HostPolicyCacheStore | None = None,
|
|
on_policy_sync: Callable[[int], None] | None = None,
|
|
mcp_busy_tracker: McpBusyTracker | None = None,
|
|
) -> None:
|
|
self.manager = manager
|
|
self.client = client
|
|
self.config = config
|
|
self.address = address
|
|
self._sleep = sleep
|
|
self.status_tracker = status_tracker
|
|
self.on_sync = on_sync
|
|
self.policy_cache = policy_cache
|
|
self.on_policy_sync = on_policy_sync
|
|
self.mcp_busy_tracker = mcp_busy_tracker
|
|
self.policy = policy_cache.load() if policy_cache is not None else None
|
|
self.policy_revision = self.policy.revision if self.policy is not None else 0
|
|
if self.status_tracker is not None:
|
|
self.status_tracker.mark_host_policy(self.policy)
|
|
|
|
async def sync_once(self) -> HeartbeatResponse:
|
|
self.probe_connected_devices()
|
|
self.connect_devices()
|
|
snapshot = build_device_snapshot(self.manager)
|
|
mcp_busy_ids = (
|
|
self.mcp_busy_tracker.busy_device_ids()
|
|
if self.mcp_busy_tracker is not None
|
|
else []
|
|
)
|
|
response = await self.client.heartbeat(
|
|
snapshot,
|
|
address=self.address,
|
|
policy_revision=self.policy_revision,
|
|
mcp_busy_device_ids=mcp_busy_ids,
|
|
)
|
|
self.policy_revision = response.policy_revision
|
|
if response.policy is not None:
|
|
self.policy = response.policy
|
|
if self.policy_cache is not None:
|
|
self.policy_cache.save(response.policy)
|
|
if self.on_policy_sync is not None:
|
|
self.on_policy_sync(response.policy.revision)
|
|
elif response.policy_revision == 0:
|
|
self.policy = None
|
|
if self.policy_cache is not None:
|
|
self.policy_cache.clear()
|
|
if self.status_tracker is not None:
|
|
self.status_tracker.mark_host_policy(self.policy)
|
|
if self.status_tracker is not None:
|
|
self.status_tracker.mark_heartbeat(ok=True, device_count=len(snapshot))
|
|
if self.on_sync is not None:
|
|
try:
|
|
self.on_sync(len(snapshot))
|
|
except Exception:
|
|
pass
|
|
return response
|
|
|
|
async def run(self, stop: asyncio.Event) -> None:
|
|
self.connect_devices()
|
|
while not stop.is_set():
|
|
await self.sync_once()
|
|
try:
|
|
await asyncio.wait_for(
|
|
stop.wait(),
|
|
timeout=self.config.heartbeat_interval_seconds,
|
|
)
|
|
except TimeoutError:
|
|
continue
|
|
|
|
def connect_devices(self) -> None:
|
|
for device in self.manager.list_devices():
|
|
if device.status not in {"idle", "offline", "error"}:
|
|
continue
|
|
try:
|
|
self.manager.connect(device.id)
|
|
except DeviceRuntimeError:
|
|
continue
|
|
|
|
def probe_connected_devices(self) -> None:
|
|
active_id: str | None = None
|
|
if self.status_tracker is not None:
|
|
current = self.status_tracker.snapshot().get("current_assignment")
|
|
if isinstance(current, dict) and isinstance(current.get("device_id"), str):
|
|
active_id = current["device_id"]
|
|
for device in self.manager.list_devices():
|
|
if device.id != active_id and device.status == "busy":
|
|
self.manager.probe(device.id)
|