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
@@ -0,0 +1,438 @@
"""Optional supervisor for the two local external processes the Host Agent
depends on for the macOS single-machine real-device workflow: the Appium
server (which gates real ``Driver.connect()``) and the local Runtime API
(used for local inspection).
Lives in ``host_agent`` because it spawns and monitors host-level processes
alongside the heartbeat/claim loop. Off by default; see ``HostAgentConfig``.
Design (``openspec/changes/host-agent-dependency-supervisor/design.md``):
- Default off. Each dependency also has its own opt-in flag.
- Before spawn: TCP connect + dependency-specific HTTP health probe. A healthy
listener is *adopted* (never killed or restarted). An unhealthy listener is
a port conflict — logged and skipped. No listener → spawn.
- Only supervisor-spawned processes are restarted on unexpected exit, with
capped exponential backoff and a per-process-lifetime attempt ceiling.
- Adopted processes are never touched by ``stop()``.
- Spawns use stdlib ``subprocess.Popen`` (no new dependency). Child stdout/stderr
is forwarded into this module's logger, tagged per dependency.
"""
from __future__ import annotations
import asyncio
import logging
import socket
import subprocess
import threading
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING
import httpx
if TYPE_CHECKING:
from host_agent.config import HostAgentConfig
_LOGGER = logging.getLogger("host_agent.dependency_supervisor")
_PROBE_TCP_TIMEOUT_SECONDS = 1.0
_PROBE_HTTP_TIMEOUT_SECONDS = 2.0
class ProbeResult(str, Enum):
"""Outcome of probing a dependency's configured address."""
HEALTHY = "healthy"
UNHEALTHY_LISTENER = "unhealthy_listener"
NO_LISTENER = "no_listener"
def probe_appium(host: str, port: int) -> ProbeResult:
"""Probe Appium at ``host:port``. Healthy iff ``GET /status`` returns 200
with a JSON body (the documented shape per ``docs/MACOS_IPHONE_SETUP.md``
§6 — contains ``ready``/build info)."""
return _probe_http(host, port, path="/status")
def probe_runtime(host: str, port: int) -> ProbeResult:
"""Probe the local Runtime API at ``host:port``. Healthy iff ``GET /devices``
returns 200 with a JSON body.
``api.rest.create_app`` does not expose a dedicated ``/health`` endpoint;
``/devices`` is the stable read-only GET that proves the FastAPI app is
mounted and the device manager is reachable. Per design.md Decision 2 this
is the "equivalent existing endpoint" used for the readiness check.
"""
return _probe_http(host, port, path="/devices")
def _probe_http(host: str, port: int, *, path: str) -> ProbeResult:
# Step 1: plain TCP connect — distinguish "nothing listening" (→ spawn)
# from "something is there but wrong" (→ port conflict, skip).
try:
with socket.create_connection((host, port), timeout=_PROBE_TCP_TIMEOUT_SECONDS):
pass
except OSError:
return ProbeResult.NO_LISTENER
# Step 2: dependency-specific HTTP health check.
try:
response = httpx.get(
f"http://{host}:{port}{path}",
timeout=_PROBE_HTTP_TIMEOUT_SECONDS,
)
except httpx.HTTPError:
return ProbeResult.UNHEALTHY_LISTENER
if response.status_code != 200:
return ProbeResult.UNHEALTHY_LISTENER
try:
response.json()
except ValueError:
return ProbeResult.UNHEALTHY_LISTENER
return ProbeResult.HEALTHY
def appium_argv_factory(host: str, port: int) -> list[str]:
return ["appium", "--address", host, "--port", str(port)]
def runtime_argv_factory(host: str, port: int) -> list[str]:
return [
"uvicorn",
"api.rest:create_app",
"--factory",
"--host",
host,
"--port",
str(port),
]
@dataclass
class SupervisedDependency:
"""Config + mutable runtime state for one supervised external process."""
name: str
host: str
port: int
argv_factory: Callable[[str, int], Sequence[str]]
probe: Callable[[str, int], ProbeResult]
# Mutable state:
process: subprocess.Popen | None = None
adopted: bool = False
given_up: bool = False
restart_attempts: int = 0
ready: bool = False
@dataclass
class _SupervisorKnobs:
"""Tunables exposed for tests; production uses the defaults."""
startup_timeout_seconds: float = 30.0
readiness_poll_interval_seconds: float = 0.5
crash_poll_interval_seconds: float = 1.0
initial_backoff_seconds: float = 1.0
max_backoff_seconds: float = 30.0
terminate_grace_period_seconds: float = 5.0
class DependencySupervisor:
"""Manage zero or more supervised local dependencies.
Each dependency is either *adopted* (an existing healthy instance was
detected — never killed or restarted) or *spawned* (a child ``Popen``
handle the supervisor owns — restart-on-crash with capped exponential
backoff up to ``max_attempts`` per process lifetime).
"""
def __init__(
self,
dependencies: list[SupervisedDependency],
*,
max_attempts: int,
knobs: _SupervisorKnobs | None = None,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
popen_factory: Callable[[Sequence[str]], subprocess.Popen] = subprocess.Popen,
logger: logging.Logger = _LOGGER,
) -> None:
self._dependencies = dependencies
self._max_attempts = max_attempts
self._knobs = knobs or _SupervisorKnobs()
self._sleep = sleep
self._popen_factory = popen_factory
self._logger = logger
self._reader_threads: list[threading.Thread] = []
@classmethod
def from_host_agent_config(
cls,
ha_config: HostAgentConfig,
**kwargs: object,
) -> DependencySupervisor:
"""Build a supervisor reflecting ``HostAgentConfig`` flags.
Caller is responsible for only invoking this when
``dependency_supervisor_enabled`` is true; the resulting supervisor
will contain only the dependencies whose individual ``*_supervised``
flag is also true (possibly an empty list).
"""
deps: list[SupervisedDependency] = []
if ha_config.appium_supervised:
deps.append(
SupervisedDependency(
name="appium",
host=ha_config.appium_host,
port=ha_config.appium_port,
argv_factory=appium_argv_factory,
probe=probe_appium,
)
)
if ha_config.runtime_supervised:
deps.append(
SupervisedDependency(
name="runtime",
host=ha_config.runtime_host,
port=ha_config.runtime_port,
argv_factory=runtime_argv_factory,
probe=probe_runtime,
)
)
return cls(
deps,
max_attempts=ha_config.dependency_restart_max_attempts,
**kwargs, # type: ignore[arg-type]
)
@property
def dependencies(self) -> list[SupervisedDependency]:
return list(self._dependencies)
async def start(self) -> None:
"""Probe + spawn/adopt + readiness wait.
Call before the heartbeat loop's first ``connect_devices()`` pass so
a supervised Appium is up before any ``Driver.connect()`` attempt.
"""
for dep in self._dependencies:
await self._start_one(dep)
async def _start_one(self, dep: SupervisedDependency) -> None:
result = await asyncio.to_thread(dep.probe, dep.host, dep.port)
if result is ProbeResult.HEALTHY:
dep.adopted = True
dep.ready = True
self._logger.info(
"dependency-supervisor: %s adopted existing instance at %s:%s",
dep.name,
dep.host,
dep.port,
)
return
if result is ProbeResult.UNHEALTHY_LISTENER:
self._logger.error(
"dependency-supervisor: %s port %s occupied by an unhealthy "
"listener; leaving un-started to avoid a port conflict",
dep.name,
dep.port,
)
dep.given_up = True
return
await self._spawn_with_readiness_wait(dep)
async def _spawn_with_readiness_wait(
self,
dep: SupervisedDependency,
) -> bool:
"""Spawn ``dep`` and wait for it to become healthy.
Returns True on readiness, False otherwise. On spawn failure (missing
executable) or readiness timeout while the process is still running,
marks the dependency as ``given_up`` (no crash-restart loop entered).
If the process exits during startup, returns False without giving up
— the crash-restart loop in ``run()`` handles subsequent restarts.
"""
argv = list(dep.argv_factory(dep.host, dep.port))
try:
proc = self._popen_factory(
argv,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1,
text=True,
)
except FileNotFoundError:
self._logger.error(
"dependency-supervisor: %s spawn failed — executable not "
"found on PATH (tried: %s)",
dep.name,
argv[0],
)
dep.given_up = True
return False
except OSError as exc:
self._logger.error(
"dependency-supervisor: %s spawn failed: %s",
dep.name,
exc,
)
dep.given_up = True
return False
dep.process = proc
dep.adopted = False
dep.ready = False
self._start_reader_thread(dep, proc)
self._logger.info(
"dependency-supervisor: %s spawned (pid %s) at %s:%s",
dep.name,
proc.pid,
dep.host,
dep.port,
)
loop = asyncio.get_running_loop()
deadline = loop.time() + self._knobs.startup_timeout_seconds
while True:
if proc.poll() is not None:
self._logger.error(
"dependency-supervisor: %s exited during startup with code %s",
dep.name,
proc.returncode,
)
dep.ready = False
return False
probe_result = await asyncio.to_thread(dep.probe, dep.host, dep.port)
if probe_result is ProbeResult.HEALTHY:
dep.ready = True
self._logger.info(
"dependency-supervisor: %s ready at %s:%s",
dep.name,
dep.host,
dep.port,
)
return True
if loop.time() >= deadline:
self._logger.error(
"dependency-supervisor: %s started but did not become "
"healthy within %ss; process still running, not entering "
"crash-restart loop",
dep.name,
self._knobs.startup_timeout_seconds,
)
dep.ready = False
return False
await self._sleep(self._knobs.readiness_poll_interval_seconds)
def _start_reader_thread(
self,
dep: SupervisedDependency,
proc: subprocess.Popen,
) -> None:
def reader_loop() -> None:
stdout = proc.stdout
if stdout is None:
return
for raw_line in stdout:
line = raw_line.rstrip()
if line:
self._logger.info("%s: %s", dep.name, line)
thread = threading.Thread(
target=reader_loop,
name=f"dep-sup-{dep.name}",
daemon=True,
)
thread.start()
self._reader_threads.append(thread)
async def run(self, stop: asyncio.Event) -> None:
"""Background monitor: detect crashes and apply backoff restart.
Returns when ``stop`` is set. Crashes are only detected for
spawned processes (not adopted ones).
"""
if not self._dependencies:
await stop.wait()
return
while not stop.is_set():
for dep in self._dependencies:
await self._check_one(dep)
try:
await asyncio.wait_for(
stop.wait(),
timeout=self._knobs.crash_poll_interval_seconds,
)
except TimeoutError:
continue
async def _check_one(self, dep: SupervisedDependency) -> None:
if dep.given_up or dep.adopted or dep.process is None:
return
if dep.process.poll() is None:
return
self._logger.warning(
"dependency-supervisor: %s exited unexpectedly with code %s",
dep.name,
dep.process.returncode,
)
dep.process = None
dep.ready = False
dep.restart_attempts += 1
if dep.restart_attempts > self._max_attempts:
self._logger.error(
"dependency-supervisor: %s restart attempts exhausted "
"(crashes: %s, limit: %s); giving up for the rest of this "
"process lifetime",
dep.name,
dep.restart_attempts,
self._max_attempts,
)
dep.given_up = True
return
backoff = min(
self._knobs.initial_backoff_seconds * (2 ** (dep.restart_attempts - 1)),
self._knobs.max_backoff_seconds,
)
self._logger.info(
"dependency-supervisor: %s restarting in %.1fs (crash %s/%s)",
dep.name,
backoff,
dep.restart_attempts,
self._max_attempts,
)
await self._sleep(backoff)
await self._spawn_with_readiness_wait(dep)
async def stop(self) -> None:
"""Terminate spawned children. Adopted processes are left untouched."""
for dep in self._dependencies:
if dep.adopted or dep.process is None:
continue
proc = dep.process
self._logger.info(
"dependency-supervisor: stopping %s (pid %s)",
dep.name,
proc.pid,
)
proc.terminate()
try:
await asyncio.to_thread(
proc.wait, self._knobs.terminate_grace_period_seconds
)
except subprocess.TimeoutExpired:
self._logger.warning(
"dependency-supervisor: %s did not exit within %ss, killing",
dep.name,
self._knobs.terminate_grace_period_seconds,
)
proc.kill()
await asyncio.to_thread(proc.wait)
except OSError:
pass
finally:
dep.process = None