Tests / Test failed: 2, passed: 691
Adds an opt-in dependency supervisor inside the Host Agent that probes,
spawns, and restarts the two local processes the macOS single-machine
real-device workflow depends on: the Appium server (gates Driver.connect())
and the local Runtime API (local inspection). Default-off; gated by
HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED plus per-dependency *_SUPERVISED
flags.
Mitigates the live-incident failure mode where forgetting to start Appium
silently keeps devices offline and tasks queued forever with no error
surfaced in Host Agent logs.
Behavior (per openspec change):
- Adopt-don't-fight: probe (TCP + dependency-specific HTTP health check)
before spawn. Healthy listener → adopted (never killed/restarted).
Unhealthy listener → port-conflict error, skip. No listener → spawn.
- Only supervisor-spawned processes are restarted on crash, with capped
exponential backoff (1s/2s/4s/8s, capped at 30s) and a per-process-lifetime
attempt ceiling (HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS, default 5).
- Spawn failures (e.g. missing executable) logged distinctly from crashes.
- Graceful stop terminates only spawned children; adopted processes untouched.
- Supervisor starts before the heartbeat loop's first connect_devices() pass
and stops alongside existing heartbeat/console teardown.
Validation: ruff check + format clean, compileall clean, openspec validate
--strict valid. Non-integration suite 503 passed / 44 deselected / 2 failed
(both failures pre-existing from unrelated 03c7c30 LLM_PROVIDER_ENC_KEY;
verified by stashing this change). macOS real-device manual verification
(task 6.4) deferred to a macOS host.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
282 lines
11 KiB
Python
282 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from contextlib import suppress
|
|
from dataclasses import dataclass
|
|
|
|
import uvicorn
|
|
|
|
from cloud.internal_api.models import AssignmentModel
|
|
from device.manager import DeviceManager
|
|
from host_agent.assignment import AssignmentExecutor
|
|
from host_agent.client import HostAgentClient, HostAgentEnrollmentClient
|
|
from host_agent.config import HostAgentConfig, load_host_agent_config
|
|
from host_agent.dependency_supervisor import DependencySupervisor
|
|
from host_agent.devices import register_local_device
|
|
from host_agent.enrollment import resolve_host_identity
|
|
from host_agent.execution import create_execution_factories
|
|
from host_agent.heartbeat import HeartbeatSynchronizer
|
|
from host_agent.history import ConsoleHistoryStore
|
|
from host_agent.identity import HostIdentityStore
|
|
from host_agent.lease import ActiveAssignmentRunner
|
|
from host_agent.local_account import LocalAccountStore
|
|
from host_agent.policy_cache import HostPolicyCacheStore
|
|
from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor
|
|
from host_agent.status import AgentStatusTracker
|
|
from host_agent.web.app import create_console_app
|
|
from host_agent.web.auth import SessionManager
|
|
from storage.device_config import DeviceConfigStore
|
|
|
|
|
|
@dataclass
|
|
class HostAgentApplication:
|
|
client: HostAgentClient
|
|
heartbeat: HeartbeatSynchronizer
|
|
processor: AssignmentProcessor
|
|
console_server: uvicorn.Server | None = None
|
|
console_enrollment_client: HostAgentEnrollmentClient | None = None
|
|
dependency_supervisor: DependencySupervisor | None = None
|
|
|
|
def run(self) -> None:
|
|
asyncio.run(self.run_async())
|
|
|
|
async def run_async(self, stop: asyncio.Event | None = None) -> None:
|
|
stop_requested = stop or asyncio.Event()
|
|
supervisor_stop = asyncio.Event()
|
|
supervisor_task: asyncio.Task[None] | None = None
|
|
if self.dependency_supervisor is not None:
|
|
# Bring up supervised dependencies (probe + spawn/adopt + readiness
|
|
# wait) before the heartbeat loop's first connect_devices() pass,
|
|
# so a supervised Appium is ready before any Driver.connect().
|
|
await self.dependency_supervisor.start()
|
|
supervisor_task = asyncio.create_task(
|
|
self.dependency_supervisor.run(supervisor_stop)
|
|
)
|
|
heartbeat_stop = asyncio.Event()
|
|
heartbeat_task = asyncio.create_task(self.heartbeat.run(heartbeat_stop))
|
|
console_task = (
|
|
asyncio.create_task(self.console_server.serve())
|
|
if self.console_server is not None
|
|
else None
|
|
)
|
|
active_processing: asyncio.Task[AssignmentProcessingResult] | None = None
|
|
try:
|
|
while not stop_requested.is_set():
|
|
assignment = await self._claim_until_stopped(stop_requested)
|
|
if assignment is None:
|
|
continue
|
|
active_processing = asyncio.create_task(
|
|
self.processor.process(assignment)
|
|
)
|
|
stopped = asyncio.create_task(stop_requested.wait())
|
|
done, _ = await asyncio.wait(
|
|
{active_processing, stopped},
|
|
return_when=asyncio.FIRST_COMPLETED,
|
|
)
|
|
if stopped in done:
|
|
self.processor.request_stop()
|
|
else:
|
|
stopped.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await stopped
|
|
await asyncio.shield(active_processing)
|
|
active_processing = None
|
|
finally:
|
|
self.processor.request_stop()
|
|
if active_processing is not None:
|
|
with suppress(Exception):
|
|
await asyncio.shield(active_processing)
|
|
heartbeat_stop.set()
|
|
supervisor_stop.set()
|
|
if self.console_server is not None:
|
|
self.console_server.should_exit = True
|
|
try:
|
|
await asyncio.gather(heartbeat_task, return_exceptions=True)
|
|
with suppress(Exception):
|
|
await self.heartbeat.sync_once()
|
|
if supervisor_task is not None:
|
|
await asyncio.gather(supervisor_task, return_exceptions=True)
|
|
with suppress(Exception):
|
|
await self.dependency_supervisor.stop()
|
|
if console_task is not None:
|
|
with suppress(asyncio.CancelledError):
|
|
await asyncio.gather(console_task, return_exceptions=True)
|
|
finally:
|
|
if self.console_enrollment_client is not None:
|
|
self.console_enrollment_client.close()
|
|
await self.client.aclose()
|
|
|
|
async def _claim_until_stopped(
|
|
self,
|
|
stop: asyncio.Event,
|
|
) -> AssignmentModel | None:
|
|
claim = asyncio.create_task(self.client.claim())
|
|
stopped = asyncio.create_task(stop.wait())
|
|
try:
|
|
done, _ = await asyncio.wait(
|
|
{claim, stopped},
|
|
return_when=asyncio.FIRST_COMPLETED,
|
|
)
|
|
except asyncio.CancelledError:
|
|
claim.cancel()
|
|
stopped.cancel()
|
|
await asyncio.gather(claim, stopped, return_exceptions=True)
|
|
raise
|
|
if stopped in done:
|
|
claim.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await claim
|
|
return None
|
|
stopped.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await stopped
|
|
return await claim
|
|
|
|
|
|
class _EmbeddedConsoleServer(uvicorn.Server):
|
|
def install_signal_handlers(self) -> None:
|
|
"""Host Agent owns process-level signal handling."""
|
|
|
|
|
|
def create_application(
|
|
*,
|
|
config: HostAgentConfig | None = None,
|
|
manager: DeviceManager | None = None,
|
|
device_config_store: DeviceConfigStore | None = None,
|
|
identity_store: HostIdentityStore | None = None,
|
|
enrollment_client: HostAgentEnrollmentClient | None = None,
|
|
) -> HostAgentApplication:
|
|
startup_config = config or load_host_agent_config()
|
|
config_store = device_config_store or DeviceConfigStore()
|
|
resolved_identity_store = identity_store or HostIdentityStore(
|
|
startup_config.identity_path
|
|
)
|
|
owned_enrollment_client = enrollment_client is None
|
|
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(startup_config)
|
|
try:
|
|
resolved_config = resolve_host_identity(
|
|
startup_config,
|
|
identity_store=resolved_identity_store,
|
|
client=bootstrap_client,
|
|
)
|
|
bootstrap_client.config = resolved_config
|
|
resolved_manager = manager or _configured_device_manager(
|
|
config_store,
|
|
config=resolved_config,
|
|
enrollment_client=bootstrap_client,
|
|
)
|
|
finally:
|
|
if owned_enrollment_client:
|
|
bootstrap_client.close()
|
|
client = HostAgentClient(resolved_config)
|
|
|
|
history_store = ConsoleHistoryStore(
|
|
resolved_config.identity_path.parent / "host_console_history.sqlite3",
|
|
limit=resolved_config.console_history_limit,
|
|
)
|
|
status_tracker = AgentStatusTracker()
|
|
console_enrollment_client: HostAgentEnrollmentClient | None = None
|
|
if resolved_config.enrollment_managed:
|
|
console_enrollment_client = HostAgentEnrollmentClient(resolved_config)
|
|
console_app = create_console_app(
|
|
config=resolved_config,
|
|
manager=resolved_manager,
|
|
config_store=config_store,
|
|
local_account_store=LocalAccountStore(resolved_config.local_account_path),
|
|
identity_store=resolved_identity_store,
|
|
history_store=history_store,
|
|
status_tracker=status_tracker,
|
|
session_manager=SessionManager(
|
|
ttl_seconds=resolved_config.console_session_ttl_seconds
|
|
),
|
|
enrollment_client=console_enrollment_client,
|
|
)
|
|
console_server = _EmbeddedConsoleServer(
|
|
uvicorn.Config(
|
|
console_app,
|
|
host=resolved_config.console_bind_host,
|
|
port=resolved_config.console_port,
|
|
log_level="warning",
|
|
)
|
|
)
|
|
|
|
heartbeat = HeartbeatSynchronizer(
|
|
resolved_manager,
|
|
client,
|
|
resolved_config,
|
|
status_tracker=status_tracker,
|
|
on_sync=lambda device_count: history_store.record_heartbeat(
|
|
device_count=device_count
|
|
),
|
|
policy_cache=HostPolicyCacheStore(
|
|
resolved_config.identity_path.parent / "host_governance_policy.json"
|
|
),
|
|
on_policy_sync=lambda revision: history_store.record_policy_sync(
|
|
revision=revision
|
|
),
|
|
)
|
|
executor = AssignmentExecutor(
|
|
create_execution_factories(
|
|
resolved_manager,
|
|
host_agent_config=resolved_config,
|
|
)
|
|
)
|
|
active_runner = ActiveAssignmentRunner(client, executor)
|
|
processor = AssignmentProcessor(
|
|
client,
|
|
active_runner,
|
|
status_tracker=status_tracker,
|
|
on_result=lambda assignment, result: _record_assignment_history(
|
|
history_store, assignment, result
|
|
),
|
|
)
|
|
dependency_supervisor: DependencySupervisor | None = None
|
|
if resolved_config.dependency_supervisor_enabled:
|
|
dependency_supervisor = DependencySupervisor.from_host_agent_config(
|
|
resolved_config
|
|
)
|
|
return HostAgentApplication(
|
|
client=client,
|
|
heartbeat=heartbeat,
|
|
processor=processor,
|
|
console_server=console_server,
|
|
console_enrollment_client=console_enrollment_client,
|
|
dependency_supervisor=dependency_supervisor,
|
|
)
|
|
|
|
|
|
def _record_assignment_history(
|
|
history_store: ConsoleHistoryStore,
|
|
assignment: AssignmentModel,
|
|
result: AssignmentProcessingResult,
|
|
) -> None:
|
|
status = "done" if result.execution.status == "done" else "failed"
|
|
history_store.record_assignment(
|
|
task_id=assignment.task_id,
|
|
attempt=assignment.attempt,
|
|
status=status,
|
|
failure_reason=result.execution.failure_reason if status == "failed" else None,
|
|
device_id=assignment.device_id,
|
|
)
|
|
|
|
|
|
def _configured_device_manager(
|
|
config_store: DeviceConfigStore,
|
|
*,
|
|
config: HostAgentConfig,
|
|
enrollment_client: HostAgentEnrollmentClient,
|
|
) -> DeviceManager:
|
|
manager = DeviceManager()
|
|
for device in config_store.list():
|
|
register_local_device(
|
|
config_store,
|
|
manager,
|
|
device_id=device["device_id"],
|
|
driver_type=device["driver_type"],
|
|
connection_info=device["connection_info"],
|
|
name=device["name"],
|
|
config=config,
|
|
enrollment_client=enrollment_client,
|
|
)
|
|
return manager
|