meshai/work/meshai/env/store.py
Matt Johnson d1f78a0836 fix(fires): wire FirePacer into the native fire broadcast path
Native fire adapters (env/fires.py source="nifc", env/firms.py
source="firms") emitted straight to the EventBus from
EnvironmentalStore._emit_event with no rate limiting of their own.
FirePacer was only ever attached to CentralConsumer (main.py), which
never runs in the actual production deployment (central.enabled=False,
all adapters feed_source=native) -- so the <=1/60s throttle + immediate
head-of-line behavior fixed for Central in #120 (issue #119) was
completely inert in production. A poll that produces several distinct
fires/clusters at once (a lightning outbreak, or several tracked fires
crossing a satellite-pass boundary together) would dump all of them on
the mesh back-to-back instead of at the intended cadence.

_emit_event() now routes fire-family Events (source in
{"nifc","firms"}, severity in {"priority","immediate"}) through an
attached FirePacer, mirroring the exact gate CentralConsumer._handle
applies. main.py attaches the same FirePacer instance to env_store
right after constructing it. "routine"-severity fire events, non-fire
native adapters, and the Central path are all unaffected; a paced event
cannot re-enter either gate (native vs Central are mutually exclusive
per feed_source), so nothing can be paced twice.

Added tests/test_native_fire_pacer.py covering: native fire events
route through the pacer, an immediate event jumps an already-queued
priority queue with nothing dropped, routine-severity fire events and
non-fire native events are NOT paced, and the no-pacer-attached
fallback is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 01:09:10 +00:00

1200 lines
60 KiB
Python

"""Environmental data store with tick-based adapter polling."""
import hashlib
import json
import logging
import time
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from ..config import EnvironmentalConfig
from ..notifications.pipeline import EventBus
logger = logging.getLogger(__name__)
# ── Received-delta key format ────────────────────────────────────────────────
# A single, shared key format used by BOTH the live emit path (_seen_key) and
# the durable startup pre-seed (_seed_from_persistent). Keeping the two on one
# helper guarantees they can never drift apart — a drift would silently defeat
# the pre-seed (keys wouldn't match), which is exactly the leak we are fixing.
_SEP = "\x1e"
def _key_ext(source: str, external_id) -> str:
"""Seen-key for an item identified by its upstream external_id."""
return f"{source}{_SEP}ext:{external_id}"
def _key_eid(source: str, event_id) -> str:
"""Seen-key for an item identified by the adapter's stable event_id."""
return f"{source}{_SEP}eid:{event_id}"
# ── FirePacer routing (native path) ──────────────────────────────────────────
# The exact Event.source strings the NATIVE fire adapters mint (verified
# against the adapter code, not assumed): env/fires.py's WFIGS incident
# adapter stamps source="nifc" (NOT "fires"/"wfigs" -- those are the Central
# NATS subject/handler names); env/firms.py's fusion broadcasts always stamp
# source="firms" regardless of fusion kind (growth/spotting/halt/cluster) --
# "firms_fusion" only ever appears as the raw internal event dict's bookkeeping
# key, never as the emitted Event.source. Mirrors the gate
# central/consumer.py:_handle applies to the Central path (issue #119) so a
# native and a Central deployment throttle identically; "routine" fire events
# (e.g. firms halt) are intentionally excluded, exactly as on the Central path.
_FIRE_PACER_SOURCES = frozenset({"nifc", "firms"})
_FIRE_PACER_SEVERITIES = frozenset({"priority", "immediate"})
class EnvironmentalStore:
"""Cache and tick-driver for all environmental feed adapters."""
def __init__(
self,
config: "EnvironmentalConfig",
region_anchors: list = None,
event_bus: Optional["EventBus"] = None,
coverage_bbox: list = None,
coverage_excluded: list = None,
coverage_areas: list = None,
generic_sources: list = None,
):
# Config-driven REST/GeoJSON sources (top-level config.generic_sources)
# for the universal GenericHttpAdapter. Plain list of dicts.
self._generic_sources = generic_sources or []
self._adapters = {} # name -> adapter instance
self._failed_adapters = {} # name -> last_error string
self._events = {} # (source, event_id) -> event dict
self._event_bus = event_bus # Pipeline EventBus for emission
# FirePacer, injected post-construction from main.py (mirrors
# central/consumer.py's self._pacer) once the pacer exists -- the
# store is built earlier in _init_components(), before start()
# creates the pacer. None until attached; _emit_event() falls back
# to emitting straight to the bus when unset (pre-existing behavior).
self._fire_pacer = None
self._swpc_status = {} # Kp/SFI/scales snapshot
self._ducting_status = {} # tropo ducting assessment
self._mesh_zones = config.nws_zones or []
self._region_anchors = region_anchors or []
self._coverage_bbox = coverage_bbox or []
self._coverage_excluded = set(coverage_excluded or [])
# ── Fire-ingest coverage-scope gate ──────────────────────────────
# The exact per-area MonitoringArea union the dispatch-level
# CoverageFilter gates on (areas_from_config(config.coverage) — the
# SAME set-union Shapely membership test). Built ONCE here and reused
# per-fire in _ingest_fires so an out-of-area fire is never STORED
# (thus never tracked / alerted / reminded / re-ingested), keeping
# ingest scope == dispatch scope. Fail-OPEN: an empty list (coverage
# disabled or no areas configured) means the gate is a no-op and every
# fire is stored, preserving current behaviour. `coverage_areas` is the
# raw config.coverage.areas dict-list; areas_from_config also honours
# the legacy single-`bbox` fallback via the `.bbox` attribute, so we
# wrap the raw list in a tiny shim exposing both fields.
self._fire_coverage_areas = []
try:
from meshai.coverage_area import areas_from_config
class _CoverageShim:
def __init__(self, areas, bbox):
self.areas = areas or []
self.bbox = bbox or []
# Only build a real gate when this adapter is NOT on the coverage
# opt-out list ("fires"), mirroring _coverage_for's escape hatch:
# an excluded fires adapter falls back to no coverage gating.
if "fires" not in self._coverage_excluded:
self._fire_coverage_areas = areas_from_config(
_CoverageShim(coverage_areas, self._coverage_bbox))
except Exception:
logger.exception(
"fire coverage-scope gate init failed; failing OPEN (no gate)")
self._fire_coverage_areas = []
# ── Received-delta gate (NATIVE-only) ────────────────────────────
# The model the operator demanded: a native adapter broadcasts an item
# ONLY when it was newly RECEIVED from the API this poll — never by
# scanning an accumulated backlog. Two layers enforce this:
#
# 1. DURABLE pre-seed (_seed_from_persistent, below). meshai already
# has a durable record of everything it has ever received: the
# persistent hazard tables. At startup we load the identifying
# keys of every already-received item into `self._seen`, so nothing
# ever received can re-broadcast — immune to fetch staging AND to
# restarts. Keyed by the event's `source` (NOT adapter name) so the
# seed and the live-emit key line up on the same value.
# 2. IN-MEMORY first-poll seed. The FIRST *non-empty* poll for a
# source records every current key as "seen" and emits NOTHING
# (that batch is pre-existing backlog); later polls emit only keys
# not already seen. Belt-and-suspenders: a source is marked
# "seeded" ONLY after a non-empty ingest, so an empty first poll
# (e.g. wzdx's registry-only tick) can never mark it seeded and
# then leak the real batch on the next tick.
#
# `self._seen` is keyed by SOURCE (evt["source"]), matching the durable
# pre-seed. Durable keys added at startup are never removed, so they
# suppress a backlog item even AFTER the source is marked seeded (this
# is what closes cross-tick, non-empty staging leaks).
self._seen: dict[str, set] = {} # source -> set of item keys
self._seeded: set[str] = set() # sources past their first non-empty poll
# Native WFIGS cold-start silent-seed gate (see _ingest_fires). The
# FIRST fires poll after boot treats every fire present as already-known
# (seed silently, no broadcast) so a fresh deploy never dumps the active-
# fire backlog to the mesh. Gated on this per-process flag rather than a
# wall-clock boot grace so it holds no matter how late the first
# successful WFIGS fetch lands (e.g. a failed first fetch pushes the
# first real poll ~10min out, well past any grace window). Flag flips
# only after a non-empty fires ingest; a fire that first appears on a
# LATER poll is a genuine ignition and broadcasts "New".
self._fires_seeded: bool = False
# Generic-source cold-start silent-seed gate (PER source name), mirror
# of _fires_seeded. The FIRST non-empty poll for a given generic source
# seeds every current item (records it seen + persists it) and emits
# NOTHING — that batch is pre-existing backlog. Items that first appear
# on a LATER poll broadcast. Keyed by the bare configured source name.
self._generic_seeded: set[str] = set()
# Create adapter instances with error isolation
self._register_adapter("nws", config.nws, ".nws", "NWSAlertsAdapter",
lambda cfg: (cfg, self._coverage_for("nws")))
self._register_adapter("swpc", config.swpc, ".swpc", "SWPCAdapter",
lambda cfg: (cfg,))
self._register_adapter("ducting", config.ducting, ".ducting", "DuctingAdapter",
lambda cfg: (cfg, self._coverage_for("ducting")))
self._register_adapter("nifc", config.fires, ".fires", "NICFFiresAdapter",
lambda cfg: (cfg, self._region_anchors, self._coverage_for("fires")))
self._register_adapter("avalanche", config.avalanche, ".avalanche", "AvalancheAdapter",
lambda cfg: (cfg, self._coverage_for("avalanche")))
self._register_adapter("usgs", config.usgs, ".usgs", "USGSStreamsAdapter",
lambda cfg: (cfg, self._coverage_for("usgs")))
self._register_adapter("usgs_quake", config.usgs_quake, ".usgs_quake", "USGSQuakeAdapter",
lambda cfg: (cfg, self._coverage_for("usgs_quake")))
self._register_adapter("traffic", config.traffic, ".traffic", "TomTomTrafficAdapter",
lambda cfg: (cfg, self._coverage_for("traffic")))
self._register_adapter("roads511", config.roads511, ".roads511", "Roads511Adapter",
lambda cfg: (cfg, self._coverage_for("roads511")))
self._register_adapter("wzdx", config.wzdx, ".wzdx", "WZDxAdapter",
lambda cfg: (cfg, self._coverage_for("wzdx")))
# Native satpass TLE fetcher (storage-only: populates sat_tles, emits
# no events). Gated on satpass.feed_source=="native" like the rest.
self._register_adapter("satpass_tle", config.satpass, ".tle_fetch", "TLEFetchAdapter",
lambda cfg: (cfg,))
# Native SGP4 pass predictor (broadcasts consolidated passes locally,
# no Central dependency). SEPARATE from the satpass_tle fetcher above;
# both are gated on satpass.enabled and feed_source=="native".
self._register_adapter("satpass", config.satpass, ".satpass", "SatpassAdapter",
lambda cfg: (cfg,))
# FIRMS needs reference to NIFC adapter for cross-referencing
if config.firms.enabled and config.firms.feed_source == "native":
try:
from .firms import FIRMSAdapter
fires_adapter = self._adapters.get("nifc")
self._firms = FIRMSAdapter(config.firms, self._region_anchors, fires_adapter, coverage=self._coverage_for("firms"))
self._adapters["firms"] = self._firms
except Exception as e:
err_msg = f"{type(e).__name__}: {e}"
logger.warning("Failed to initialize firms adapter: %s", err_msg)
self._failed_adapters["firms"] = err_msg
# Universal config-driven REST/GeoJSON sources. ONE adapter instance
# handles every source in config.generic_sources; construct it only
# when at least one source is configured. Guarded like firms so a bad
# import/config never takes the whole store down.
if self._generic_sources:
try:
from .generic_http import GenericHttpAdapter
self._generic = GenericHttpAdapter(
self._generic_sources,
self._coverage_for("generic_http"))
self._adapters["generic_http"] = self._generic
except Exception as e:
err_msg = f"{type(e).__name__}: {e}"
logger.warning("Failed to initialize generic_http adapter: %s", err_msg)
self._failed_adapters["generic_http"] = err_msg
_central = [n for n in ("nws", "swpc", "ducting", "fires", "avalanche", "usgs", "usgs_quake", "traffic", "roads511", "wzdx", "firms", "satpass")
if getattr(getattr(config, n, None), "feed_source", "native") == "central"]
if _central:
logger.debug("Adapters sourced from Central (native skipped): %s", _central)
if self._failed_adapters:
logger.warning("Failed adapters: %s", list(self._failed_adapters.keys()))
logger.info(f"EnvironmentalStore initialized with {len(self._adapters)} adapters")
# Durable pre-seed: load every already-received item key from the
# persistent hazard tables into `self._seen` so nothing ever received
# can re-broadcast, regardless of fetch staging or process restarts.
self._seed_from_persistent()
def _register_adapter(self, name: str, cfg, module_path: str, class_name: str, args_fn):
"""Register a single adapter with error isolation."""
if not cfg.enabled or cfg.feed_source != "native":
return
try:
module = __import__(f"meshai.env{module_path}", fromlist=[class_name])
cls = getattr(module, class_name)
self._adapters[name] = cls(*args_fn(cfg))
except Exception as e:
err_msg = f"{type(e).__name__}: {e}"
logger.warning("Failed to initialize %s adapter: %s", name, err_msg)
self._failed_adapters[name] = err_msg
def _coverage_for(self, adapter: str):
"""Derived coverage scope for a NATIVE adapter, or None to use its own config (override/fallback).
Returns None immediately for any adapter listed in coverage_excluded, so
that adapter falls back to its own config fields exactly as if no coverage
bbox were set (the "advanced override" escape hatch).
"""
if adapter in self._coverage_excluded:
return None
from meshai import coverage as _cov
return _cov.resolve_adapter_coverage(adapter, self._coverage_bbox, "native")
def refresh(self) -> bool:
"""Called every second from main loop. Ticks each adapter.
Returns:
True if any data changed
"""
changed = False
for name, adapter in self._adapters.items():
try:
if adapter.tick():
changed = True
self._ingest(name, adapter)
except Exception as e:
logger.warning("Env adapter %s error: %s", name, e)
self._purge_expired()
return changed
def _seen_key(self, raw_evt: dict) -> str:
"""Derive a STABLE per-item key for the received-delta gate.
Stability across polls is the whole point: the SAME real-world item
must produce the SAME key every poll, or it would look "newly
received" forever and re-broadcast on each tick. Preference order,
most→least explicit id:
1. external_id — the upstream feed's own stable id (e.g. WZDx)
2. event_id — the adapter's stable per-item id; every native raw
event carries one (the store's own dedup already keys on it, and
the adapters build it from natural ids: USGS quake id, 511 event
id, NWS alert id, ``swpc_<scale><level>``, ``ducting_<tier>_<loc>``,
``avy_<center>_<zone>``, etc. — all stable across polls).
3. content hash — last-resort fallback if an item somehow carries no
id at all.
Namespaced by the event's ``source`` (NOT the adapter name). The
persistent hazard tables key on ``source`` (e.g. ``wzdx``), so keying
the live-emit path on the same value is what lets the durable startup
pre-seed line up with what the adapter emits. Every native raw event
carries a stable ``source``; if one somehow does not, fall back to a
neutral namespace so two feeds still never cross-contaminate.
"""
source = raw_evt.get("source") or "?"
ext = raw_evt.get("external_id")
if ext:
return _key_ext(source, ext)
eid = raw_evt.get("event_id")
if eid:
return _key_eid(source, eid)
blob = json.dumps(raw_evt, sort_keys=True, default=str)
return f"{source}{_SEP}hash:" + hashlib.sha1(blob.encode()).hexdigest()[:16]
def _delta_emit(self, name: str, adapter, raw_evt: dict, force: bool = False):
"""Received-delta gate — emit ONLY items newly received THIS poll.
- First poll for ``name`` (name not yet in ``self._seeded``): record
the item's key as seen and emit NOTHING. That batch is the
pre-existing backlog.
- Later polls: emit only when the key is not already in the seen-set
(it just appeared upstream = "just received"); then record it.
- ``force=True`` (avalanche danger-level rise) re-emits an already-seen
item on a legitimate CONTENT change — but is IGNORED on the first
poll, so a restart still can never replay the backlog.
This replaces the native path's reliance on the deciders'
broadcast-state tables for the "is this new" decision. Legitimate
content filtering (severity/threshold/impact, decider gates) still runs
downstream in ``_emit_event``.
"""
source = raw_evt.get("source") or name
seen = self._seen.setdefault(source, set())
key = self._seen_key(raw_evt)
first_poll = source not in self._seeded
if first_poll:
seen.add(key) # seed silently — this is backlog
return
if key in seen and not force:
return # already received (prior poll OR durable pre-seed)
seen.add(key)
if self._event_bus is not None and hasattr(adapter, "to_event"):
self._emit_event(adapter, raw_evt)
def _ingest(self, name: str, adapter):
"""Ingest data from an adapter after it ticks.
Emission goes through the received-delta gate (``_delta_emit``): the
adapter's FIRST data-bearing poll seeds the seen-set and broadcasts
nothing; only items that newly appear on later polls are broadcast.
``self._events`` is still maintained for state/queries as before.
Belt-and-suspenders: a source is marked "seeded" (past its first poll)
ONLY after a NON-EMPTY ingest for that source. An empty poll — e.g.
wzdx's registry-only tick that yields 0 events but returns ``tick()``
True — must never mark a source seeded, or the next tick's real batch
would be treated as "new" and leak. We collect the sources that
actually carried ≥1 event this ingest and mark only those.
"""
touched: set[str] = set()
if name == "swpc":
self._swpc_status = adapter.get_status()
# Also ingest any alert events (R-scale >= 3)
for evt in adapter.get_events():
key = (evt["source"], evt["event_id"])
self._events[key] = evt
touched.add(evt.get("source") or name)
self._delta_emit(name, adapter, evt)
elif name == "ducting":
self._ducting_status = adapter.get_status()
for evt in adapter.get_events():
key = (evt["source"], evt["event_id"])
self._events[key] = evt
touched.add(evt.get("source") or name)
self._delta_emit(name, adapter, evt)
elif name == "nifc":
# Native WFIGS fires DELIBERATELY bypass the received-delta `_seen`
# gate: a fire's growth updates are repeat sightings of the SAME
# event_id that `_delta_emit` would wrongly suppress. The DECIDER
# (gating.fire.decide, backed by the fires table + 8h cooldown) is
# the gate instead. `_ingest_fires` also silent-seeds EVERY current
# fire on the first poll (cold start) so boot never dumps a backlog.
for evt in adapter.get_events():
key = (evt["source"], evt["event_id"])
self._events[key] = evt
self._ingest_fires(adapter)
elif name == "generic_http":
# Universal config-driven sources: custom ingest with COLD-START-
# SILENT per source name + persist-every-item (see _ingest_generic).
self._ingest_generic(adapter)
elif name == "wzdx":
# Native WZDx work zones DELIBERATELY bypass the generic
# `_delta_emit` path: that path silent-seeds every zone on the cold-
# start first poll and `return`s BEFORE the incident decider's
# `INSERT INTO traffic_events` ever runs, so the current active
# work-zone set (the ~127 coalesced zones) would never persist —
# only later-newly-appearing zones would trickle in, leaving the
# daily summary + DM query counting ~0. Instead `_ingest_wzdx`
# UPSERTS the CURRENT coalesced set into traffic_events every poll
# (persist-only, NEVER emit/decide/broadcast — mirrors the fires /
# generic persist-every-item pattern), and reconciles removals, so
# the table always equals the current active set the summary reads.
self._ingest_wzdx(adapter)
elif name == "avalanche":
# Avalanche: re-emit on danger_level rise (Update:) not just new
# events. The rise is a legitimate CONTENT change, so it passes
# `force=True` — but the received-delta gate still suppresses it on
# the first poll (backlog stays silent).
for evt in adapter.get_events():
key = (evt["source"], evt["event_id"])
prior = self._events.get(key)
prior_level = prior.get("danger_level", -1) if prior else -1
level_rose = (prior is not None) and (
evt.get("danger_level", -1) > prior_level)
evt["_is_update"] = level_rose # signal to to_event()
touched.add(evt.get("source") or name)
self._delta_emit(name, adapter, evt, force=level_rose)
self._events[key] = evt # always update stored state
else:
for evt in adapter.get_events():
key = (evt["source"], evt["event_id"])
self._events[key] = evt
touched.add(evt.get("source") or name)
self._delta_emit(name, adapter, evt)
# First (non-empty) poll for these sources is now complete: later polls
# may emit. Empty ingests touch nothing and so never mark a source
# seeded — the leak-proof invariant behind the wzdx staging fix.
self._seeded.update(touched)
def _fire_out_of_coverage(self, lat, lon) -> bool:
"""True iff (lat, lon) lies OUTSIDE every configured coverage area.
Mirrors the dispatch-level CoverageFilter membership exactly: builds a
GeoJSON Point from the fire's (lon, lat) and classifies it against
``self._fire_coverage_areas`` with the SAME set-union Shapely test
(classify_geom_areas). Only ``out-of-bounds`` returns True (drop);
every other verdict — in-bounds, or an unlocatable / unparseable point
(null-geom / invalid-geom) — returns False (fail-OPEN, keep), matching
the fire path's fail-open semantics. Callers must already have checked
that ``self._fire_coverage_areas`` is non-empty.
"""
try:
if lat is None or lon is None:
return False # unlocatable fire -> fail open (keep/store)
from meshai.coverage_area import build_geom_json, classify_geom_areas
geom_json = build_geom_json({"centroid": [lon, lat]})
verdict = classify_geom_areas(geom_json, self._fire_coverage_areas)
return verdict == "out-of-bounds"
except Exception:
logger.exception(
"fire coverage-scope test failed for (%s,%s); failing OPEN",
lat, lon)
return False
def _ingest_fires(self, adapter) -> None:
"""Native WFIGS fire ingest — Phase-3 growth-decider path.
For each polled fire (already recorded in ``self._events`` by the
caller):
1. COLD-START silent-seed. On the FIRST fires poll after boot
(``self._fires_seeded`` is False), EVERY first-sight fire (no
``fires`` row) is treated as a pre-existing active fire —
regardless of age. We INSERT a ``fires`` row stamped as ALREADY
broadcast (``last_broadcast_*`` = current) and emit NOTHING, so the
decider treats later polls as Update and only fires on real growth
— no boot backlog dump (a fresh deploy with an empty ``fires`` table
must NOT broadcast every fire discovered in the last 48h). A fire
only broadcasts "New" if it FIRST appears on a LATER poll (a
genuine ignition since startup). The shared ``gating.fire.decide``
is untouched. This is gated on the per-process first-poll flag, not
a wall-clock boot grace, so it holds no matter how late the first
successful WFIGS fetch lands.
2. Unconditional current-state write (mirrors the Central
``wfigs_handler``): INSERT (``last_broadcast_*`` NULL) on first
sight else UPDATE ``current_*``. This GUARANTEES a row exists so
the decider's deferred ``commit`` UPSERT
(``WHERE irwin_id=?``) persists ``last_broadcast_*`` — without it a
genuine New would never latch and would re-broadcast every poll.
3. Decider path via ``_emit_event``: runs ``gating.fire.decide``,
applies ``gate.data_patch`` (``_dedup_suffix`` / ``_severity_override``
/ render hints) and arms ``gate.commit`` onto the emitted Event.
"""
from meshai.notifications import clock as _clock
now = _clock.now()
try:
from meshai.persistence import get_db
conn = get_db()
except Exception as e:
logger.warning("nifc fire ingest skipped (DB unavailable): %s", e)
return
# Cold-start gate: is this the FIRST fires poll since boot? Captured
# once for the whole batch, before the flag is flipped below, so every
# fire in the initial full-state sweep is silent-seeded together.
cold_start = not self._fires_seeded
events = adapter.get_events()
for evt in events:
try:
irwin_id = evt.get("irwin_id")
if not irwin_id:
continue # no stable identity -> not gate-able via fires
acres = evt.get("acres")
contained = evt.get("contained_pct")
declared = evt.get("declared_at_epoch")
# ── Coverage-scope drop ──────────────────────────────────
# BEFORE any store write (cold-start seed AND live
# INSERT/UPDATE), drop a fire whose (lat, lon) falls OUTSIDE
# every configured coverage area. Uses the SAME per-area
# set-union Shapely membership test as the dispatch-level
# CoverageFilter (classify_geom_areas over the areas built in
# __init__), so ingest scope == dispatch scope. Fail-OPEN: with
# no coverage areas the list is empty and this never drops.
if self._fire_coverage_areas and self._fire_out_of_coverage(
evt.get("lat"), evt.get("lon")):
logger.info(
"coverage: skipped out-of-area fire %s (%s,%s)",
evt.get("name") or irwin_id,
evt.get("lat"), evt.get("lon"))
continue
row = conn.execute(
"SELECT last_broadcast_at FROM fires WHERE irwin_id=?",
(irwin_id,)).fetchone()
first_sight = row is None
# (1) Cold-start silent-seed — EVERY first-sight fire on the
# first poll, regardless of age; seed as already-broadcast, no
# emit.
if first_sight and cold_start:
conn.execute(
"INSERT INTO fires(irwin_id, incident_name, "
"current_acres, current_contained_pct, lat, lon, "
"county, state, declared_at, last_event_at, "
"last_broadcast_at, first_broadcast_at, "
"last_broadcast_acres, last_broadcast_contained) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(irwin_id, evt.get("name"), acres, contained,
evt.get("lat"), evt.get("lon"), evt.get("county"),
evt.get("state"), declared, int(now),
int(now), int(now), acres, contained),
)
logger.info(
"nifc cold-start silent-seed irwin=%s acres=%s "
"(seeded already-broadcast, no mesh)", irwin_id, acres)
continue
# (2) Unconditional current-state write so commit can latch.
if first_sight:
conn.execute(
"INSERT INTO fires(irwin_id, incident_name, "
"current_acres, current_contained_pct, lat, lon, "
"county, state, declared_at, last_event_at, "
"last_broadcast_at, last_broadcast_acres, "
"last_broadcast_contained) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
(irwin_id, evt.get("name"), acres, contained,
evt.get("lat"), evt.get("lon"), evt.get("county"),
evt.get("state"), declared, int(now),
None, None, None),
)
else:
conn.execute(
"UPDATE fires SET current_acres=?, "
"current_contained_pct=?, lat=COALESCE(?, lat), "
"lon=COALESCE(?, lon), last_event_at=? "
"WHERE irwin_id=?",
(acres, contained, evt.get("lat"), evt.get("lon"),
int(now), irwin_id),
)
# (3) Run the decider + emit (or suppress) via the shared path.
if self._event_bus is not None and hasattr(adapter, "to_event"):
self._emit_event(adapter, evt)
except Exception:
logger.exception(
"nifc fire ingest failed for %s", evt.get("event_id", "?"))
# First fires poll complete: later polls broadcast genuine ignitions.
# `_ingest_fires` is only reached when the adapter's tick() reported a
# change, which for the atomic WFIGS fetch means a non-empty batch on
# the first successful poll (a 0-fire fetch is `changed=False` and never
# ingests) — so this flip only ever happens on a real full-state sweep.
if events:
self._fires_seeded = True
def _ingest_wzdx(self, adapter) -> None:
"""Native WZDx work-zone ingest — persist-only current-state mirror.
The daily summary (``wzdx_summary.fire_once``) and the DM detail
(``env_reporter.build_work_zones_detail``) both read the CURRENT active
work-zone set straight from ``traffic_events``:
SELECT ... FROM traffic_events
WHERE source='wzdx' AND (end_at IS NULL OR end_at >= now)
Left on the generic ``_delta_emit`` path, wzdx never populates that
table on a cold start: ``_delta_emit`` silent-seeds every zone into the
received-delta seen-set and ``return``s on the first poll, BEFORE the
incident decider's ``INSERT INTO traffic_events`` runs — so the current
coalesced set (the ~127 zones) is never persisted; only zones that
FIRST appear on a LATER poll would trickle in. Result: the summary/DM
count ~0 instead of the real active total.
This dedicated ingest fixes the gap the way ``_ingest_fires`` /
``_persist_generic`` do — UPSERT EVERY current item on EVERY poll —
but scoped to persistence only:
* UPSERT each CURRENT coalesced work zone into ``traffic_events``
(INSERT on a new ``external_id`` with ``first_seen_at`` = now and
``last_broadcast_at`` = NULL; UPDATE ``last_seen_at`` + the current
fields, preserving ``first_seen_at``), using the SAME column
set/types the incident decider writes so the summary/DM queries
work unchanged. ``last_broadcast_at`` is left NULL forever (never
armed) so nothing here ever looks "already broadcast".
* RECONCILE removals: after upserting this poll's set, DELETE any
``source='wzdx'`` rows whose ``external_id`` is NOT in the current
set, so the table == the CURRENT active set (stale zones would
otherwise inflate the count). Reconciliation runs ONLY when the
poll actually returned ≥1 zone — an empty/failed fetch never wipes
the existing rows.
Persist ONLY: it does NOT emit to the pipeline, run the decider, or
broadcast. Per-event work-zone broadcast stays suppressed exactly as
before (the incident decider's ``work_zone`` ``broadcast=False`` gate
remains as defense-in-depth for any wzdx event that reaches it another
way). ``self._events`` is not touched here — the summary/DM read the
durable table, not the in-memory cache.
"""
try:
from meshai.persistence import get_db
conn = get_db()
except Exception as e:
logger.warning("wzdx ingest skipped (DB unavailable): %s", e)
return
now = int(time.time())
events = adapter.get_events()
current_ext_ids: list = []
for evt in events:
try:
external_id = evt.get("external_id")
if not external_id:
continue # no stable identity → cannot dedup/persist
n = evt.get("normalized") or {}
road = n.get("road")
direction = n.get("direction")
mile_start = n.get("mile_start")
mile_end = n.get("mile_end")
sub_type = n.get("sub_type")
impact = n.get("impact")
lat = evt.get("lat")
lon = evt.get("lon")
start_at = evt.get("start_at")
end_at = evt.get("end_at")
current_ext_ids.append(external_id)
# UPSERT the current coalesced zone. Composite PK
# (source, external_id): INSERT on first sight (first_seen_at =
# now, last_broadcast_at = NULL → never armed), else UPDATE the
# current fields + last_seen_at while PRESERVING first_seen_at.
# end_at is refreshed from the feed on every poll so the
# summary's not-expired filter (end_at IS NULL OR end_at>=now)
# stays correct. Same columns/types the incident decider uses.
conn.execute(
"INSERT INTO traffic_events("
"source, external_id, road, direction, "
"mile_start, mile_end, county, state, lat, lon, "
"sub_type, impact, start_at, end_at, "
"first_seen_at, last_seen_at, last_broadcast_at"
") VALUES ('wzdx',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(source, external_id) DO UPDATE SET "
"road=excluded.road, direction=excluded.direction, "
"mile_start=excluded.mile_start, mile_end=excluded.mile_end, "
"lat=excluded.lat, lon=excluded.lon, "
"sub_type=excluded.sub_type, impact=excluded.impact, "
"start_at=excluded.start_at, end_at=excluded.end_at, "
"last_seen_at=excluded.last_seen_at",
(
external_id, road, direction,
mile_start, mile_end, None, None, lat, lon,
sub_type, impact, start_at, end_at,
now, now, None,
),
)
except Exception:
logger.exception(
"wzdx ingest failed for %s", evt.get("external_id", "?"))
# RECONCILE removals — only when this poll actually returned a set.
# A zone that dropped out of the feed is deleted so the table equals
# the CURRENT active set; an empty/failed fetch (no current ext_ids)
# is left untouched so stale-but-only-momentarily-missing rows and a
# transient outage never wipe the summary's data.
if current_ext_ids:
try:
placeholders = ",".join("?" for _ in current_ext_ids)
conn.execute(
"DELETE FROM traffic_events WHERE source='wzdx' "
f"AND external_id NOT IN ({placeholders})",
current_ext_ids,
)
except Exception:
logger.exception("wzdx ingest reconcile-delete failed")
def _ingest_generic(self, adapter) -> None:
"""Native generic-source ingest — COLD-START-SILENT per source.
Mirrors the ``_fires_seeded`` cold-start pattern, but PER configured
source name:
1. The FIRST non-empty poll for a given source seeds every current
item into the received-delta seen-set and PERSISTS it, but emits
NOTHING — that batch is pre-existing backlog.
2. On later polls, only items whose key is not already seen (they
newly appeared upstream = "just received") broadcast via
``_emit_event`` (which runs the coverage/decider path).
3. EVERY item — seeded or new — is upserted into ``generic_events``
so the LLM (env_reporter.build_generic_detail) sees it immediately.
Dedup is by (source, event_id).
The received-delta seen-set is namespaced by the event's ``source``
(``generic:<name>``) so distinct sources never cross-contaminate.
"""
conn = None
try:
from meshai.persistence import get_db
conn = get_db()
except Exception as e:
logger.warning("generic ingest skipped persistence (DB unavailable): %s", e)
# Group this poll's events by bare source name to apply the per-source
# cold-start gate consistently across all of that source's items.
by_source: dict = {}
for evt in adapter.get_active():
by_source.setdefault(evt.get("_source") or "?", []).append(evt)
for src_name, items in by_source.items():
cold_start = src_name not in self._generic_seeded
for evt in items:
key = (evt["source"], evt["event_id"])
self._events[key] = evt # always track current state
if conn is not None:
self._persist_generic(conn, evt)
seen = self._seen.setdefault(evt["source"], set())
seen_key = self._seen_key(evt)
if cold_start:
seen.add(seen_key) # silent seed — backlog, no emit
continue
if seen_key in seen:
continue # already received (prior poll)
seen.add(seen_key)
if self._event_bus is not None and hasattr(adapter, "to_event"):
self._emit_event(adapter, evt)
# First non-empty poll for this source complete: later polls emit.
if items:
self._generic_seeded.add(src_name)
def _persist_generic(self, conn, evt: dict) -> None:
"""Upsert one generic event into ``generic_events`` (source keyed by
the bare configured name). Mirrors how _ingest_fires writes the fires
table. Never fatal — a persistence error logs and continues."""
now = int(time.time())
try:
conn.execute(
"INSERT INTO generic_events(source, event_id, category, title, "
"lat, lon, severity, data_json, first_seen, last_seen) "
"VALUES (?,?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(source, event_id) DO UPDATE SET "
"category=excluded.category, title=excluded.title, "
"lat=excluded.lat, lon=excluded.lon, "
"severity=excluded.severity, data_json=excluded.data_json, "
"last_seen=excluded.last_seen",
(
evt.get("_source"), evt["event_id"], evt.get("category"),
evt.get("title"), evt.get("latitude"), evt.get("longitude"),
evt.get("severity"),
json.dumps(evt.get("data", {}), default=str),
now, now,
),
)
except Exception:
logger.exception("generic persist failed for %s",
evt.get("event_id", "?"))
def _seed_from_persistent(self) -> None:
"""Pre-seed ``self._seen`` from the durable hazard tables at startup.
A row exists in these tables iff meshai has ALREADY RECEIVED that item,
so loading their identifying keys guarantees a native adapter can never
re-broadcast a previously-received item — immune to fetch staging and
to process restarts. Durable keys are added to ``self._seen`` and,
when ≥1 key is loaded for a source, that source is marked ``_seeded``
so the durable set becomes its baseline: the very next poll emits ONLY
items NOT already received (the operator's "newly received → send it
now") and suppresses everything in the table — no silent first-poll
needed, and no dependency on how the fetch is staged. Because durable
keys are never removed, they keep suppressing backlog across every
later poll, which is what closes cross-tick, non-empty staging leaks.
(When a source has 0 durable rows we do NOT mark it seeded, so it falls
back to the leak-proof in-memory silent first-poll seed — a fresh DB
can never cause the first real batch to be treated as new.)
Only sources whose native emit key PROVABLY equals the persistent key
are seeded here (verified against the live schema + adapter code):
* ``wzdx`` — the native WZDx adapter carries a stable
``external_id`` on its raw event and the incident decider persists
it as ``traffic_events(source='wzdx', external_id)``. Same value on
both sides → ``_key_ext('wzdx', external_id)`` matches exactly.
* ``usgs_quake`` — the native adapter's raw ``event_id`` is the bare
USGS id (e.g. ``us6000t9bn``) and the quake decider persists it as
``quake_events.event_id`` verbatim → ``_key_eid('usgs_quake', id)``
matches exactly.
* ``roads511`` — the native 511 adapter now carries a stable
``external_id`` (``511_{itd_id}``, EQUAL to its ``event_id``) on its
raw event and the incident decider persists it as
``traffic_events(source='511', external_id='511_{itd_id}')``. Same
value on both sides → ``_key_ext('511', external_id)`` matches
exactly. Central-era rows used source ``itd_511`` with
``idaho_511:event:*`` ids — a DIFFERENT keyspace this pre-seed does
NOT cover. On the first restart after this change there may be ~0
``source='511'`` rows yet; the layer-2 in-memory silent-first-poll
seed covers any Central-era backlog in the interim (safe because
roads511 fetches atomically per tick), and durability grows as
native ``source='511'`` rows accumulate.
DELIBERATELY NOT seeded here (native emit key ≠ any persistent key —
see the report; these stay protected by the in-memory first-poll +
non-empty-seed guard, which is sufficient because each fetches
atomically per tick rather than across ticks):
traffic (persistent rows are Central-keyed: tomtom_incidents with
tomtom external_ids, but the native adapter emits source 'traffic'
with derived event_ids),
fires (native ``nifc_<name>_<state>`` vs persistent IRWIN GUID),
firms (native ``firms_<lat>_<lon>_<date>_<time>`` — no matching PK),
satpass (native ``<norad>:<bucket>`` vs persistent
``<norad>:<observer>:<bucket>``), nws (native CAP url vs persistent
bare urn:oid), swpc (native ``swpc_<scale><level>`` vs timestamped
persistent ids), usgs hydro (time-series, no per-item PK).
Resilient by construction: each table is loaded in its own try/except
so a missing/renamed table (or an unavailable DB) logs and continues —
startup never crashes. The SELECTs are cheap key-only scans of bounded
tables.
"""
try:
from meshai.persistence import get_db
conn = get_db()
except Exception as e:
logger.warning("received-delta pre-seed skipped (DB unavailable): %s", e)
return
# (source, description, SQL, row->key) — one spec per verified source.
specs = [
(
"wzdx",
"traffic_events(source='wzdx')",
"SELECT external_id FROM traffic_events "
"WHERE source='wzdx' AND external_id IS NOT NULL",
lambda row: _key_ext("wzdx", row[0]),
),
(
"511",
"traffic_events(source='511')",
"SELECT external_id FROM traffic_events "
"WHERE source='511' AND external_id IS NOT NULL",
lambda row: _key_ext("511", row[0]),
),
(
"usgs_quake",
"quake_events",
"SELECT event_id FROM quake_events WHERE event_id IS NOT NULL",
lambda row: _key_eid("usgs_quake", row[0]),
),
]
total = 0
for source, desc, sql, key_fn in specs:
try:
seen = self._seen.setdefault(source, set())
n = 0
for row in conn.execute(sql).fetchall():
seen.add(key_fn(row))
n += 1
total += n
if n:
# Durable baseline established → skip the silent first-poll
# seed; the next poll emits only genuinely-new items.
self._seeded.add(source)
logger.info(
"received-delta pre-seed: %d key(s) from %s -> source %r%s",
n, desc, source, " (marked seeded)" if n else "",
)
except Exception as e:
# Missing/renamed table or bad row — never fatal at startup.
logger.warning(
"received-delta pre-seed: %s failed (continuing): %s",
desc, e,
)
logger.info("received-delta pre-seed complete: %d durable key(s) loaded", total)
def _emit_event(self, adapter, raw_evt: dict):
"""Convert raw event to pipeline Event and emit to bus.
Phase-1 new-arch hook: if a gating decider is registered for the
event's category (via meshai.notifications.gating.DECIDERS), it is
called with event.data before the event reaches the bus. The decider
mirrors the Central-path gate so native and Central ingestion share
identical broadcast decisions.
Decider contract:
- Returns GateResult.broadcast=False → suppress (event never emits)
- Returns GateResult.broadcast=True → apply data_patch, attach
commit, then emit normally.
- Exceptions in the decider are caught; event is silently suppressed
to preserve the default-deny safety property.
"""
try:
event = adapter.to_event(raw_evt)
if event is None:
return # adapter declined to emit (non-actionable reading)
# ── New-arch decider hook ──────────────────────────────────────
# Applied only when BOTH a decider is registered AND the category
# has been explicitly cut over via MESHAI_CUTOVER_CATEGORIES.
# When not cut over, the native adapter emits directly to the bus
# (original pre-Phase-1 behavior); shadow_gate in consumer handles
# dry-run comparison for the bake period.
try:
from meshai.notifications.gating import get_decider
from meshai.notifications.cutover import is_cutover, NATIVE_ALWAYS_DECIDE
from meshai.notifications import clock as _clock
decider = get_decider(event.category)
# Native WFIGS fire categories always run the decider — it IS
# the fire gate (fires table + cooldown), so it can't hang on the
# shadow-bake env var. Central is off, so this cannot affect any
# Central render. All other categories stay env-gated (is_cutover).
if decider is not None and (
is_cutover(event.category)
or event.category in NATIVE_ALWAYS_DECIDE):
if event.data is None:
event.data = {}
gate = decider(event.data, source=event.source,
now=_clock.now())
if not gate.broadcast:
logger.debug(
"store: decider suppressed %s event %s: %s",
event.category, raw_evt.get("event_id", "?"),
gate.reason,
)
return
# Apply data_patch into event.data
event.data.update(gate.data_patch)
# Promote decider overrides onto the Event itself, mirroring the
# Central path (central/consumer.py). Deciders stamp
# _severity_override (fire: "priority" on every New/Update) and,
# for fire New/tombstone, a category override. Merging them into
# event.data alone left event.severity at the adapter's value
# ("routine" for fires >=25 km from an anchor), which silently
# failed the toggle/matrix min_severity floors. Native and Central
# must share identical broadcast decisions. satpass/firms already
# self-consume this in-adapter; this fixes it for every other
# cut-over category at the shared choke point.
_sev_override = gate.data_patch.get("_severity_override")
if _sev_override:
event.severity = _sev_override
_cat_override = gate.data_patch.get("category")
if _cat_override:
event.category = _cat_override
if gate.commit is not None:
event.data["_on_broadcast_committed"] = gate.commit
except Exception as _gate_exc:
logger.warning(
"store: decider failed for %s, suppressing: %s",
event.category, _gate_exc,
)
return # default-deny on decider error
# Native fire-family events get the same <=1/60s pacing (with
# head-of-line for "immediate" severity) that the Central path
# applies via CentralConsumer._handle (issue #119) -- see the
# _FIRE_PACER_SOURCES/_FIRE_PACER_SEVERITIES gate above. Only ONE
# of {pacer, direct bus.emit} ever runs for a given event: this
# gate only executes on the NATIVE ingest path (an adapter whose
# feed_source=="native"), and the pacer's own drain loop calls
# bus.emit() directly -- never back through this method or
# through CentralConsumer._handle -- so a paced event cannot
# re-enter this gate and be paced twice.
fire_pacer = getattr(self, "_fire_pacer", None)
if (fire_pacer is not None
and event.source in _FIRE_PACER_SOURCES
and event.severity in _FIRE_PACER_SEVERITIES):
fire_pacer.enqueue(event)
logger.info(
"Queued %s event %s (%s) on FirePacer",
event.source, event.id, event.category,
)
return
self._event_bus.emit(event)
logger.info(
"Emitted %s event %s (%s) to pipeline bus",
event.source,
event.id,
event.category,
)
except Exception as e:
logger.warning("Failed to emit event to pipeline: %s", e)
def _purge_expired(self):
"""Remove expired events."""
now = time.time()
expired = [
k for k, v in self._events.items()
if v.get("expires") and v["expires"] < now
]
for k in expired:
del self._events[k]
def get_active(self, source: str = None) -> list:
"""Get active events, optionally filtered by source.
Args:
source: Filter to specific source (nws, swpc, etc.)
Returns:
List of event dicts sorted by fetched_at (newest first)
"""
events = list(self._events.values())
if source:
events = [e for e in events if e["source"] == source]
return sorted(events, key=lambda e: e.get("fetched_at", 0), reverse=True)
def get_for_zones(self, zones: list) -> list:
"""Get events affecting specific NWS zones.
Args:
zones: List of UGC zone codes (e.g., ["IDZ016", "IDZ030"])
Returns:
List of events with overlapping zone coverage
"""
zone_set = set(zones)
return [
e for e in self._events.values()
if set(e.get("areas", [])) & zone_set
]
def get_swpc_status(self) -> dict:
"""Get current SWPC space weather status."""
return self._swpc_status
def get_ducting_status(self) -> dict:
"""Get current tropospheric ducting status."""
return self._ducting_status
def get_rf_propagation(self) -> dict:
"""Combined HF + UHF propagation summary for dashboard/LLM."""
return {
"hf": self._swpc_status,
"uhf_ducting": self._ducting_status,
}
def get_summary(self) -> str:
"""Compact text block for LLM context injection."""
lines = []
lines.append(f"### Current Conditions (as of {time.strftime('%H:%M:%S MT')}):")
# NWS alerts
nws = self.get_active(source="nws")
if nws:
lines.append(f"NWS: {len(nws)} active alert(s):")
for a in nws[:3]:
lines.append(f" - {a['event_type']}: {a['headline'][:120]}")
else:
lines.append("NWS: No active alerts for mesh area.")
# Space weather indices (raw - LLM interprets)
s = self._swpc_status
if s:
kp = s.get("kp_current", "?")
sfi = s.get("sfi", "?")
r = s.get("r_scale", 0)
g = s.get("g_scale", 0)
lines.append(f"Space Weather: SFI {sfi}, Kp {kp}, R{r}/G{g}")
warnings = s.get("active_warnings", [])
if warnings:
for w in warnings[:2]:
lines.append(f" Warning: {w}")
else:
lines.append("Space Weather: Data not available.")
# Tropospheric ducting (raw - LLM interprets)
d = self._ducting_status
if d:
condition = d.get("condition", "unknown")
gradient = d.get("min_gradient", "?")
if condition == "normal":
lines.append(f"Tropospheric: Normal (dM/dz {gradient} M-units/km)")
else:
thickness = d.get("duct_thickness_m", "?")
lines.append(f"Tropospheric: {condition.replace('_', ' ').title()}")
lines.append(f" dM/dz: {gradient} M-units/km, duct ~{thickness}m thick")
# Active fires
fires = self.get_active(source="nifc")
if fires:
lines.append(f"Wildfires: {len(fires)} active")
for f in fires[:2]:
name = f.get("name", "Unknown")
acres = f.get("acres", 0)
pct = f.get("pct_contained", 0)
dist = f.get("distance_km")
lines.append(f" - {name}: {int(acres):,} ac, {int(pct)}% contained" +
(f" ({int(dist)} km)" if dist else ""))
# Avalanche advisories
avy = self.get_active(source="avalanche")
if avy:
lines.append(f"Avalanche: {len(avy)} zone(s) with advisories")
for a in avy[:2]:
zone = a.get("zone_name", "Unknown")
danger = a.get("danger_name", "Unknown")
lines.append(f" - {zone}: {danger}")
# Stream gauges
streams = self.get_active(source="usgs")
if streams:
lines.append(f"Stream Gauges: {len(streams)} readings")
for s in streams[:2]:
lines.append(f" - {s['headline']}")
# Traffic flow
traffic = self.get_active(source="traffic")
if traffic:
lines.append(f"Traffic: {len(traffic)} corridors")
for t in traffic[:2]:
lines.append(f" - {t['headline']}")
# 511 road events
roads = self.get_active(source="511")
if roads:
lines.append(f"Road Events: {len(roads)} active")
for r in roads[:2]:
lines.append(f" - {r['headline'][:60]}")
# Satellite hotspots
hotspots = self.get_active(source="firms")
if hotspots:
new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")]
lines.append(f"Satellite Hotspots: {len(hotspots)} detected")
if new_ignitions:
lines.append(f" *** {len(new_ignitions)} POTENTIAL NEW IGNITION(S) ***")
for h in hotspots[:2]:
lines.append(f" - {h['headline']}")
return "\n".join(lines)
def get_status(self) -> list:
"""Get status of all adapters including failed ones."""
status = []
for name, adapter in self._adapters.items():
try:
hs = adapter.health_status
status.append({
"source": name,
"is_loaded": True,
"last_error": hs.get("last_error"),
"consecutive_errors": hs.get("consecutive_errors", 0),
"event_count": hs.get("event_count", 0),
"last_fetch": hs.get("last_fetch"),
})
except Exception:
status.append({
"source": name,
"is_loaded": True,
"last_error": None,
"consecutive_errors": 0,
"event_count": 0,
"last_fetch": None,
})
for name, error in self._failed_adapters.items():
status.append({
"source": name,
"is_loaded": False,
"last_error": error,
"consecutive_errors": 0,
"event_count": 0,
"last_fetch": None,
})
return status
def get_source_health(self) -> list:
"""Get health status for all adapters."""
return [a.health_status for a in self._adapters.values()]