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>
This commit is contained in:
@@ -11,6 +11,7 @@ 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
|
||||
@@ -34,12 +35,23 @@ class HostAgentApplication:
|
||||
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 = (
|
||||
@@ -75,12 +87,17 @@ class HostAgentApplication:
|
||||
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)
|
||||
@@ -213,12 +230,18 @@ def create_application(
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user