Skip to content

Commit f28d383

Browse files
authored
Merge pull request #93 from zestones/24-m42-sentinel-asyncio-loop
24 m42 sentinel asyncio loop
2 parents 924a6e5 + 5cae5d9 commit f28d383

3 files changed

Lines changed: 675 additions & 0 deletions

File tree

backend/agents/sentinel.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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

Comments
 (0)