feat(host-agent): add optional Appium/Runtime supervisor
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>
This commit is contained in:
2026-07-14 09:28:57 +08:00
co-authored by Claude Opus 4.6
parent 03c7c30067
commit 75879c8a52
12 changed files with 1475 additions and 0 deletions
+23
View File
@@ -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,
)