|
| 1 | +"""Sentinel — 30s threshold-breach detection loop. |
| 2 | +
|
| 3 | +Issue #24 (M4.2). Opens a ``work_order(status='detected')`` on breach, |
| 4 | +broadcasts ``anomaly_detected`` + ``ui_render(alert_banner)``, and spawns |
| 5 | +the Investigator agent (#25) in the background. |
| 6 | +
|
| 7 | +The loop runs forever, started by the FastAPI lifespan (#26). Each tick |
| 8 | +wraps its body in ``try/except`` so a single bad cell cannot kill the |
| 9 | +entire asyncio Task — the loop must survive any transient tool or DB |
| 10 | +error so detection resumes on the next 30s tick. |
| 11 | +
|
| 12 | +Threshold evaluation is delegated to ``get_signal_anomalies`` (M2.3), |
| 13 | +which internally calls :func:`core.thresholds.evaluate_threshold` and |
| 14 | +handles both single-sided (``alert`` / ``trip``) and double-sided |
| 15 | +(``low_alert`` / ``high_alert``) shapes identically. Sentinel only |
| 16 | +consumes the structured breach list and never interprets raw thresholds |
| 17 | +itself — this keeps the detection contract in one place. |
| 18 | +""" |
| 19 | + |
| 20 | +from __future__ import annotations |
| 21 | + |
| 22 | +import asyncio |
| 23 | +import json |
| 24 | +import logging |
| 25 | +import uuid |
| 26 | +from datetime import datetime, timedelta, timezone |
| 27 | +from typing import Any |
| 28 | + |
| 29 | +from aria_mcp.client import mcp_client |
| 30 | +from core.database import db |
| 31 | +from core.db_helpers import must |
| 32 | +from core.ws_manager import ws_manager |
| 33 | +from modules.work_order.repository import WorkOrderRepository |
| 34 | + |
| 35 | +log = logging.getLogger("aria.sentinel") |
| 36 | + |
| 37 | +_TICK_SECONDS = 30 |
| 38 | +_WINDOW_MINUTES = 5 |
| 39 | +_DEBOUNCE_MINUTES = 30 |
| 40 | + |
| 41 | +# Module-level flag — emit the "watching / ignored" summary exactly once at |
| 42 | +# first tick so the Docker logs carry a stable startup fingerprint without |
| 43 | +# being spammed every 30s. |
| 44 | +_logged_cells = False |
| 45 | + |
| 46 | + |
| 47 | +async def sentinel_loop() -> None: |
| 48 | + """Run forever. Wraps each tick in try/except so the loop never dies.""" |
| 49 | + log.info("Sentinel started") |
| 50 | + while True: |
| 51 | + try: |
| 52 | + await _sentinel_tick() |
| 53 | + except Exception: # noqa: BLE001 — outer loop must survive any tick-level error |
| 54 | + log.exception("Sentinel tick failed — continuing") |
| 55 | + await asyncio.sleep(_TICK_SECONDS) |
| 56 | + |
| 57 | + |
| 58 | +async def _sentinel_tick() -> None: |
| 59 | + """One detection pass over every onboarded cell.""" |
| 60 | + global _logged_cells |
| 61 | + |
| 62 | + async with db.pool.acquire() as conn: |
| 63 | + cell_rows = await conn.fetch( |
| 64 | + """ |
| 65 | + SELECT k.cell_id, c.name AS cell_name, k.onboarding_complete |
| 66 | + FROM equipment_kb k |
| 67 | + JOIN cell c ON c.id = k.cell_id |
| 68 | + ORDER BY k.cell_id |
| 69 | + """ |
| 70 | + ) |
| 71 | + |
| 72 | + if not _logged_cells: |
| 73 | + watched = [r["cell_id"] for r in cell_rows if r["onboarding_complete"]] |
| 74 | + ignored = [r["cell_id"] for r in cell_rows if not r["onboarding_complete"]] |
| 75 | + log.info( |
| 76 | + "Sentinel watching cells: %s | ignored (no KB / not onboarded): %s", |
| 77 | + watched, |
| 78 | + ignored, |
| 79 | + ) |
| 80 | + _logged_cells = True |
| 81 | + |
| 82 | + now = datetime.now(timezone.utc) |
| 83 | + window_start = (now - timedelta(minutes=_WINDOW_MINUTES)).isoformat() |
| 84 | + window_end = now.isoformat() |
| 85 | + |
| 86 | + for row in cell_rows: |
| 87 | + if not row["onboarding_complete"]: |
| 88 | + continue |
| 89 | + await _check_cell( |
| 90 | + cell_id=row["cell_id"], |
| 91 | + cell_name=row["cell_name"], |
| 92 | + window_start=window_start, |
| 93 | + window_end=window_end, |
| 94 | + ) |
| 95 | + |
| 96 | + |
| 97 | +async def _check_cell(*, cell_id: int, cell_name: str, window_start: str, window_end: str) -> None: |
| 98 | + """Check one cell for breaches and handle each unique-signal breach.""" |
| 99 | + result = await mcp_client.call_tool( |
| 100 | + "get_signal_anomalies", |
| 101 | + {"cell_id": cell_id, "window_start": window_start, "window_end": window_end}, |
| 102 | + ) |
| 103 | + if result.is_error: |
| 104 | + # KB misconfigured (no thresholds / no kb_threshold_key matches). Skip |
| 105 | + # this cell for this tick — a fix to the KB flips it back on without |
| 106 | + # restarting Sentinel. |
| 107 | + log.warning("get_signal_anomalies error for cell %d: %s", cell_id, result.content) |
| 108 | + return |
| 109 | + |
| 110 | + try: |
| 111 | + breaches = json.loads(result.content) if result.content else [] |
| 112 | + except json.JSONDecodeError: |
| 113 | + log.warning("get_signal_anomalies returned non-JSON for cell %d", cell_id) |
| 114 | + return |
| 115 | + |
| 116 | + if not breaches: |
| 117 | + return |
| 118 | + |
| 119 | + # Within one tick, only act on the first breach per signal_def_id — later |
| 120 | + # readings of the same signal in the same 5-min window would just produce |
| 121 | + # duplicate work orders. Cross-tick debounce is handled by the DB query |
| 122 | + # in :func:`_handle_breach`. |
| 123 | + seen_signals: set[int] = set() |
| 124 | + for breach in breaches: |
| 125 | + signal_def_id = breach["signal_def_id"] |
| 126 | + if signal_def_id in seen_signals: |
| 127 | + continue |
| 128 | + seen_signals.add(signal_def_id) |
| 129 | + await _handle_breach(cell_id=cell_id, cell_name=cell_name, breach=breach) |
| 130 | + |
| 131 | + |
| 132 | +async def _handle_breach(*, cell_id: int, cell_name: str, breach: dict[str, Any]) -> None: |
| 133 | + """Open a work_order on the first fresh breach and broadcast the event. |
| 134 | +
|
| 135 | + Debounce rule: if any open work_order for the same (cell, signal) was |
| 136 | + created in the last 30 minutes, skip. The DB is the source of truth so |
| 137 | + the debounce window survives Sentinel restarts — and a human closing |
| 138 | + the WO (``status='completed'``/``'cancelled'``) re-enables detection |
| 139 | + immediately. |
| 140 | + """ |
| 141 | + signal_def_id: int = breach["signal_def_id"] |
| 142 | + |
| 143 | + async with db.pool.acquire() as conn: |
| 144 | + existing = await conn.fetchval( |
| 145 | + """ |
| 146 | + SELECT 1 |
| 147 | + FROM work_order |
| 148 | + WHERE cell_id = $1 |
| 149 | + AND triggered_by_signal_def_id = $2 |
| 150 | + AND created_at > NOW() - INTERVAL '30 minutes' |
| 151 | + AND status NOT IN ('completed', 'cancelled') |
| 152 | + LIMIT 1 |
| 153 | + """, |
| 154 | + cell_id, |
| 155 | + signal_def_id, |
| 156 | + ) |
| 157 | + if existing: |
| 158 | + log.debug( |
| 159 | + "Sentinel debounced cell=%d signal=%d — open WO in last 30 min", |
| 160 | + cell_id, |
| 161 | + signal_def_id, |
| 162 | + ) |
| 163 | + return |
| 164 | + |
| 165 | + wo = must( |
| 166 | + await WorkOrderRepository(conn).create( |
| 167 | + { |
| 168 | + "cell_id": cell_id, |
| 169 | + "status": "detected", |
| 170 | + "priority": "high", |
| 171 | + "title": f"Anomaly detected — {breach['display_name']}", |
| 172 | + "generated_by_agent": True, |
| 173 | + "trigger_anomaly_time": datetime.fromisoformat(breach["time"]), |
| 174 | + "triggered_by_signal_def_id": signal_def_id, |
| 175 | + } |
| 176 | + ), |
| 177 | + what="work_order row just inserted", |
| 178 | + ) |
| 179 | + |
| 180 | + wo_id: int = wo["id"] |
| 181 | + |
| 182 | + # turn_id correlates the anomaly_detected + alert_banner frames in the |
| 183 | + # frontend Activity Feed / Agent Inspector. Sentinel runs outside an |
| 184 | + # agent turn so it mints a fresh id here rather than reading the |
| 185 | + # WSManager ContextVar (which is reserved for actual agent turns). |
| 186 | + turn_id = uuid.uuid4().hex |
| 187 | + |
| 188 | + await ws_manager.broadcast( |
| 189 | + "anomaly_detected", |
| 190 | + { |
| 191 | + "cell_id": cell_id, |
| 192 | + "signal_def_id": signal_def_id, |
| 193 | + "value": breach["value"], |
| 194 | + "threshold": breach["threshold_value"], |
| 195 | + "work_order_id": wo_id, |
| 196 | + "time": breach["time"], |
| 197 | + "severity": breach["severity"], |
| 198 | + "direction": breach["direction"], |
| 199 | + }, |
| 200 | + ) |
| 201 | + await ws_manager.broadcast( |
| 202 | + "ui_render", |
| 203 | + { |
| 204 | + "agent": "sentinel", |
| 205 | + "component": "alert_banner", |
| 206 | + "props": { |
| 207 | + "cell_id": cell_id, |
| 208 | + "severity": breach["severity"], |
| 209 | + "message": ( |
| 210 | + f"{cell_name}: {breach['display_name']} = {breach['value']} " |
| 211 | + f"({breach['threshold_field']} {breach['threshold_value']})" |
| 212 | + ), |
| 213 | + "anomaly_id": wo_id, |
| 214 | + }, |
| 215 | + "turn_id": turn_id, |
| 216 | + }, |
| 217 | + ) |
| 218 | + |
| 219 | + _spawn_investigator(wo_id) |
| 220 | + |
| 221 | + |
| 222 | +def _spawn_investigator(work_order_id: int) -> None: |
| 223 | + """Kick off the Investigator agent in the background. |
| 224 | +
|
| 225 | + Lazy import: #25 will ship ``agents.investigator.run_investigator``. |
| 226 | + Until then the ImportError branch keeps Sentinel independent of the |
| 227 | + Investigator so it can be merged and demoed on its own — the WO |
| 228 | + simply stays in ``status='detected'`` with no RCA attached. |
| 229 | + """ |
| 230 | + try: |
| 231 | + from agents.investigator import run_investigator # type: ignore[import-not-found] |
| 232 | + except ImportError: |
| 233 | + log.info( |
| 234 | + "Sentinel: Investigator not yet implemented (#25) — WO %d left in status=detected", |
| 235 | + work_order_id, |
| 236 | + ) |
| 237 | + return |
| 238 | + |
| 239 | + asyncio.create_task( |
| 240 | + run_investigator(work_order_id), |
| 241 | + name=f"investigator-wo-{work_order_id}", |
| 242 | + ) |
0 commit comments