feat(canary): single-shot on_pause_callback + wire Telegram drift alert
Canary auto-pause was silent: when drift > threshold the module flipped to paused without any user-facing notification, leaving the user to wonder why detection went dark. Add an optional on_pause_callback invoked exactly once per not_paused→paused transition. Wrap the call in try/except so a notifier failure can never break the detection cycle. main.py wires the callback to emit canary_drift_paused audit event plus a warn Alert guiding the user toward /resume or recalibration. Tests: test_canary_pause_callback_fires_once (idempotent), test_canary_resume_allows_new_pause_notification (re-arms after resume), test_canary_pause_callback_exception_does_not_crash_check (safety). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -140,6 +140,52 @@ def test_pause_file_written(tmp_path: Path) -> None:
|
||||
assert flag.exists()
|
||||
|
||||
|
||||
def test_canary_pause_callback_fires_once() -> None:
|
||||
"""Single-shot: callback invoked exactly once per not_paused→paused edge."""
|
||||
cfg = _cfg_with_baseline(BASELINE_FRAME)
|
||||
calls: list[int] = []
|
||||
|
||||
canary = Canary(cfg, on_pause_callback=lambda d: calls.append(d))
|
||||
|
||||
canary.check(DRIFTED_FRAME) # transition → callback fires
|
||||
canary.check(DRIFTED_FRAME) # still paused → no new callback
|
||||
canary.check(BASELINE_FRAME) # clean but still paused → no new callback
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0] > 0 # distance should be positive
|
||||
|
||||
|
||||
def test_canary_resume_allows_new_pause_notification() -> None:
|
||||
"""After resume, a fresh drift must re-fire the callback."""
|
||||
cfg = _cfg_with_baseline(BASELINE_FRAME)
|
||||
calls: list[int] = []
|
||||
|
||||
canary = Canary(cfg, on_pause_callback=lambda d: calls.append(d))
|
||||
|
||||
canary.check(DRIFTED_FRAME)
|
||||
assert len(calls) == 1
|
||||
|
||||
canary.resume()
|
||||
canary.check(DRIFTED_FRAME) # new pause transition
|
||||
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_canary_pause_callback_exception_does_not_crash_check() -> None:
|
||||
"""A failing callback must not break canary.check (detection cycle safety)."""
|
||||
cfg = _cfg_with_baseline(BASELINE_FRAME)
|
||||
|
||||
def _boom(_d: int) -> None:
|
||||
raise RuntimeError("notifier down")
|
||||
|
||||
canary = Canary(cfg, on_pause_callback=_boom)
|
||||
|
||||
# Must not raise — exception is swallowed + logged.
|
||||
result = canary.check(DRIFTED_FRAME)
|
||||
assert result.paused is True
|
||||
assert canary.is_paused is True
|
||||
|
||||
|
||||
def test_resume_deletes_pause_file(tmp_path: Path) -> None:
|
||||
"""resume() deletes the pause flag file."""
|
||||
flag = tmp_path / "paused.flag"
|
||||
|
||||
Reference in New Issue
Block a user