fix(trigger): runaway-log alerts expire 24h like every other mute (DPLAN-0256 backlog) — _write_alert hardcoded expires_at None, alerts nagged forever; DEFAULT_ALERT_TTL_SECONDS=86400 + forever escape hatch. 2 new tests, 621 green, audit 100. Built by @trigger, devpulse-verified (diff read + suite re-run)

This commit is contained in:
AIOSAI
2026-07-21 18:19:37 -07:00
parent 42f56f2cc4
commit 37a26ea86c
3 changed files with 61 additions and 5 deletions
+8
View File
@@ -11,6 +11,14 @@ PyPI version — not the changelog header.
## [2026-07-21]
**fix(trigger)** — runaway-log alerts get the 24h TTL every other mute already
had (DPLAN-0256 backlog clear): `_write_alert()` hardcoded `expires_at: None`,
so alerts.json entries nagged forever while medic branch mutes self-expired.
New `DEFAULT_ALERT_TTL_SECONDS = 86400` (matches medic_state's
`DEFAULT_MUTE_SECONDS`) with a `forever` escape hatch threaded through
`handle_runaway_log_detected()`. 2 new tests, trigger suite 621 green,
audit 100%. Built by @trigger.
**feat(drone)** — joint-decision gate on `drone @git merge` (DPLAN-0256,
Patrick ruling S330: merges are always done together, never accidental).
The gate sits in `_handle_merge` before the plugin import — `merge_pr()` is
@@ -3,7 +3,7 @@
# Description: Runaway log event handler with per-file cooldown gating
# Version: 1.0.0
# Created: 2026-07-14
# Modified: 2026-07-14
# Modified: 2026-07-21
# =============================================
"""
@@ -24,12 +24,16 @@ Gating:
- Per-file cooldown (30min default) — independent of medic circuit breaker
- Branch mute check (reuses TTL mute infrastructure from trigger_config.json)
- UNKNOWN/missing branch → dispatch to @prax as fallback
Alerts written to .aipass/alerts.json expire after 24h by default (same TTL
convention as medic_state.py's DEFAULT_MUTE_SECONDS) — pass forever=True to
skip expiry.
"""
import json
import time
import uuid
from datetime import datetime
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Callable, Optional
@@ -71,6 +75,7 @@ _send_email: Optional[Callable[..., bool]] = None
_file_cooldowns: dict[str, float] = {}
COOLDOWN_SECONDS = 1800
DEFAULT_ALERT_TTL_SECONDS = 86400 # 24 hours — matches medic_state.py DEFAULT_MUTE_SECONDS
def set_send_email_callback(callback: Callable[..., bool]) -> None:
@@ -163,7 +168,9 @@ def _write_suppression_log(reason: str, file_path: str, branch: str) -> None:
_log_warning(f"suppression log write failed ({reason}): {exc}")
def _write_alert(file_path: str, severity: str, branch: str, rate: float, duration: float) -> None:
def _write_alert(
file_path: str, severity: str, branch: str, rate: float, duration: float, forever: bool = False
) -> None:
"""Write an alert entry to .aipass/alerts.json.
Args:
@@ -172,8 +179,10 @@ def _write_alert(file_path: str, severity: str, branch: str, rate: float, durati
branch: Responsible branch name
rate: Lines per minute
duration: Sustained duration in seconds
forever: If True, alert never auto-expires (default: 24h TTL)
"""
try:
expires_at = None if forever else (datetime.now() + timedelta(seconds=DEFAULT_ALERT_TTL_SECONDS)).isoformat()
alert = {
"id": str(uuid.uuid4()),
"source": "prax",
@@ -183,7 +192,7 @@ def _write_alert(file_path: str, severity: str, branch: str, rate: float, durati
f"Log file {file_path} producing {rate:.0f} lines/min sustained {duration:.0f}s. Branch: {branch}."
),
"created_at": datetime.now().isoformat(),
"expires_at": None,
"expires_at": expires_at,
}
ALERTS_FILE.parent.mkdir(parents=True, exist_ok=True)
with json_file_lock(ALERTS_FILE):
@@ -204,6 +213,7 @@ def handle_runaway_log_detected(
sustained_duration_sec: float = 0,
severity: str = "warning",
branch: str | None = None,
forever: bool = False,
**kwargs: Any,
) -> None:
"""Handle runaway_log_detected event — dispatch to responsible branch.
@@ -217,6 +227,7 @@ def handle_runaway_log_detected(
sustained_duration_sec: How long the rate has been sustained
severity: "warning" or "critical"
branch: Responsible branch name (None/UNKNOWN → dispatch to @prax)
forever: If True, the resulting alert never auto-expires (default: 24h TTL)
**kwargs: Additional event data (ignored)
"""
try:
@@ -276,7 +287,7 @@ def handle_runaway_log_detected(
except Exception:
pass # Email in inbox as fallback
_write_alert(file_path, severity, target_branch, rate_lines_per_min, sustained_duration_sec)
_write_alert(file_path, severity, target_branch, rate_lines_per_min, sustained_duration_sec, forever=forever)
_record_file_dispatch(file_path)
json_handler.log_operation("runaway_dispatch_sent", {"recipient": recipient, "file": file_path})
@@ -2,6 +2,7 @@
import json
import sys
from datetime import datetime, timedelta
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -306,6 +307,42 @@ class TestAlertFileSchema:
assert alert["body"]
assert alert["created_at"]
def test_alert_defaults_to_24h_ttl(self, tmp_path: Path) -> None:
"""expires_at defaults to ~24h from now, not None."""
_setup_happy_path()
mod.handle_runaway_log_detected(
file_path="/var/log/test.log",
branch="flow",
rate_lines_per_min=500,
sustained_duration_sec=60,
)
alerts_file = tmp_path / "alerts.json"
data = json.loads(alerts_file.read_text(encoding="utf-8"))
expires_at = data["alerts"][0]["expires_at"]
assert expires_at is not None
expires_dt = datetime.fromisoformat(expires_at)
delta = expires_dt - datetime.now()
assert timedelta(hours=23) < delta <= timedelta(hours=24, minutes=1)
def test_alert_forever_true_sets_no_expiry(self, tmp_path: Path) -> None:
"""forever=True writes expires_at=None (permanent alert)."""
_setup_happy_path()
mod.handle_runaway_log_detected(
file_path="/var/log/test.log",
branch="flow",
rate_lines_per_min=500,
sustained_duration_sec=60,
forever=True,
)
alerts_file = tmp_path / "alerts.json"
data = json.loads(alerts_file.read_text(encoding="utf-8"))
assert data["alerts"][0]["expires_at"] is None
# ---------------------------------------------------------------------------
# 10. Alert appends — existing alerts preserved when new one appended