"""Swarm announce loop — this Navi's heartbeat to the hive registry.
The hive (see ``hive/``) is the swarm's address book: a tiny standalone service
on the main server. Each Navi announces itself (name, uuid, address, machine
info) every ANNOUNCE_INTERVAL_SEC seconds.
Design rules:
- The hive is NEVER a blocking dependency. Startup, agent turns and all
tools work with the hive down; the loop just keeps trying on its next tick.
- The Navi must KNOW about reachability, though: state transitions are logged
(up→down, down→up — not every tick), /health carries the status, and the
``hive_status`` context provider tells the agent when the registry is down
so it can act (this system is supposed to fix its own network).
"""
from __future__ import annotations
import asyncio
import hmac
import os
import platform
import socket
from datetime import datetime, timezone
from pathlib import Path
import httpx
import structlog
from navi import __version__
from navi.identity import InstanceIdentity
log = structlog.get_logger()
def read_swarm_key(path: str | Path) -> str | None:
"""The swarm PSK from a key file, or None if the file is missing/empty.
A ``.swarm-key.previous`` file next to the main key is NOT read here — the
rotation window applies to the receiving side (hive/peer endpoints accept
both); the announcer always sends the current key.
"""
try:
value = Path(path).read_text(encoding="utf-8").strip()
except OSError:
return None
return value or None
def verify_swarm_key(provided: str | None) -> bool:
"""Receiving-side check of an X-Swarm-Key header.
Accepts the current key AND the ``.previous`` one (rotation window — the
sender may still be on the old key while it propagates). Constant-time.
"""
from navi.config import settings
if not provided:
return False
current = read_swarm_key(settings.swarm_key_file)
previous = read_swarm_key(Path(settings.swarm_key_file).with_name(
Path(settings.swarm_key_file).name + ".previous"
))
return any(k and hmac.compare_digest(k, provided) for k in (current, previous))
def _machine_meta() -> dict:
"""Basic machine facts for the address book (the mini knowledge base)."""
meta: dict = {
"hostname": socket.gethostname(),
"os": f"{platform.system()} {platform.release()}",
"cpu_cores": os.cpu_count(),
}
try: # Linux-only, cheap; absent fields are fine
with open("/proc/meminfo") as f:
for line in f:
if line.startswith("MemTotal:"):
meta["ram_mb"] = round(int(line.split()[1]) / 1024)
break
except OSError:
pass
return meta
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
class HiveAnnouncer:
"""Periodic announce loop with reachable/unreachable state tracking."""
def __init__(
self,
hive_url: str,
key: str,
identity: InstanceIdentity,
port: int,
interval: int = 120,
client: httpx.AsyncClient | None = None,
) -> None:
self._hive_url = hive_url.rstrip("/")
self._key = key
self._identity = identity
self._port = port
self._interval = interval
self._client = client # injectable for tests; created lazily otherwise
# state
self._reachable: bool | None = None # None = never announced yet
self._last_success: str | None = None
self._last_error: str | None = None
self._down_since: str | None = None
self._consecutive_failures = 0
@property
def payload(self) -> dict:
return {
"name": self._identity.name,
"instance_id": self._identity.instance_id,
"version": __version__,
"port": self._port,
"meta": _machine_meta(),
}
async def announce_once(self) -> bool:
"""One announce attempt; updates and logs state transitions."""
client = self._client or httpx.AsyncClient(timeout=5.0)
try:
resp = await client.post(
f"{self._hive_url}/announce",
json=self.payload,
headers={"X-Swarm-Key": self._key},
)
resp.raise_for_status()
except Exception as e:
self._consecutive_failures += 1
self._last_error = f"{type(e).__name__}: {e}"
if self._reachable is not False:
self._down_since = _now_iso()
log.warning(
"hive.announce_failed",
hive=self._hive_url,
error=self._last_error,
hint="registry unreachable - other agents cannot discover this navi",
)
self._reachable = False
return False
finally:
if self._client is None:
await client.aclose()
if self._reachable is not True:
log.info("hive.announce_recovered" if self._reachable is False else "hive.announce_ok", hive=self._hive_url)
self._reachable = True
self._last_success = _now_iso()
self._last_error = None
self._down_since = None
self._consecutive_failures = 0
return True
async def run(self) -> None:
"""Loop forever; the first announce fires immediately at startup."""
log.info("hive.announce_started", hive=self._hive_url, interval=self._interval)
while True:
try:
await self.announce_once()
except asyncio.CancelledError:
raise
except Exception: # never die — the loop outlives any single error
log.exception("hive.announce_loop_error")
await asyncio.sleep(self._interval)
def get_status(self) -> dict:
return {
"configured": True,
"url": self._hive_url,
"reachable": self._reachable,
"last_success": self._last_success,
"last_error": self._last_error,
"down_since": self._down_since,
"consecutive_failures": self._consecutive_failures,
}
# ── process-wide instance (lifespan creates it; provider/health read it) ──
_announcer: HiveAnnouncer | None = None
def set_announcer(announcer: HiveAnnouncer | None) -> None:
global _announcer
_announcer = announcer
def get_announcer() -> HiveAnnouncer | None:
return _announcer
def build_announcer_from_settings() -> HiveAnnouncer | None:
"""Assemble the announcer from config; None (with a warning) if misconfigured."""
from navi.config import settings
from navi.identity import ensure_identity
key = read_swarm_key(settings.swarm_key_file)
if not key:
log.warning(
"hive.key_missing",
key_file=settings.swarm_key_file,
hint="HIVE_URL is set but the swarm key file is absent - announce disabled",
)
return None
return HiveAnnouncer(
hive_url=settings.hive_url,
key=key,
identity=ensure_identity(settings.instance_file),
port=settings.navi_port,
interval=settings.announce_interval_sec,
)