"""ORCH-111 TC-07: full tick -> dispatch of the proc_blocking alert (integration). REGRESS: ``Watchdog.tick()`` with a collector that returns a long-lived blocking process must dispatch exactly one ``proc_blocking`` alert through the fake Notifier — even though ``/metrics`` reports no ``stuck`` stage and no hung agent. With the kill-switch OFF the path is inert (byte-for-byte as before ORCH-111). The orchestrator ``/metrics`` envelope is stubbed healthy so ONLY the new signal can fire; the proc collector is stubbed at the module boundary so the real ``_collect_proc`` gate + wrapper still execute. """ from watchdog.collectors import orch as orch_mod from watchdog.collectors import proc as proc_mod from watchdog.config import Config from watchdog.core import Watchdog class _Notifier: def __init__(self): self.sent = [] def send(self, text): self.sent.append(text) return True class _StubDocker: def inspect(self, name): return {"State": {"Status": "running"}} def _healthy_metrics(monkeypatch): env = { "schema_version": 1, "generated_at": "2026-06-15T00:00:00Z", "clk_tck": 100, "agents": [], "stages": [], "queue": {"depth": 0, "counts": {"failed": 0}}, } monkeypatch.setattr( orch_mod, "fetch_metrics", lambda *a, **k: orch_mod.FetchResult(ok=True, envelope=env), ) def _cfg(**kw): base = { "WATCHDOG_TG_BOT_TOKEN": "t", "WATCHDOG_TG_CHAT_ID": "c", "WATCHDOG_PROC_ENABLED": "true", "WATCHDOG_PROC_AGE_MIN": "60", # proc_age_s == 3600 "WATCHDOG_CONTAINERS": "orchestrator", } return Config.from_env({**base, **kw}) def _blocking(monkeypatch, age_s=7200.0): rec = {"pid": 4242, "cmdline": "python3 -m pytest tests/test_install_lite_script.py", "age_s": age_s, "cpu_s": 99999.0, "start_ticks": 1} monkeypatch.setattr(proc_mod, "collect_candidates", lambda *a, **k: [rec]) def _proc_alerts(notifier): return [m for m in notifier.sent if "Блокирующий процесс" in m] def test_tc07_tick_dispatches_proc_blocking_alert(monkeypatch): _healthy_metrics(monkeypatch) _blocking(monkeypatch) notifier = _Notifier() dog = Watchdog(_cfg(), notifier=notifier, docker=_StubDocker(), now_provider=lambda: 0.0) dog.tick() alerts = _proc_alerts(notifier) assert len(alerts) == 1 assert "4242" in alerts[0] assert "pytest" in alerts[0] assert alerts[0].startswith("\U0001f534") # red ALERT prefix def test_tc07_killswitch_off_dispatches_nothing(monkeypatch): _healthy_metrics(monkeypatch) # Even if the collector WOULD return a blocking process, the gate skips it. called = {"n": 0} def _boom(*a, **k): called["n"] += 1 return [{"pid": 1, "cmdline": "pytest", "age_s": 9e9, "cpu_s": 0.0}] monkeypatch.setattr(proc_mod, "collect_candidates", _boom) notifier = _Notifier() dog = Watchdog( _cfg(WATCHDOG_PROC_ENABLED="false"), notifier=notifier, docker=_StubDocker(), now_provider=lambda: 0.0, ) dog.tick() assert _proc_alerts(notifier) == [] assert called["n"] == 0 # collector never invoked when disabled (zero overhead) def test_tc07_in_budget_process_does_not_alert(monkeypatch): # A process below the threshold (legitimate in-flight run) -> no alert (AC-4). _healthy_metrics(monkeypatch) _blocking(monkeypatch, age_s=600.0) notifier = _Notifier() dog = Watchdog(_cfg(), notifier=notifier, docker=_StubDocker(), now_provider=lambda: 0.0) dog.tick() assert _proc_alerts(notifier) == [] def test_tc07_tick_never_raises_when_collector_explodes(monkeypatch): _healthy_metrics(monkeypatch) def _explode(*a, **k): raise RuntimeError("boom") monkeypatch.setattr(proc_mod, "collect_candidates", _explode) notifier = _Notifier() dog = Watchdog(_cfg(), notifier=notifier, docker=_StubDocker(), now_provider=lambda: 0.0) dog.tick() # must not raise — collector error degrades to one skipped signal assert _proc_alerts(notifier) == []