allarmi: il marcatore "gia' detto" si scrive DOPO l'invio, non prima

Debito §5.2 chiuso su decisione dell'operatore. `run_once` salvava lo stato coi
marcatori `alerted` gia' a True e l'invio lo faceva il chiamante DOPO: col 6,9%
di invii falliti misurato (2 su 29), un 🚨 perso restava perso per l'EPISODIO
INTERO — l'ora dopo lo stato diceva "gia' detto" e usciva WATCH/MUTO. Gli
episodi storici durano 200-2.324 ore, quindi il buco non era teorico.

- `run_once(state_path, sender=None)`: il sender e' INIETTATO, non importato —
  e' cio' che tiene la funzione testabile senza rete d'uscita, che era la
  ragione del disegno precedente. Senza sender il comportamento resta quello di
  prima e il report lo DICE (`invio`), invece di lasciar credere che qualcosa
  sia partito.
- Su invio fallito si disfano SOLO i marcatori "gia' detto", non le misure:
  · asset in ALERT -> alerted=False, l'ora dopo ri-allerta;
  · lock MAINT/ALERT -> alerted_soft/hard=False ma le ORE restano a correre,
    cosi' una manutenzione che sfora la grazia sale ad ALERT anche col trasporto
    giu' (disfare anche le ore congelerebbe l'escalation proprio mentre non si
    riesce a parlare);
  · lock RIENTRATO -> si ripristina l'intero LockState, perche' il rientro si
    annuncia una volta sola e senza le ore non ci sarebbe piu' niente da dire.
- `notify(..., tentativi=)`: il retry esisteva in `send` e non arrivava qui.
  venue_watch ora manda con 3 tentativi.
- L'esito dell'invio finisce nel log del cron invece di sparire.

5 test nuovi. Il primo e' quello che conta — dopo un invio fallito, l'ora dopo
ri-allerta — col suo controllo positivo (un invio riuscito consuma l'allarme
UNA volta sola), senza il quale "ri-allerta sempre" passerebbe.

Suite: 782 passati, 2 falliti (i due del gate GTAA, non toccati qui).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-08-28 12:34:44 +00:00
parent c21a0d4058
commit 801bd13f10
4 changed files with 166 additions and 21 deletions
+7 -3
View File
@@ -76,8 +76,12 @@ def send(text: str, tentativi: int = 1, pausa_s: float = 2.0) -> bool:
return False
def notify(title: str, data: dict | None = None) -> bool:
"""Invia un alert formattato. Ritorna True se inviato (config presente + rete ok)."""
def notify(title: str, data: dict | None = None, tentativi: int = 1) -> bool:
"""Invia un alert formattato. Ritorna True se inviato (config presente + rete ok).
`tentativi` DEFAULT 1 = comportamento invariato per tutti i chiamanti esistenti; chi manda
un allarme che non puo' permettersi di perdere lo alza (venue_watch: 3).
"""
try:
from src.version import APP_VERSION
ver = f" <code>v{APP_VERSION}</code>"
@@ -86,7 +90,7 @@ def notify(title: str, data: dict | None = None) -> bool:
lines = [f"<b>{title}</b>{ver}"]
for k, v in (data or {}).items():
lines.append(f" {k}: {v}")
return send("\n".join(lines))
return send("\n".join(lines), tentativi=tentativi)
def is_configured() -> bool:
+47 -3
View File
@@ -299,10 +299,33 @@ def observe() -> dict:
return out
def run_once(state_path: Path = STATE_PATH) -> dict:
"""Un giro completo: osserva, avanza lo stato, ritorna il report (NON invia nulla).
L'invio Telegram lo fa il chiamante, cosi' questa resta testabile senza rete d'uscita."""
def run_once(state_path: Path = STATE_PATH, sender=None) -> dict:
"""Un giro completo: osserva, avanza lo stato, INVIA (se gli si da' un `sender`), salva.
PERCHE' L'INVIO E' ENTRATO QUI. Fino al 2026-08-28 questa funzione salvava lo stato — coi
marcatori `alerted` gia' a True — e l'invio lo faceva il chiamante DOPO. Con un tasso di
invii falliti misurato del 6,9% (2 su 29), un 🚨 perso restava perso per l'EPISODIO INTERO:
l'ora dopo lo stato diceva «gia' detto» e usciva WATCH/MUTO. Gli episodi storici durano
200-2.324 ore, quindi il buco non e' teorico. Il marcatore «gia' detto» adesso si scrive
solo se qualcuno l'ha davvero sentito.
`sender(report) -> (ok: bool, motivo: str)`. Iniettato, non importato: e' cio' che tiene
questa funzione testabile senza rete d'uscita, che era la ragione del disegno precedente.
Senza `sender` il comportamento resta quello di prima e il report lo DICE (`invio`), invece
di lasciar credere che qualcosa sia stato spedito.
COSA SI DISFA SU UN INVIO FALLITO — solo i marcatori «gia' detto», non le misure:
· asset in ALERT -> `alerted=False`: l'ora dopo ri-allerta;
· lock MAINT / ALERT -> `alerted_soft`/`alerted_hard`=False ma le ORE restano a correre,
cosi' una manutenzione che sfora la grazia sale ad ALERT anche se il trasporto e' giu'
(disfare anche le ore congelerebbe l'escalation proprio mentre non si riesce a parlare);
· lock RIENTRATO -> si ripristina l'intero LockState precedente, perche' il rientro
si annuncia una volta sola e senza le ore a zero non ci sarebbe piu' un rientro da dire.
Restano fuori BLIND (si ripete ogni ora da solo) e il livello WATCH (non consuma nulla).
"""
st = load_state(state_path)
prima_lock = LockState(**asdict(st.lock)) # per disfare il rientro, che si dice una volta
asset_alertati: list[str] = []
obs = observe()
report = {"ts": obs["ts"], "platform_locked": obs["platform_locked"],
"locked_raw": obs.get("locked_raw"), "maintenance": obs.get("maintenance", False),
@@ -313,6 +336,7 @@ def run_once(state_path: Path = STATE_PATH) -> dict:
st.assets[a] = new
report["levels"][a] = level
if level == "ALERT":
asset_alertati.append(a)
report["alerts"].append(
f"{a}: scarto {o['bps']:+.0f} bps vs consenso ({o['n_refs']} referenze) "
f"persistente da {new.streak_hours}h a segno costante")
@@ -345,5 +369,25 @@ def run_once(state_path: Path = STATE_PATH) -> dict:
report["severity"] = report["severity"] or "warn"
st.last_ts = obs["ts"]
# --- l'invio, e il commit dei marcatori SOLO se e' andato ---------------------------------
if not report["alerts"]:
report["invio"] = "niente da dire"
elif sender is None:
report["invio"] = ("non tentato (nessun sender): i marcatori 'gia' detto' restano "
"impostati, l'invio e' responsabilita' del chiamante")
else:
ok, motivo = sender(report)
report["invio"] = "inviato" if ok else f"FALLITO — {motivo}"
if not ok:
for a in asset_alertati:
st.assets[a].alerted = False
if lock_lvl == "MAINT":
st.lock.alerted_soft = False
elif lock_lvl == "ALERT":
st.lock.alerted_soft = st.lock.alerted_hard = False
elif lock_lvl == "RIENTRATO":
st.lock = prima_lock
save_state(st, state_path)
return report