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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user